Files
TA-Deployment-Broker/internal/app/authorization.go
T
David Schroeder e19b23de81 update
2026-07-25 15:08:37 -05:00

462 lines
13 KiB
Go

package app
import (
"context"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
)
func (s *Server) handlePortal(w http.ResponseWriter, r *http.Request) {
tech, err := s.currentTechnician(r)
if err != nil {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
packages, err := s.listPackages(r)
if err != nil {
http.Error(w, "unable to list packages", http.StatusInternalServerError)
return
}
authorizations, err := s.listAuthorizations(r)
if err != nil {
http.Error(w, "unable to list authorizations", http.StatusInternalServerError)
return
}
actions, err := s.listActions(r)
if err != nil {
http.Error(w, "unable to list installer actions", http.StatusInternalServerError)
return
}
auditEvents, err := s.listAuditEvents(r)
if err != nil {
http.Error(w, "unable to list audit events", http.StatusInternalServerError)
return
}
s.render(w, "portal.html", pageData{
Title: "Deployment Access",
Technician: tech,
CSRFToken: tech.CSRFToken,
Authorizations: authorizations,
AuditEvents: auditEvents,
Packages: packages,
Actions: actions,
NewCode: r.URL.Query().Get("code"),
DefaultHostLimit: s.cfg.DefaultHostLimit,
DefaultDuration: strconv.Itoa(int(s.cfg.DefaultDuration.Hours())),
Notice: r.URL.Query().Get("notice"),
})
}
func (s *Server) listActions(r *http.Request) ([]actionRecord, error) {
rows, err := s.db.QueryContext(
r.Context(),
`SELECT slug, display_name, enabled
FROM installer_actions
ORDER BY sort_order, display_name`,
)
if err != nil {
return nil, err
}
defer rows.Close()
var records []actionRecord
for rows.Next() {
var record actionRecord
if err := rows.Scan(&record.Slug, &record.DisplayName, &record.Enabled); err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}
func (s *Server) listAuditEvents(r *http.Request) ([]auditRecord, error) {
rows, err := s.db.QueryContext(
r.Context(),
`SELECT event_type, actor, hostname, package_slug, source_ip, details, created_at
FROM audit_events
ORDER BY created_at DESC
LIMIT 100`,
)
if err != nil {
return nil, err
}
defer rows.Close()
var records []auditRecord
for rows.Next() {
var record auditRecord
if err := rows.Scan(
&record.EventType,
&record.Actor,
&record.Hostname,
&record.PackageSlug,
&record.SourceIP,
&record.Details,
&record.CreatedAt,
); err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}
func (s *Server) listPackages(r *http.Request) ([]packageRecord, error) {
rows, err := s.db.QueryContext(
r.Context(),
`SELECT id, slug, display_name, package_name, package_version,
file_name, sha256, enabled
FROM packages
ORDER BY display_name, package_version`,
)
if err != nil {
return nil, err
}
defer rows.Close()
var records []packageRecord
for rows.Next() {
var record packageRecord
if err := rows.Scan(
&record.ID,
&record.Slug,
&record.DisplayName,
&record.PackageName,
&record.PackageVersion,
&record.FileName,
&record.SHA256,
&record.Enabled,
); err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}
func (s *Server) listAuthorizations(r *http.Request) ([]authorizationRecord, error) {
rows, err := s.db.QueryContext(
r.Context(),
`SELECT a.id, a.code_hint, a.created_by, a.customer_label,
a.host_limit,
(SELECT COUNT(*) FROM authorization_hosts h
WHERE h.authorization_id = a.id),
a.created_at, a.expires_at, a.revoked_at,
COALESCE((
SELECT GROUP_CONCAT(ap.display_name
ORDER BY ap.display_name SEPARATOR ', ')
FROM authorization_packages ap
WHERE ap.authorization_id = a.id
), ''),
COALESCE((
SELECT GROUP_CONCAT(ia.display_name
ORDER BY ia.sort_order, ia.display_name SEPARATOR ', ')
FROM authorization_actions aa
JOIN installer_actions ia ON ia.slug = aa.action_slug
WHERE aa.authorization_id = a.id
), '')
FROM authorizations a
WHERE a.created_at > UTC_TIMESTAMP(6) - INTERVAL 30 DAY
ORDER BY a.created_at DESC
LIMIT 100`,
)
if err != nil {
return nil, err
}
defer rows.Close()
var records []authorizationRecord
for rows.Next() {
var record authorizationRecord
if err := rows.Scan(
&record.ID,
&record.CodeHint,
&record.CreatedBy,
&record.CustomerLabel,
&record.HostLimit,
&record.HostCount,
&record.CreatedAt,
&record.ExpiresAt,
&record.RevokedAt,
&record.Packages,
&record.Actions,
); err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}
func (s *Server) handleCreateAuthorization(w http.ResponseWriter, r *http.Request) {
tech, _ := s.currentTechnician(r)
hostLimit, err := strconv.Atoi(r.FormValue("host_limit"))
if err != nil || hostLimit < 1 || hostLimit > s.cfg.MaxHostLimit {
http.Error(w, "invalid host limit", http.StatusBadRequest)
return
}
durationHours, err := strconv.Atoi(r.FormValue("duration_hours"))
if err != nil || durationHours < 1 || durationHours > 24 {
http.Error(w, "duration must be between 1 and 24 hours", http.StatusBadRequest)
return
}
packageIDs := r.Form["package_id"]
actionSlugs := r.Form["action_slug"]
if len(packageIDs) == 0 && len(actionSlugs) == 0 {
http.Error(w, "select at least one package or installer action", http.StatusBadRequest)
return
}
code, err := deploymentCode()
if err != nil {
http.Error(w, "unable to generate code", http.StatusInternalServerError)
return
}
codeHash := hashValue(code)
expiresAt := time.Now().UTC().Add(time.Duration(durationHours) * time.Hour)
customerLabel := strings.TrimSpace(r.FormValue("customer_label"))
if len(customerLabel) > 255 {
http.Error(w, "customer label is too long", http.StatusBadRequest)
return
}
tx, err := s.db.BeginTx(r.Context(), nil)
if err != nil {
http.Error(w, "unable to create authorization", http.StatusInternalServerError)
return
}
defer tx.Rollback()
result, err := tx.ExecContext(
r.Context(),
`INSERT INTO authorizations
(code_hash, code_hint, created_by, customer_label, host_limit, expires_at)
VALUES (?, ?, ?, ?, ?, ?)`,
codeHash[:], code[len(code)-5:], tech.Login, customerLabel, hostLimit, expiresAt,
)
if err != nil {
http.Error(w, "unable to create authorization", http.StatusInternalServerError)
return
}
authorizationID, _ := result.LastInsertId()
for _, rawID := range packageIDs {
packageID, err := strconv.ParseUint(rawID, 10, 64)
if err != nil {
http.Error(w, "invalid package selection", http.StatusBadRequest)
return
}
result, err := tx.ExecContext(
r.Context(),
`INSERT INTO authorization_packages
(authorization_id, package_id, package_slug, display_name,
package_name, package_version, file_name, sha256)
SELECT ?, id, slug, display_name, package_name, package_version,
file_name, sha256
FROM packages
WHERE id = ? AND enabled = TRUE`,
authorizationID, packageID,
)
if err != nil {
http.Error(w, "unable to authorize package", http.StatusInternalServerError)
return
}
affected, _ := result.RowsAffected()
if affected != 1 {
http.Error(w, "selected package is unavailable", http.StatusBadRequest)
return
}
}
for _, actionSlug := range actionSlugs {
actionSlug = strings.TrimSpace(actionSlug)
if !validSlug(actionSlug) {
http.Error(w, "invalid installer action", http.StatusBadRequest)
return
}
result, err := tx.ExecContext(
r.Context(),
`INSERT INTO authorization_actions (authorization_id, action_slug)
SELECT ?, slug FROM installer_actions
WHERE slug = ? AND enabled = TRUE`,
authorizationID, actionSlug,
)
if err != nil {
http.Error(w, "unable to authorize installer action", http.StatusInternalServerError)
return
}
affected, _ := result.RowsAffected()
if affected != 1 {
http.Error(w, "selected installer action is unavailable", http.StatusBadRequest)
return
}
}
if err := tx.Commit(); err != nil {
http.Error(w, "unable to save authorization", http.StatusInternalServerError)
return
}
id := uint64(authorizationID)
_ = s.audit(r.Context(), "authorization_created", tech.Login, &id, "", "", s.clientIP(r),
fmt.Sprintf("host_limit=%d duration_hours=%d customer=%q", hostLimit, durationHours, customerLabel))
http.Redirect(w, r, "/portal?code="+code, http.StatusSeeOther)
}
func (s *Server) handleRevokeAuthorization(w http.ResponseWriter, r *http.Request) {
tech, _ := s.currentTechnician(r)
id, err := parseUintPath(r, "id")
if err != nil {
http.Error(w, "invalid authorization", http.StatusBadRequest)
return
}
result, err := s.db.ExecContext(
r.Context(),
`UPDATE authorizations
SET revoked_at = COALESCE(revoked_at, UTC_TIMESTAMP(6))
WHERE id = ?`,
id,
)
if err != nil {
http.Error(w, "unable to revoke authorization", http.StatusInternalServerError)
return
}
affected, _ := result.RowsAffected()
if affected == 0 {
http.Error(w, "authorization not found", http.StatusNotFound)
return
}
_, _ = s.db.ExecContext(
r.Context(),
`UPDATE download_sessions SET revoked_at = UTC_TIMESTAMP(6)
WHERE authorization_id = ? AND revoked_at IS NULL`,
id,
)
_ = s.audit(r.Context(), "authorization_revoked", tech.Login, &id, "", "", s.clientIP(r), "")
http.Redirect(w, r, "/portal?notice=Authorization+revoked", http.StatusSeeOther)
}
func (s *Server) handleUpsertPackage(w http.ResponseWriter, r *http.Request) {
tech, _ := s.currentTechnician(r)
fields := []string{
"slug", "display_name", "package_version", "file_name", "sha256",
}
values := make(map[string]string)
for _, field := range fields {
values[field] = strings.TrimSpace(r.FormValue(field))
if values[field] == "" {
http.Error(w, field+" is required", http.StatusBadRequest)
return
}
}
if !validSlug(values["slug"]) ||
len(values["display_name"]) > 255 ||
!validRegistrySegment(values["package_version"], 100) ||
!validRegistrySegment(values["file_name"], 255) ||
!validSHA256(values["sha256"]) {
http.Error(w, "invalid package metadata", http.StatusBadRequest)
return
}
enabled := r.FormValue("enabled") == "1"
err := s.savePackage(
r,
values["slug"],
values["display_name"],
values["slug"],
values["package_version"],
values["file_name"],
strings.ToLower(values["sha256"]),
enabled,
)
if err != nil {
http.Error(w, "unable to save package", http.StatusInternalServerError)
return
}
_ = s.audit(r.Context(), "package_saved", tech.Login, nil, "", values["slug"], s.clientIP(r),
fmt.Sprintf("version=%s enabled=%t", values["package_version"], enabled))
http.Redirect(w, r, "/portal?notice=Package+saved", http.StatusSeeOther)
}
func (s *Server) savePackage(
r *http.Request,
slug string,
displayName string,
packageName string,
packageVersion string,
fileName string,
sha256 string,
enabled bool,
) error {
_, err := s.db.ExecContext(
r.Context(),
`INSERT INTO packages
(slug, display_name, package_name, package_version, file_name, sha256, enabled)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
display_name = VALUES(display_name),
package_name = VALUES(package_name),
package_version = VALUES(package_version),
file_name = VALUES(file_name),
sha256 = VALUES(sha256),
enabled = VALUES(enabled)`,
slug,
displayName,
packageName,
packageVersion,
fileName,
sha256,
enabled,
)
return err
}
func validSlug(value string) bool {
if value == "" || len(value) > 100 {
return false
}
for _, character := range value {
if (character < 'a' || character > 'z') &&
(character < '0' || character > '9') &&
character != '-' && character != '_' {
return false
}
}
return true
}
func validSHA256(value string) bool {
if len(value) != 64 {
return false
}
for _, character := range strings.ToLower(value) {
if (character < '0' || character > '9') &&
(character < 'a' || character > 'f') {
return false
}
}
return true
}
func (s *Server) audit(
ctx context.Context,
eventType string,
actor string,
authorizationID *uint64,
hostname string,
packageSlug string,
sourceIP string,
details string,
) error {
var authID any
if authorizationID != nil {
authID = *authorizationID
}
_, err := s.db.ExecContext(
ctx,
`INSERT INTO audit_events
(event_type, actor, authorization_id, hostname, package_slug, source_ip, details)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
eventType, actor, authID, hostname, packageSlug, sourceIP, details,
)
return err
}
var errAuthorizationDenied = errors.New("authorization denied")