package api import ( "context" "database/sql" "errors" "net/http" "strconv" "strings" "time" "git.ryuvia.com/niklas/terdut-server/internal/models" "github.com/go-chi/chi/v5" ) // serviceAccountKeyPrefix marks a service-account key visibly, in logs and at // a glance, distinct from a user's own personal API key. It carries no // meaning to the server itself — the hash is looked up the same way either // kind of key is — it exists entirely for whoever is reading a log line or an // audit trail. const serviceAccountKeyPrefix = "tdsa_" // randomServiceAccountToken is randomToken with serviceAccountKeyPrefix on the // raw value, hashed as a whole: the prefix is not a fixed header stripped // before hashing, it is part of the secret, the same as if it had been // generated that long to begin with. func randomServiceAccountToken() (raw, hash string, err error) { body, _, err := randomToken() if err != nil { return "", "", err } raw = serviceAccountKeyPrefix + body return raw, hashToken(raw), nil } // callerIsAdmin reports whether the caller is a signed-in human system // administrator. A service account never is, by design (SERVICE-ACCOUNTS.md): // account and user management stays human-only, service accounts included. func callerIsAdmin(ctx context.Context) bool { u, ok := userFromContext(ctx) return ok && u.IsAdmin } // callerOwnsTeam reports whether the caller is a human owner of teamID. Built // on callerRole/ctxTeams like requireTeamOwner, but without writing a // response: callers here need to combine it with other ways of being // allowed, not stop at the first no. func callerOwnsTeam(ctx context.Context, teamID int64) bool { role, ok := callerRole(ctx, teamID) return ok && role == models.RoleOwner } // handleCreateServiceAccount creates a service account and mints its first // key. Who may do this depends on scope: an instance-scoped account (which // can in turn create a team and a team-scoped account for it) is system // administration's own reach extended to automation, so only a human admin // grants one. A team-scoped account is that team's owner's reach, so a human // admin, the target team's own human owner, or an existing instance-scoped // service account (minting itself a narrower credential for a team it just // created) may create one. func handleCreateServiceAccount(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req struct { Name string `json:"name"` Scope string `json:"scope"` TeamID int64 `json:"team_id"` } if err := decodeJSON(r, &req); err != nil { respond(w, http.StatusBadRequest, errResp("invalid request body")) return } req.Name = strings.TrimSpace(req.Name) if req.Name == "" { respond(w, http.StatusBadRequest, errResp("name is required")) return } if req.Scope != models.ServiceAccountScopeInstance && req.Scope != models.ServiceAccountScopeTeam { respond(w, http.StatusBadRequest, errResp("scope must be instance or team")) return } if req.Scope == models.ServiceAccountScopeTeam && req.TeamID == 0 { respond(w, http.StatusBadRequest, errResp("team_id is required for a team-scoped account")) return } if req.Scope == models.ServiceAccountScopeInstance && req.TeamID != 0 { respond(w, http.StatusBadRequest, errResp("team_id must not be set for an instance-scoped account")) return } allowed := callerIsAdmin(r.Context()) if !allowed && req.Scope == models.ServiceAccountScopeTeam { allowed = callerOwnsTeam(r.Context(), req.TeamID) || isInstanceServiceAccount(r.Context()) } if !allowed { respond(w, http.StatusForbidden, errResp("team owner, system administrator, or instance-scoped service account access required")) return } var callerUserID *int64 if u, ok := userFromContext(r.Context()); ok { id := u.ID callerUserID = &id } var teamID *int64 if req.Scope == models.ServiceAccountScopeTeam { teamID = &req.TeamID } var sa models.ServiceAccount var created int64 if err := db.QueryRowContext(r.Context(), ` INSERT INTO service_accounts (name, scope, team_id, created_by) VALUES ($1, $2, $3, $4) RETURNING id, name, scope, team_id, created_by, created_at`, req.Name, req.Scope, teamID, callerUserID, ).Scan(&sa.ID, &sa.Name, &sa.Scope, &sa.TeamID, &sa.CreatedBy, &created); err != nil { if isUniqueViolation(err) { respond(w, http.StatusConflict, errResp("a service account with that name already exists")) return } // The only foreign key that can fail here is team_id: an // instance-scoped caller is not otherwise checked against it // (callerOwnsTeam already proved it exists for a human owner). respond(w, http.StatusBadRequest, errResp("unknown team_id")) return } sa.CreatedAt = time.Unix(created, 0).UTC() key, err := mintServiceAccountKey(r.Context(), db, sa.ID, "initial") if err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return } respond(w, http.StatusCreated, map[string]any{"service_account": sa, "key": key}) } } // mintServiceAccountKey inserts one key for an existing account and returns // it with its raw value populated — the one moment that value exists outside // the request that generated it. func mintServiceAccountKey(ctx context.Context, db *sql.DB, serviceAccountID int64, name string) (models.ServiceAccountKey, error) { raw, hash, err := randomServiceAccountToken() if err != nil { return models.ServiceAccountKey{}, err } var key models.ServiceAccountKey var created int64 if err := db.QueryRowContext(ctx, ` INSERT INTO service_account_keys (service_account_id, key_hash, name) VALUES ($1, $2, $3) RETURNING id, service_account_id, name, created_at`, serviceAccountID, hash, name, ).Scan(&key.ID, &key.ServiceAccountID, &key.Name, &created); err != nil { return models.ServiceAccountKey{}, err } key.CreatedAt = time.Unix(created, 0).UTC() key.Key = raw return key, nil } func fetchServiceAccount(ctx context.Context, db *sql.DB, id int64) (models.ServiceAccount, error) { var sa models.ServiceAccount var created int64 err := db.QueryRowContext(ctx, "SELECT id, name, scope, team_id, created_by, created_at FROM service_accounts WHERE id = $1", id, ).Scan(&sa.ID, &sa.Name, &sa.Scope, &sa.TeamID, &sa.CreatedBy, &created) if err != nil { return sa, err } sa.CreatedAt = time.Unix(created, 0).UTC() return sa, nil } // callerMayManageServiceAccount reports whether the caller may mint or revoke // a key on sa: a system administrator, that team-scoped account's own human // owner, or the account rotating its own credential — which is not a // privilege escalation, the same reasoning requireSelfOrAdmin already rests // on for a user's own API keys. func callerMayManageServiceAccount(ctx context.Context, sa models.ServiceAccount) bool { if callerIsAdmin(ctx) { return true } if sa.TeamID != nil && callerOwnsTeam(ctx, *sa.TeamID) { return true } if self, ok := serviceAccountFromContext(ctx); ok && self.id == sa.ID { return true } return false } func serviceAccountParam(w http.ResponseWriter, r *http.Request) (int64, bool) { id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { respond(w, http.StatusBadRequest, errResp("invalid service account id")) return 0, false } return id, true } func handleCreateServiceAccountKey(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id, ok := serviceAccountParam(w, r) if !ok { return } sa, err := fetchServiceAccount(r.Context(), db, id) if errors.Is(err, sql.ErrNoRows) { respond(w, http.StatusNotFound, errResp("service account not found")) return } if err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return } if !callerMayManageServiceAccount(r.Context(), sa) { respond(w, http.StatusForbidden, errResp("team owner, system administrator, or the account itself may rotate its key")) return } var req struct { Name string `json:"name"` } if err := decodeJSON(r, &req); err != nil { respond(w, http.StatusBadRequest, errResp("invalid request body")) return } if req.Name == "" { respond(w, http.StatusBadRequest, errResp("name is required")) return } key, err := mintServiceAccountKey(r.Context(), db, sa.ID, req.Name) if err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return } respond(w, http.StatusCreated, key) } } func handleDeleteServiceAccountKey(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id, ok := serviceAccountParam(w, r) if !ok { return } sa, err := fetchServiceAccount(r.Context(), db, id) if errors.Is(err, sql.ErrNoRows) { respond(w, http.StatusNotFound, errResp("service account not found")) return } if err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return } if !callerMayManageServiceAccount(r.Context(), sa) { respond(w, http.StatusForbidden, errResp("team owner, system administrator, or the account itself may revoke its key")) return } keyID, err := strconv.ParseInt(chi.URLParam(r, "keyID"), 10, 64) if err != nil { respond(w, http.StatusBadRequest, errResp("invalid key id")) return } res, err := db.ExecContext(r.Context(), "DELETE FROM service_account_keys WHERE id = $1 AND service_account_id = $2", keyID, sa.ID) if err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return } if n, _ := res.RowsAffected(); n == 0 { respond(w, http.StatusNotFound, errResp("key not found")) return } w.WriteHeader(http.StatusNoContent) } } // handleListServiceAccounts lists every service account, or looks one up by // its exact name with ?name=. The name lookup is open to any authenticated // caller, human or service account: it returns no key material, and it is // what lets a service account find its own account on the 403 that follows a // second POST — the self-registration pattern SERVICE-ACCOUNTS.md describes. // Listing everything, with no filter, stays administrator-only. func handleListServiceAccounts(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { name := strings.TrimSpace(r.URL.Query().Get("name")) if name == "" && !callerIsAdmin(r.Context()) { respond(w, http.StatusForbidden, errResp("administrator access required to list every service account; pass ?name= to look up one by name")) return } query := "SELECT id, name, scope, team_id, created_by, created_at FROM service_accounts" var args []any if name != "" { query += " WHERE name = $1" args = append(args, name) } query += " ORDER BY id" rows, err := db.QueryContext(r.Context(), query, args...) if err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return } defer rows.Close() accounts := []models.ServiceAccount{} for rows.Next() { var sa models.ServiceAccount var created int64 if err := rows.Scan(&sa.ID, &sa.Name, &sa.Scope, &sa.TeamID, &sa.CreatedBy, &created); err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return } sa.CreatedAt = time.Unix(created, 0).UTC() accounts = append(accounts, sa) } if err := rows.Err(); err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return } respond(w, http.StatusOK, accounts) } }