From 510d5490f915547b488d32c4dce3292154e746a4 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Tue, 28 Jul 2026 20:45:34 -0500 Subject: [PATCH] update gui --- .env.example | 2 +- README.md | 6 + docs/deployment.md | 13 ++ install.sh | 2 +- internal/app/authorization.go | 282 ++++++++++++++++++++++++++--- internal/app/authorization_test.go | 209 +++++++++++++++++++++ internal/app/config.go | 16 +- internal/app/config_test.go | 25 +++ internal/app/server.go | 6 +- internal/app/server_test.go | 14 +- internal/app/static/app.css | 6 + internal/app/templates/portal.html | 64 ++++++- tests/test-deployment-scripts.sh | 1 + 13 files changed, 606 insertions(+), 40 deletions(-) create mode 100644 internal/app/authorization_test.go diff --git a/.env.example b/.env.example index fe1e6c2..e46fc20 100644 --- a/.env.example +++ b/.env.example @@ -33,7 +33,7 @@ TAPM_GITEA_PACKAGE_WRITE_USERNAME= TAPM_GITEA_PACKAGE_WRITE_TOKEN= TAPM_ALLOWED_GITEA_USERS=taiadmin TAPM_COOKIE_SECRET= -TAPM_DEFAULT_DURATION=3h +TAPM_DEFAULT_DURATION=7d TAPM_DEFAULT_HOST_LIMIT=3 TAPM_MAX_HOST_LIMIT=25 TAPM_MAX_UPLOAD_BYTES=1073741824 diff --git a/README.md b/README.md index 37fe86c..7494b4a 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,12 @@ HTTP-only Nginx test configuration. It does not invent Gitea OAuth or package credentials. After creating those resources in Gitea, run `./manage.sh configure-broker` and `./manage.sh start`. +New deployment codes default to seven days. While a code is active, the Codes +page can raise or lower its host limit, add or remove packages and installer +actions, and extend its expiration in whole days. The host limit cannot be +reduced below the number of hosts already enrolled, and every change is +recorded in the audit trail. + Once public DNS and TCP 80/443 forwarding are ready, switch the same persistent deployment to Let's Encrypt: diff --git a/docs/deployment.md b/docs/deployment.md index 35d1374..b0e61a3 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -124,6 +124,19 @@ launch the broker until all required credentials are present. OAuth token exchange and package traffic stay on the private Compose network. Do not publish Gitea's internal port to make broker authentication work. +Deployment codes default to seven days: + +```dotenv +TAPM_DEFAULT_DURATION=7d +``` + +The setting accepts day values such as `7d` and legacy Go duration values such +as `168h`. Existing deployments retain their current `.env`; change a previous +`TAPM_DEFAULT_DURATION=3h` value to `7d` and run `./manage.sh start` to adopt +the new default. Individual active codes can be extended from the Codes page. +The same editor can change the maximum host count and add or remove protected +packages and installer actions without issuing a replacement code. + For troubleshooting, use `./manage.sh diagnostics`. Do not share unredacted `docker compose config` output: Compose resolves the broker's `.env` file and can print cookie, OAuth, and package secrets. If configuration output is diff --git a/install.sh b/install.sh index 6585315..99418d2 100755 --- a/install.sh +++ b/install.sh @@ -124,7 +124,7 @@ umask 077 printf 'TAPM_GITEA_PACKAGE_WRITE_TOKEN=\n' printf 'TAPM_ALLOWED_GITEA_USERS=taiadmin\n' printf 'TAPM_COOKIE_SECRET=%s\n' "$cookie_secret" - printf 'TAPM_DEFAULT_DURATION=3h\n' + printf 'TAPM_DEFAULT_DURATION=7d\n' printf 'TAPM_DEFAULT_HOST_LIMIT=3\n' printf 'TAPM_MAX_HOST_LIMIT=25\n' printf 'TAPM_MAX_UPLOAD_BYTES=1073741824\n' diff --git a/internal/app/authorization.go b/internal/app/authorization.go index 2627ed9..8df5775 100644 --- a/internal/app/authorization.go +++ b/internal/app/authorization.go @@ -2,6 +2,7 @@ package app import ( "context" + "database/sql" "errors" "fmt" "net/http" @@ -10,6 +11,8 @@ import ( "time" ) +const maxAuthorizationDays = 90 + func (s *Server) handlePortal(w http.ResponseWriter, r *http.Request) { if hasAuditQuery(r) { http.Redirect(w, r, "/portal/audit?"+r.URL.RawQuery, http.StatusSeeOther) @@ -50,12 +53,18 @@ func (s *Server) handlePortal(w http.ResponseWriter, r *http.Request) { Actions: actions, NewCode: r.URL.Query().Get("code"), DefaultHostLimit: s.cfg.DefaultHostLimit, - DefaultDuration: strconv.Itoa(int(s.cfg.DefaultDuration.Hours())), + DefaultDays: durationDays(s.cfg.DefaultDuration), + MaxHostLimit: s.cfg.MaxHostLimit, Notice: r.URL.Query().Get("notice"), CurrentView: "codes", }) } +func durationDays(duration time.Duration) int { + const day = 24 * time.Hour + return int((duration + day - 1) / day) +} + func hasAuditQuery(r *http.Request) bool { query := r.URL.Query() for _, field := range []string{ @@ -342,16 +351,19 @@ func (s *Server) listAuthorizations(r *http.Request) ([]authorizationRecord, err ), '') FROM authorizations a WHERE a.created_at > datetime('now', '-30 days') + OR (a.revoked_at IS NULL AND a.expires_at > CURRENT_TIMESTAMP) 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 + record := authorizationRecord{ + PackageIDs: make(map[uint64]bool), + ActionSlugs: make(map[string]bool), + } if err := rows.Scan( &record.ID, &record.CodeHint, @@ -369,7 +381,76 @@ func (s *Server) listAuthorizations(r *http.Request) ([]authorizationRecord, err } records = append(records, record) } - return records, rows.Err() + if err := rows.Err(); err != nil { + _ = rows.Close() + return nil, err + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := s.loadAuthorizationSelections(r.Context(), records); err != nil { + return nil, err + } + return records, nil +} + +func (s *Server) loadAuthorizationSelections( + ctx context.Context, + records []authorizationRecord, +) error { + indices := make(map[uint64]int, len(records)) + for index := range records { + indices[records[index].ID] = index + } + if len(indices) == 0 { + return nil + } + + packageRows, err := s.db.QueryContext( + ctx, + `SELECT authorization_id, package_id FROM authorization_packages`, + ) + if err != nil { + return err + } + for packageRows.Next() { + var authorizationID uint64 + var packageID uint64 + if err := packageRows.Scan(&authorizationID, &packageID); err != nil { + _ = packageRows.Close() + return err + } + if index, ok := indices[authorizationID]; ok { + records[index].PackageIDs[packageID] = true + } + } + if err := packageRows.Err(); err != nil { + _ = packageRows.Close() + return err + } + if err := packageRows.Close(); err != nil { + return err + } + + actionRows, err := s.db.QueryContext( + ctx, + `SELECT authorization_id, action_slug FROM authorization_actions`, + ) + if err != nil { + return err + } + defer actionRows.Close() + for actionRows.Next() { + var authorizationID uint64 + var actionSlug string + if err := actionRows.Scan(&authorizationID, &actionSlug); err != nil { + return err + } + if index, ok := indices[authorizationID]; ok { + records[index].ActionSlugs[actionSlug] = true + } + } + return actionRows.Err() } func (s *Server) handleCreateAuthorization(w http.ResponseWriter, r *http.Request) { @@ -379,9 +460,9 @@ func (s *Server) handleCreateAuthorization(w http.ResponseWriter, r *http.Reques 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) + durationDays, err := strconv.Atoi(r.FormValue("duration_days")) + if err != nil || durationDays < 1 || durationDays > maxAuthorizationDays { + http.Error(w, "duration must be between 1 and 90 days", http.StatusBadRequest) return } packageIDs := r.Form["package_id"] @@ -396,7 +477,7 @@ func (s *Server) handleCreateAuthorization(w http.ResponseWriter, r *http.Reques return } codeHash := hashValue(code) - expiresAt := time.Now().UTC().Add(time.Duration(durationHours) * time.Hour) + expiresAt := time.Now().UTC().Add(time.Duration(durationDays) * 24 * time.Hour) customerLabel := strings.TrimSpace(r.FormValue("customer_label")) if len(customerLabel) > 255 { http.Error(w, "customer label is too long", http.StatusBadRequest) @@ -421,14 +502,41 @@ func (s *Server) handleCreateAuthorization(w http.ResponseWriter, r *http.Reques return } authorizationID, _ := result.LastInsertId() + if status, err := saveAuthorizationSelections( + r.Context(), tx, uint64(authorizationID), packageIDs, actionSlugs, + ); err != nil { + http.Error(w, err.Error(), status) + 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_days=%d customer=%q", hostLimit, durationDays, customerLabel)) + http.Redirect(w, r, "/portal?code="+code, http.StatusSeeOther) +} + +func saveAuthorizationSelections( + ctx context.Context, + tx *sql.Tx, + authorizationID uint64, + packageIDs []string, + actionSlugs []string, +) (int, error) { + seenPackages := make(map[uint64]bool) for _, rawID := range packageIDs { packageID, err := strconv.ParseUint(rawID, 10, 64) if err != nil { - http.Error(w, "invalid package selection", http.StatusBadRequest) - return + return http.StatusBadRequest, errors.New("invalid package selection") } + if seenPackages[packageID] { + continue + } + seenPackages[packageID] = true result, err := tx.ExecContext( - r.Context(), + ctx, `INSERT INTO authorization_packages (authorization_id, package_id, package_slug, display_name, package_name, package_version, file_name, sha256) @@ -439,35 +547,148 @@ func (s *Server) handleCreateAuthorization(w http.ResponseWriter, r *http.Reques authorizationID, packageID, ) if err != nil { - http.Error(w, "unable to authorize package", http.StatusInternalServerError) - return + return http.StatusInternalServerError, errors.New("unable to authorize package") } affected, _ := result.RowsAffected() if affected != 1 { - http.Error(w, "selected package is unavailable", http.StatusBadRequest) - return + return http.StatusBadRequest, errors.New("selected package is unavailable") } } + + seenActions := make(map[string]bool) for _, actionSlug := range actionSlugs { actionSlug = strings.TrimSpace(actionSlug) if !validSlug(actionSlug) { - http.Error(w, "invalid installer action", http.StatusBadRequest) - return + return http.StatusBadRequest, errors.New("invalid installer action") } + if seenActions[actionSlug] { + continue + } + seenActions[actionSlug] = true result, err := tx.ExecContext( - r.Context(), + ctx, `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 + return http.StatusInternalServerError, errors.New("unable to authorize installer action") } affected, _ := result.RowsAffected() if affected != 1 { - http.Error(w, "selected installer action is unavailable", http.StatusBadRequest) + return http.StatusBadRequest, errors.New("selected installer action is unavailable") + } + } + return 0, nil +} + +func (s *Server) handleUpdateAuthorization(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 + } + 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 + } + extendDays, err := strconv.Atoi(r.FormValue("extend_days")) + if err != nil || extendDays < 0 || extendDays > maxAuthorizationDays { + http.Error(w, "extension must be between 0 and 90 days", 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 + } + + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + http.Error(w, "unable to update authorization", http.StatusInternalServerError) + return + } + defer tx.Rollback() + + var oldHostLimit int + var hostCount int + var expiresAt time.Time + var revokedAt sql.NullTime + err = tx.QueryRowContext( + r.Context(), + `SELECT a.host_limit, + (SELECT COUNT(*) FROM authorization_hosts h + WHERE h.authorization_id = a.id), + a.expires_at, a.revoked_at + FROM authorizations a + WHERE a.id = ?`, + id, + ).Scan(&oldHostLimit, &hostCount, &expiresAt, &revokedAt) + if errors.Is(err, sql.ErrNoRows) { + http.Error(w, "authorization not found", http.StatusNotFound) + return + } + if err != nil { + http.Error(w, "unable to update authorization", http.StatusInternalServerError) + return + } + if revokedAt.Valid || !time.Now().Before(expiresAt) { + http.Error(w, "closed authorizations cannot be edited", http.StatusBadRequest) + return + } + if hostLimit < hostCount { + http.Error( + w, + fmt.Sprintf("host limit cannot be lower than %d enrolled hosts", hostCount), + http.StatusBadRequest, + ) + return + } + + newExpiresAt := expiresAt.Add(time.Duration(extendDays) * 24 * time.Hour) + if _, err := tx.ExecContext( + r.Context(), + `DELETE FROM authorization_packages WHERE authorization_id = ?`, + id, + ); err != nil { + http.Error(w, "unable to update authorized packages", http.StatusInternalServerError) + return + } + if _, err := tx.ExecContext( + r.Context(), + `DELETE FROM authorization_actions WHERE authorization_id = ?`, + id, + ); err != nil { + http.Error(w, "unable to update installer actions", http.StatusInternalServerError) + return + } + if status, err := saveAuthorizationSelections( + r.Context(), tx, id, packageIDs, actionSlugs, + ); err != nil { + http.Error(w, err.Error(), status) + return + } + if _, err := tx.ExecContext( + r.Context(), + `UPDATE authorizations SET host_limit = ?, expires_at = ? WHERE id = ?`, + hostLimit, newExpiresAt, id, + ); err != nil { + http.Error(w, "unable to update authorization", http.StatusInternalServerError) + return + } + if extendDays > 0 { + if _, err := tx.ExecContext( + r.Context(), + `UPDATE download_sessions + SET expires_at = ? + WHERE authorization_id = ? AND revoked_at IS NULL`, + newExpiresAt, id, + ); err != nil { + http.Error(w, "unable to extend download sessions", http.StatusInternalServerError) return } } @@ -475,10 +696,21 @@ func (s *Server) handleCreateAuthorization(w http.ResponseWriter, r *http.Reques 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) + + _ = s.audit( + r.Context(), + "authorization_updated", + tech.Login, + &id, + "", + "", + s.clientIP(r), + fmt.Sprintf( + "host_limit=%d->%d extend_days=%d packages=%d actions=%d", + oldHostLimit, hostLimit, extendDays, len(packageIDs), len(actionSlugs), + ), + ) + http.Redirect(w, r, "/portal?notice=Authorization+updated", http.StatusSeeOther) } func (s *Server) handleRevokeAuthorization(w http.ResponseWriter, r *http.Request) { diff --git a/internal/app/authorization_test.go b/internal/app/authorization_test.go new file mode 100644 index 0000000..4c0710d --- /dev/null +++ b/internal/app/authorization_test.go @@ -0,0 +1,209 @@ +package app + +import ( + "database/sql" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strings" + "testing" + "time" + + _ "modernc.org/sqlite" +) + +func newAuthorizationTestServer(t *testing.T) (*Server, string) { + t.Helper() + db, err := sql.Open("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared") + if err != nil { + t.Fatal(err) + } + db.SetMaxOpenConns(1) + t.Cleanup(func() { _ = db.Close() }) + migration, err := os.ReadFile("../../migrations/001_initial.sql") + if err != nil { + t.Fatal(err) + } + if _, err := db.Exec(string(migration)); err != nil { + t.Fatal(err) + } + + sessionToken := "authorization-test-session" + sessionHash := hashValue(sessionToken) + if _, err := db.Exec( + `INSERT INTO technician_sessions + (token_hash, csrf_token, gitea_login, display_name, expires_at) + VALUES (?, 'csrf-token', 'taiadmin', 'TAI Admin', ?)`, + sessionHash[:], time.Now().UTC().Add(time.Hour), + ); err != nil { + t.Fatal(err) + } + return &Server{ + db: db, + cfg: Config{ + MaxHostLimit: 25, + CookieSecret: []byte("0123456789abcdef0123456789abcdef"), + }, + }, sessionToken +} + +func seedAuthorizationForUpdate(t *testing.T, server *Server) time.Time { + t.Helper() + for _, values := range [][]any{ + {1, "sentinelone-linux", "SentinelOne", "sentinelone-linux", "1.0", "s1.deb", strings.Repeat("a", 64), true}, + {2, "support-tool", "Support Tool", "support-tool", "2.0", "support.deb", strings.Repeat("b", 64), true}, + } { + if _, err := server.db.Exec( + `INSERT INTO packages + (id, slug, display_name, package_name, package_version, + file_name, sha256, enabled) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + values..., + ); err != nil { + t.Fatal(err) + } + } + + expiresAt := time.Now().UTC().Add(48 * time.Hour).Truncate(time.Second) + if _, err := server.db.Exec( + `INSERT INTO authorizations + (id, code_hash, code_hint, created_by, customer_label, host_limit, expires_at) + VALUES (1, ?, 'ABCDE', 'taiadmin', 'Test migration', 3, ?)`, + make([]byte, 32), expiresAt, + ); err != nil { + t.Fatal(err) + } + if _, err := server.db.Exec( + `INSERT INTO authorization_packages + (authorization_id, package_id, package_slug, display_name, + package_name, package_version, file_name, sha256) + SELECT 1, id, slug, display_name, package_name, package_version, + file_name, sha256 + FROM packages WHERE id = 1`, + ); err != nil { + t.Fatal(err) + } + if _, err := server.db.Exec( + `INSERT INTO authorization_hosts + (id, authorization_id, host_fingerprint, hostname) + VALUES + (1, 1, ?, 'pve01'), + (2, 1, ?, 'pve02')`, + make([]byte, 32), bytesOf(1, 32), + ); err != nil { + t.Fatal(err) + } + if _, err := server.db.Exec( + `INSERT INTO download_sessions + (token_hash, authorization_id, authorization_host_id, expires_at) + VALUES (?, 1, 1, ?)`, + make([]byte, 32), expiresAt, + ); err != nil { + t.Fatal(err) + } + return expiresAt +} + +func authorizationUpdateRequest( + t *testing.T, + server *Server, + sessionToken string, + values url.Values, +) *httptest.ResponseRecorder { + t.Helper() + request := httptest.NewRequest( + http.MethodPost, + "/portal/authorizations/1/update", + strings.NewReader(values.Encode()), + ) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.AddCookie(&http.Cookie{Name: sessionCookieName, Value: sessionToken}) + request.SetPathValue("id", "1") + response := httptest.NewRecorder() + server.handleUpdateAuthorization(response, request) + return response +} + +func TestUpdateAuthorizationExtendsAndReplacesAccess(t *testing.T) { + server, sessionToken := newAuthorizationTestServer(t) + oldExpiresAt := seedAuthorizationForUpdate(t, server) + + response := authorizationUpdateRequest(t, server, sessionToken, url.Values{ + "csrf_token": {"csrf-token"}, + "host_limit": {"6"}, + "extend_days": {"7"}, + "package_id": {"2"}, + "action_slug": {"install-rmm"}, + }) + if response.Code != http.StatusSeeOther { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } + + var hostLimit int + var expiresAt time.Time + if err := server.db.QueryRow( + `SELECT host_limit, expires_at FROM authorizations WHERE id = 1`, + ).Scan(&hostLimit, &expiresAt); err != nil { + t.Fatal(err) + } + if hostLimit != 6 { + t.Fatalf("host limit = %d, want 6", hostLimit) + } + wantExpiresAt := oldExpiresAt.Add(7 * 24 * time.Hour) + if !expiresAt.Equal(wantExpiresAt) { + t.Fatalf("expiration = %s, want %s", expiresAt, wantExpiresAt) + } + + var selectedPackage uint64 + if err := server.db.QueryRow( + `SELECT package_id FROM authorization_packages WHERE authorization_id = 1`, + ).Scan(&selectedPackage); err != nil { + t.Fatal(err) + } + if selectedPackage != 2 { + t.Fatalf("selected package = %d, want 2", selectedPackage) + } + var selectedAction string + if err := server.db.QueryRow( + `SELECT action_slug FROM authorization_actions WHERE authorization_id = 1`, + ).Scan(&selectedAction); err != nil { + t.Fatal(err) + } + if selectedAction != "install-rmm" { + t.Fatalf("selected action = %q, want install-rmm", selectedAction) + } + + var sessionExpiresAt time.Time + if err := server.db.QueryRow( + `SELECT expires_at FROM download_sessions WHERE authorization_id = 1`, + ).Scan(&sessionExpiresAt); err != nil { + t.Fatal(err) + } + if !sessionExpiresAt.Equal(wantExpiresAt) { + t.Fatalf("session expiration = %s, want %s", sessionExpiresAt, wantExpiresAt) + } +} + +func TestUpdateAuthorizationRejectsLimitBelowEnrolledHosts(t *testing.T) { + server, sessionToken := newAuthorizationTestServer(t) + seedAuthorizationForUpdate(t, server) + + response := authorizationUpdateRequest(t, server, sessionToken, url.Values{ + "csrf_token": {"csrf-token"}, + "host_limit": {"1"}, + "extend_days": {"0"}, + "package_id": {"1"}, + }) + if response.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", response.Code, http.StatusBadRequest) + } +} + +func bytesOf(value byte, size int) []byte { + result := make([]byte, size) + for index := range result { + result[index] = value + } + return result +} diff --git a/internal/app/config.go b/internal/app/config.go index a6b6c72..8281222 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -76,8 +76,8 @@ func LoadConfig() (Config, error) { } cfg.CookieSecure = cfg.PublicURL.Scheme == "https" - cfg.DefaultDuration, err = time.ParseDuration( - envDefault("TAPM_DEFAULT_DURATION", "3h"), + cfg.DefaultDuration, err = parseAuthorizationDuration( + envDefault("TAPM_DEFAULT_DURATION", "7d"), ) if err != nil || cfg.DefaultDuration <= 0 { return cfg, fmt.Errorf("TAPM_DEFAULT_DURATION must be positive") @@ -145,6 +145,18 @@ func LoadConfig() (Config, error) { return cfg, nil } +func parseAuthorizationDuration(value string) (time.Duration, error) { + value = strings.TrimSpace(value) + if strings.HasSuffix(value, "d") { + days, err := strconv.Atoi(strings.TrimSuffix(value, "d")) + if err != nil || days < 1 { + return 0, fmt.Errorf("invalid day duration") + } + return time.Duration(days) * 24 * time.Hour, nil + } + return time.ParseDuration(value) +} + func parseAbsoluteURL(name string, allowInsecureHTTP bool) (*url.URL, error) { return parseURLValue(name, os.Getenv(name), allowInsecureHTTP) } diff --git a/internal/app/config_test.go b/internal/app/config_test.go index 2b1f47e..acaec17 100644 --- a/internal/app/config_test.go +++ b/internal/app/config_test.go @@ -3,6 +3,7 @@ package app import ( "strings" "testing" + "time" ) func setRequiredConfigEnvironment(t *testing.T, scheme string) { @@ -63,3 +64,27 @@ func TestConfigAcceptsExplicitInternalGiteaURL(t *testing.T) { t.Fatalf("GiteaInternalURL = %q, want configured private URL", got) } } + +func TestConfigAcceptsDayBasedAuthorizationDuration(t *testing.T) { + setRequiredConfigEnvironment(t, "https") + t.Setenv("TAPM_DEFAULT_DURATION", "7d") + cfg, err := LoadConfig() + if err != nil { + t.Fatal(err) + } + if cfg.DefaultDuration != 7*24*time.Hour { + t.Fatalf("DefaultDuration = %s, want 7 days", cfg.DefaultDuration) + } +} + +func TestConfigKeepsLegacyHourDurationCompatibility(t *testing.T) { + setRequiredConfigEnvironment(t, "https") + t.Setenv("TAPM_DEFAULT_DURATION", "3h") + cfg, err := LoadConfig() + if err != nil { + t.Fatal(err) + } + if cfg.DefaultDuration != 3*time.Hour { + t.Fatalf("DefaultDuration = %s, want 3 hours", cfg.DefaultDuration) + } +} diff --git a/internal/app/server.go b/internal/app/server.go index b0cd6a2..4eb52be 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -66,6 +66,8 @@ type authorizationRecord struct { RevokedAt sql.NullTime Packages string Actions string + PackageIDs map[uint64]bool + ActionSlugs map[string]bool } type auditRecord struct { @@ -103,7 +105,8 @@ type pageData struct { FleetHosts []fleetHostRecord NewCode string DefaultHostLimit int - DefaultDuration string + DefaultDays int + MaxHostLimit int Error string Notice string GiteaLoginURL string @@ -200,6 +203,7 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("GET /portal/audit", s.requireTechnician(s.handleAuditPortal)) mux.HandleFunc("GET /portal/hosts", s.requireTechnician(s.handleFleetPortal)) mux.HandleFunc("POST /portal/authorizations", s.requireTechnician(s.handleCreateAuthorization)) + mux.HandleFunc("POST /portal/authorizations/{id}/update", s.requireTechnician(s.handleUpdateAuthorization)) mux.HandleFunc("POST /portal/authorizations/{id}/revoke", s.requireTechnician(s.handleRevokeAuthorization)) mux.HandleFunc("POST /portal/packages", s.requireTechnician(s.handleUpsertPackage)) mux.HandleFunc("POST /portal/packages/upload", s.requireTechnician(s.handleUploadPackage)) diff --git a/internal/app/server_test.go b/internal/app/server_test.go index da323f4..7d154e0 100644 --- a/internal/app/server_test.go +++ b/internal/app/server_test.go @@ -30,12 +30,16 @@ func TestPortalTemplateExecutes(t *testing.T) { Enabled: true, }}, Authorizations: []authorizationRecord{{ - ID: 1, - CreatedBy: "taiadmin", - ExpiresAt: time.Now().Add(time.Hour), - Packages: "SentinelOne Linux Agent", - Actions: "Install RMM", + ID: 1, + CreatedBy: "taiadmin", + ExpiresAt: time.Now().Add(time.Hour), + Packages: "SentinelOne Linux Agent", + Actions: "Install RMM", + PackageIDs: map[uint64]bool{1: true}, + ActionSlugs: map[string]bool{"install-rmm": true}, }}, + DefaultDays: 7, + MaxHostLimit: 25, }) if err != nil { t.Fatal(err) diff --git a/internal/app/static/app.css b/internal/app/static/app.css index eed7c31..c0c74b0 100644 --- a/internal/app/static/app.css +++ b/internal/app/static/app.css @@ -212,6 +212,12 @@ legend { margin-bottom: 8px; } } .authorization.active .status, .status.enabled { color: var(--accent-dark); border-color: rgba(105, 145, 64, .42); background: rgba(185, 217, 130, .25); } .code-hint { color: var(--amber); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } +.authorization-editor { margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--line); } +.authorization-editor summary { color: var(--accent-dark); cursor: pointer; font-weight: 800; } +.authorization-editor[open] summary { margin-bottom: 18px; } +.authorization-editor fieldset { padding: 14px; } +.authorization-editor .button { justify-self: start; } +.authorization-revoke { margin-top: 14px; } dl { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px 24px; margin: 18px 0; } dl div { min-width: 0; } dt { color: var(--muted); font-size: .7rem; text-transform: uppercase; } diff --git a/internal/app/templates/portal.html b/internal/app/templates/portal.html index cc84eb4..953fc38 100644 --- a/internal/app/templates/portal.html +++ b/internal/app/templates/portal.html @@ -37,7 +37,7 @@
Default policy {{.DefaultHostLimit}} hosts - {{.DefaultDuration}} hours + {{.DefaultDays}} days
@@ -60,11 +60,11 @@
@@ -116,6 +116,7 @@
{{range .Authorizations}} + {{$authorization := .}}
@@ -132,7 +133,60 @@
Created by
{{.CreatedBy}}
{{if isActive .}} -
+
+ Edit access + + +
+ + +
+ Use 0 to keep the current expiration. Extensions are added to the existing deadline. + +
+ Allowed packages + {{range $.Packages}} + {{if .Enabled}} + + {{end}} + {{else}} +

No packages have been enabled yet.

+ {{end}} +
+ +
+ Allowed installer actions + {{range $.Actions}} + {{if .Enabled}} + + {{end}} + {{else}} +

No installer actions have been enabled.

+ {{end}} +
+ + + +
+
diff --git a/tests/test-deployment-scripts.sh b/tests/test-deployment-scripts.sh index 55fb44a..968a530 100755 --- a/tests/test-deployment-scripts.sh +++ b/tests/test-deployment-scripts.sh @@ -79,6 +79,7 @@ assert_env 'TAPM_PUBLIC_URL=http://broker.test:8780' assert_env 'TAPM_GITEA_URL=http://git.test:8780' assert_env 'TAPM_GITEA_INTERNAL_URL=http://gitea:3000' assert_env 'GITEA_PUBLIC_URL=http://git.test:8780/' +assert_env 'TAPM_DEFAULT_DURATION=7d' grep -F -- '-f compose.yaml -f compose.http.yaml up -d gitea nginx' \ "$MOCK_DOCKER_LOG" >/dev/null || { printf 'FAIL: HTTP bootstrap did not select compose.http.yaml\n' >&2