// Package server wires together the HTTP router, middleware, and handlers // for the MCIAS authentication server. // // Security design: // - All endpoints use HTTPS (enforced at the listener level in cmd/mciassrv). // - Authentication state is carried via JWT; no cookies or server-side sessions. // - Credential fields (password hash, TOTP secret, Postgres password) are // never included in any API response. // - All JSON parsing uses strict decoders that reject unknown fields. package server import ( "crypto/ed25519" "encoding/json" "errors" "fmt" "log/slog" "net/http" "git.wntrmute.dev/kyle/mcias/internal/auth" "git.wntrmute.dev/kyle/mcias/internal/config" "git.wntrmute.dev/kyle/mcias/internal/crypto" "git.wntrmute.dev/kyle/mcias/internal/db" "git.wntrmute.dev/kyle/mcias/internal/middleware" "git.wntrmute.dev/kyle/mcias/internal/model" "git.wntrmute.dev/kyle/mcias/internal/token" "git.wntrmute.dev/kyle/mcias/internal/ui" ) // Server holds the dependencies injected into all handlers. type Server struct { db *db.DB cfg *config.Config logger *slog.Logger privKey ed25519.PrivateKey pubKey ed25519.PublicKey masterKey []byte } // New creates a Server with the given dependencies. func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed25519.PublicKey, masterKey []byte, logger *slog.Logger) *Server { return &Server{ db: database, cfg: cfg, privKey: priv, pubKey: pub, masterKey: masterKey, logger: logger, } } // Handler builds and returns the root HTTP handler with all routes and middleware. func (s *Server) Handler() http.Handler { mux := http.NewServeMux() // Security: per-IP rate limiting on public auth endpoints to prevent // brute-force login attempts and token-validation abuse. Parameters match // the gRPC rate limiter (10 req/s sustained, burst 10). loginRateLimit := middleware.RateLimit(10, 10) // Public endpoints (no authentication required). mux.HandleFunc("GET /v1/health", s.handleHealth) mux.HandleFunc("GET /v1/keys/public", s.handlePublicKey) mux.Handle("POST /v1/auth/login", loginRateLimit(http.HandlerFunc(s.handleLogin))) mux.Handle("POST /v1/token/validate", loginRateLimit(http.HandlerFunc(s.handleTokenValidate))) // Authenticated endpoints. requireAuth := middleware.RequireAuth(s.pubKey, s.db, s.cfg.Tokens.Issuer) requireAdmin := func(h http.Handler) http.Handler { return requireAuth(middleware.RequireRole("admin")(h)) } // Auth endpoints (require valid token). mux.Handle("POST /v1/auth/logout", requireAuth(http.HandlerFunc(s.handleLogout))) mux.Handle("POST /v1/auth/renew", requireAuth(http.HandlerFunc(s.handleRenew))) mux.Handle("POST /v1/auth/totp/enroll", requireAuth(http.HandlerFunc(s.handleTOTPEnroll))) mux.Handle("POST /v1/auth/totp/confirm", requireAuth(http.HandlerFunc(s.handleTOTPConfirm))) // Admin-only endpoints. mux.Handle("DELETE /v1/auth/totp", requireAdmin(http.HandlerFunc(s.handleTOTPRemove))) mux.Handle("POST /v1/token/issue", requireAdmin(http.HandlerFunc(s.handleTokenIssue))) mux.Handle("DELETE /v1/token/{jti}", requireAdmin(http.HandlerFunc(s.handleTokenRevoke))) mux.Handle("GET /v1/accounts", requireAdmin(http.HandlerFunc(s.handleListAccounts))) mux.Handle("POST /v1/accounts", requireAdmin(http.HandlerFunc(s.handleCreateAccount))) mux.Handle("GET /v1/accounts/{id}", requireAdmin(http.HandlerFunc(s.handleGetAccount))) mux.Handle("PATCH /v1/accounts/{id}", requireAdmin(http.HandlerFunc(s.handleUpdateAccount))) mux.Handle("DELETE /v1/accounts/{id}", requireAdmin(http.HandlerFunc(s.handleDeleteAccount))) mux.Handle("GET /v1/accounts/{id}/roles", requireAdmin(http.HandlerFunc(s.handleGetRoles))) mux.Handle("PUT /v1/accounts/{id}/roles", requireAdmin(http.HandlerFunc(s.handleSetRoles))) mux.Handle("GET /v1/accounts/{id}/pgcreds", requireAdmin(http.HandlerFunc(s.handleGetPGCreds))) mux.Handle("PUT /v1/accounts/{id}/pgcreds", requireAdmin(http.HandlerFunc(s.handleSetPGCreds))) mux.Handle("GET /v1/audit", requireAdmin(http.HandlerFunc(s.handleListAudit))) // UI routes (HTMX-based management frontend). uiSrv, err := ui.New(s.db, s.cfg, s.privKey, s.pubKey, s.masterKey, s.logger) if err != nil { panic(fmt.Sprintf("ui: init failed: %v", err)) } uiSrv.Register(mux) // Apply global middleware: request logging. // Rate limiting is applied per-route above (login, token/validate). var root http.Handler = mux root = middleware.RequestLogger(s.logger)(root) return root } // ---- Public handlers ---- func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } // handlePublicKey returns the server's Ed25519 public key in JWK format. // This allows relying parties to independently verify JWTs. func (s *Server) handlePublicKey(w http.ResponseWriter, _ *http.Request) { // Encode the Ed25519 public key as a JWK (RFC 8037). // The "x" parameter is the base64url-encoded public key bytes. jwk := map[string]string{ "kty": "OKP", "crv": "Ed25519", "use": "sig", "alg": "EdDSA", "x": encodeBase64URL(s.pubKey), } writeJSON(w, http.StatusOK, jwk) } // ---- Auth handlers ---- // loginRequest is the request body for POST /v1/auth/login. type loginRequest struct { Username string `json:"username"` Password string `json:"password"` TOTPCode string `json:"totp_code,omitempty"` } // loginResponse is the response body for a successful login. type loginResponse struct { Token string `json:"token"` ExpiresAt string `json:"expires_at"` } func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { var req loginRequest if !decodeJSON(w, r, &req) { return } if req.Username == "" || req.Password == "" { middleware.WriteError(w, http.StatusBadRequest, "username and password are required", "bad_request") return } // Load account by username. acct, err := s.db.GetAccountByUsername(req.Username) if err != nil { // Security: return a generic error whether the user exists or not. // Always run a dummy Argon2 check to prevent timing-based user enumeration. _, _ = auth.VerifyPassword("dummy", "$argon2id$v=19$m=65536,t=3,p=4$dGVzdHNhbHQ$dGVzdGhhc2g") s.writeAudit(r, model.EventLoginFail, nil, nil, fmt.Sprintf(`{"username":%q,"reason":"unknown_user"}`, req.Username)) middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized") return } // Security: Check account status before credential verification to avoid // leaking whether the account exists based on timing differences. if acct.Status != model.AccountStatusActive { _, _ = auth.VerifyPassword("dummy", "$argon2id$v=19$m=65536,t=3,p=4$dGVzdHNhbHQ$dGVzdGhhc2g") s.writeAudit(r, model.EventLoginFail, &acct.ID, nil, `{"reason":"account_inactive"}`) middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized") return } // Verify password. This is always run, even for system accounts (which have // no password hash), to maintain constant timing. ok, err := auth.VerifyPassword(req.Password, acct.PasswordHash) if err != nil || !ok { s.writeAudit(r, model.EventLoginFail, &acct.ID, nil, `{"reason":"wrong_password"}`) middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized") return } // TOTP check (if enrolled). if acct.TOTPRequired { if req.TOTPCode == "" { s.writeAudit(r, model.EventLoginFail, &acct.ID, nil, `{"reason":"totp_missing"}`) middleware.WriteError(w, http.StatusUnauthorized, "TOTP code required", "totp_required") return } // Decrypt the TOTP secret. secret, err := crypto.OpenAESGCM(s.masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc) if err != nil { s.logger.Error("decrypt TOTP secret", "error", err, "account_id", acct.ID) middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } valid, err := auth.ValidateTOTP(secret, req.TOTPCode) if err != nil || !valid { s.writeAudit(r, model.EventLoginTOTPFail, &acct.ID, nil, `{"reason":"wrong_totp"}`) middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized") return } } // Determine expiry. expiry := s.cfg.DefaultExpiry() roles, err := s.db.GetRoles(acct.ID) if err != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } for _, r := range roles { if r == "admin" { expiry = s.cfg.AdminExpiry() break } } tokenStr, claims, err := token.IssueToken(s.privKey, s.cfg.Tokens.Issuer, acct.UUID, roles, expiry) if err != nil { s.logger.Error("issue token", "error", err) middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } if err := s.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil { s.logger.Error("track token", "error", err) middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } s.writeAudit(r, model.EventLoginOK, &acct.ID, nil, "") s.writeAudit(r, model.EventTokenIssued, &acct.ID, nil, fmt.Sprintf(`{"jti":%q}`, claims.JTI)) writeJSON(w, http.StatusOK, loginResponse{ Token: tokenStr, ExpiresAt: claims.ExpiresAt.Format("2006-01-02T15:04:05Z"), }) } func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) if err := s.db.RevokeToken(claims.JTI, "logout"); err != nil { s.logger.Error("revoke token on logout", "error", err) middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } s.writeAudit(r, model.EventTokenRevoked, nil, nil, fmt.Sprintf(`{"jti":%q,"reason":"logout"}`, claims.JTI)) w.WriteHeader(http.StatusNoContent) } func (s *Server) handleRenew(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) // Load account to get current roles (they may have changed since token issuance). acct, err := s.db.GetAccountByUUID(claims.Subject) if err != nil { middleware.WriteError(w, http.StatusUnauthorized, "account not found", "unauthorized") return } if acct.Status != model.AccountStatusActive { middleware.WriteError(w, http.StatusUnauthorized, "account inactive", "unauthorized") return } roles, err := s.db.GetRoles(acct.ID) if err != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } expiry := s.cfg.DefaultExpiry() for _, role := range roles { if role == "admin" { expiry = s.cfg.AdminExpiry() break } } newTokenStr, newClaims, err := token.IssueToken(s.privKey, s.cfg.Tokens.Issuer, acct.UUID, roles, expiry) if err != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } // Security: revoke old + track new in a single transaction (F-03) so that a // failure between the two steps cannot leave the user with no valid token. if err := s.db.RenewToken(claims.JTI, "renewed", newClaims.JTI, acct.ID, newClaims.IssuedAt, newClaims.ExpiresAt); err != nil { s.logger.Error("renew token atomic", "error", err) middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } s.writeAudit(r, model.EventTokenRenewed, &acct.ID, nil, fmt.Sprintf(`{"old_jti":%q,"new_jti":%q}`, claims.JTI, newClaims.JTI)) writeJSON(w, http.StatusOK, loginResponse{ Token: newTokenStr, ExpiresAt: newClaims.ExpiresAt.Format("2006-01-02T15:04:05Z"), }) } // ---- Token endpoints ---- type validateRequest struct { Token string `json:"token"` } type validateResponse struct { Subject string `json:"sub,omitempty"` ExpiresAt string `json:"expires_at,omitempty"` Roles []string `json:"roles,omitempty"` Valid bool `json:"valid"` } func (s *Server) handleTokenValidate(w http.ResponseWriter, r *http.Request) { // Accept token either from Authorization: Bearer header or JSON body. tokenStr, err := extractBearerFromRequest(r) if err != nil { // Try JSON body. var req validateRequest if !decodeJSON(w, r, &req) { return } tokenStr = req.Token } if tokenStr == "" { writeJSON(w, http.StatusOK, validateResponse{Valid: false}) return } claims, err := token.ValidateToken(s.pubKey, tokenStr, s.cfg.Tokens.Issuer) if err != nil { writeJSON(w, http.StatusOK, validateResponse{Valid: false}) return } rec, err := s.db.GetTokenRecord(claims.JTI) if err != nil || rec.IsRevoked() { writeJSON(w, http.StatusOK, validateResponse{Valid: false}) return } writeJSON(w, http.StatusOK, validateResponse{ Valid: true, Subject: claims.Subject, Roles: claims.Roles, ExpiresAt: claims.ExpiresAt.Format("2006-01-02T15:04:05Z"), }) } type issueTokenRequest struct { AccountID string `json:"account_id"` } func (s *Server) handleTokenIssue(w http.ResponseWriter, r *http.Request) { var req issueTokenRequest if !decodeJSON(w, r, &req) { return } acct, err := s.db.GetAccountByUUID(req.AccountID) if err != nil { middleware.WriteError(w, http.StatusNotFound, "account not found", "not_found") return } if acct.AccountType != model.AccountTypeSystem { middleware.WriteError(w, http.StatusBadRequest, "token issue is only for system accounts", "bad_request") return } tokenStr, claims, err := token.IssueToken(s.privKey, s.cfg.Tokens.Issuer, acct.UUID, nil, s.cfg.ServiceExpiry()) if err != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } // Revoke existing system token if any. existing, err := s.db.GetSystemToken(acct.ID) if err == nil && existing != nil { _ = s.db.RevokeToken(existing.JTI, "rotated") } if err := s.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } if err := s.db.SetSystemToken(acct.ID, claims.JTI, claims.ExpiresAt); err != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } actor := middleware.ClaimsFromContext(r.Context()) var actorID *int64 if actor != nil { if a, err := s.db.GetAccountByUUID(actor.Subject); err == nil { actorID = &a.ID } } s.writeAudit(r, model.EventTokenIssued, actorID, &acct.ID, fmt.Sprintf(`{"jti":%q}`, claims.JTI)) writeJSON(w, http.StatusOK, loginResponse{ Token: tokenStr, ExpiresAt: claims.ExpiresAt.Format("2006-01-02T15:04:05Z"), }) } func (s *Server) handleTokenRevoke(w http.ResponseWriter, r *http.Request) { jti := r.PathValue("jti") if jti == "" { middleware.WriteError(w, http.StatusBadRequest, "jti is required", "bad_request") return } if err := s.db.RevokeToken(jti, "admin revocation"); err != nil { middleware.WriteError(w, http.StatusNotFound, "token not found or already revoked", "not_found") return } s.writeAudit(r, model.EventTokenRevoked, nil, nil, fmt.Sprintf(`{"jti":%q}`, jti)) w.WriteHeader(http.StatusNoContent) } // ---- Account endpoints ---- type createAccountRequest struct { Username string `json:"username"` Password string `json:"password,omitempty"` Type string `json:"account_type"` } type accountResponse struct { ID string `json:"id"` Username string `json:"username"` AccountType string `json:"account_type"` Status string `json:"status"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` TOTPEnabled bool `json:"totp_enabled"` } func accountToResponse(a *model.Account) accountResponse { resp := accountResponse{ ID: a.UUID, Username: a.Username, AccountType: string(a.AccountType), Status: string(a.Status), TOTPEnabled: a.TOTPRequired, CreatedAt: a.CreatedAt.Format("2006-01-02T15:04:05Z"), UpdatedAt: a.UpdatedAt.Format("2006-01-02T15:04:05Z"), } return resp } func (s *Server) handleListAccounts(w http.ResponseWriter, _ *http.Request) { accounts, err := s.db.ListAccounts() if err != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } resp := make([]accountResponse, len(accounts)) for i, a := range accounts { resp[i] = accountToResponse(a) } writeJSON(w, http.StatusOK, resp) } func (s *Server) handleCreateAccount(w http.ResponseWriter, r *http.Request) { var req createAccountRequest if !decodeJSON(w, r, &req) { return } if req.Username == "" { middleware.WriteError(w, http.StatusBadRequest, "username is required", "bad_request") return } accountType := model.AccountType(req.Type) if accountType != model.AccountTypeHuman && accountType != model.AccountTypeSystem { middleware.WriteError(w, http.StatusBadRequest, "account_type must be 'human' or 'system'", "bad_request") return } var passwordHash string if accountType == model.AccountTypeHuman { if req.Password == "" { middleware.WriteError(w, http.StatusBadRequest, "password is required for human accounts", "bad_request") return } var err error passwordHash, err = auth.HashPassword(req.Password, auth.ArgonParams{ Time: s.cfg.Argon2.Time, Memory: s.cfg.Argon2.Memory, Threads: s.cfg.Argon2.Threads, }) if err != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } } acct, err := s.db.CreateAccount(req.Username, accountType, passwordHash) if err != nil { middleware.WriteError(w, http.StatusConflict, "username already exists", "conflict") return } s.writeAudit(r, model.EventAccountCreated, nil, &acct.ID, fmt.Sprintf(`{"username":%q}`, acct.Username)) writeJSON(w, http.StatusCreated, accountToResponse(acct)) } func (s *Server) handleGetAccount(w http.ResponseWriter, r *http.Request) { acct, ok := s.loadAccount(w, r) if !ok { return } writeJSON(w, http.StatusOK, accountToResponse(acct)) } type updateAccountRequest struct { Status string `json:"status,omitempty"` } func (s *Server) handleUpdateAccount(w http.ResponseWriter, r *http.Request) { acct, ok := s.loadAccount(w, r) if !ok { return } var req updateAccountRequest if !decodeJSON(w, r, &req) { return } if req.Status != "" { newStatus := model.AccountStatus(req.Status) if newStatus != model.AccountStatusActive && newStatus != model.AccountStatusInactive { middleware.WriteError(w, http.StatusBadRequest, "status must be 'active' or 'inactive'", "bad_request") return } if err := s.db.UpdateAccountStatus(acct.ID, newStatus); err != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } } s.writeAudit(r, model.EventAccountUpdated, nil, &acct.ID, "") w.WriteHeader(http.StatusNoContent) } func (s *Server) handleDeleteAccount(w http.ResponseWriter, r *http.Request) { acct, ok := s.loadAccount(w, r) if !ok { return } if err := s.db.UpdateAccountStatus(acct.ID, model.AccountStatusDeleted); err != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } if err := s.db.RevokeAllUserTokens(acct.ID, "account deleted"); err != nil { s.logger.Error("revoke tokens on delete", "error", err, "account_id", acct.ID) } s.writeAudit(r, model.EventAccountDeleted, nil, &acct.ID, "") w.WriteHeader(http.StatusNoContent) } // ---- Role endpoints ---- type rolesResponse struct { Roles []string `json:"roles"` } type setRolesRequest struct { Roles []string `json:"roles"` } func (s *Server) handleGetRoles(w http.ResponseWriter, r *http.Request) { acct, ok := s.loadAccount(w, r) if !ok { return } roles, err := s.db.GetRoles(acct.ID) if err != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } if roles == nil { roles = []string{} } writeJSON(w, http.StatusOK, rolesResponse{Roles: roles}) } func (s *Server) handleSetRoles(w http.ResponseWriter, r *http.Request) { acct, ok := s.loadAccount(w, r) if !ok { return } var req setRolesRequest if !decodeJSON(w, r, &req) { return } actor := middleware.ClaimsFromContext(r.Context()) var grantedBy *int64 if actor != nil { if a, err := s.db.GetAccountByUUID(actor.Subject); err == nil { grantedBy = &a.ID } } if err := s.db.SetRoles(acct.ID, req.Roles, grantedBy); err != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } s.writeAudit(r, model.EventRoleGranted, grantedBy, &acct.ID, fmt.Sprintf(`{"roles":%v}`, req.Roles)) w.WriteHeader(http.StatusNoContent) } // ---- TOTP endpoints ---- type totpEnrollResponse struct { Secret string `json:"secret"` // base32-encoded OTPAuthURI string `json:"otpauth_uri"` } type totpConfirmRequest struct { Code string `json:"code"` } func (s *Server) handleTOTPEnroll(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) acct, err := s.db.GetAccountByUUID(claims.Subject) if err != nil { middleware.WriteError(w, http.StatusUnauthorized, "account not found", "unauthorized") return } rawSecret, b32Secret, err := auth.GenerateTOTPSecret() if err != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } // Encrypt the secret before storing it temporarily. // Note: we store as pending; enrollment is confirmed with /confirm. secretEnc, secretNonce, err := crypto.SealAESGCM(s.masterKey, rawSecret) if err != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } // Security: use StorePendingTOTP (not SetTOTP) so that totp_required // remains 0 until the user proves possession of the secret by confirming // a valid code. If the user abandons enrollment the flag stays unset and // they can still log in with just their password — no lockout. if err := s.db.StorePendingTOTP(acct.ID, secretEnc, secretNonce); err != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } otpURI := fmt.Sprintf("otpauth://totp/MCIAS:%s?secret=%s&issuer=MCIAS", acct.Username, b32Secret) // Security: return the secret for display to the user. It is only shown // once; subsequent reads are not possible (only the encrypted form is stored). writeJSON(w, http.StatusOK, totpEnrollResponse{ Secret: b32Secret, OTPAuthURI: otpURI, }) } func (s *Server) handleTOTPConfirm(w http.ResponseWriter, r *http.Request) { var req totpConfirmRequest if !decodeJSON(w, r, &req) { return } claims := middleware.ClaimsFromContext(r.Context()) acct, err := s.db.GetAccountByUUID(claims.Subject) if err != nil { middleware.WriteError(w, http.StatusUnauthorized, "account not found", "unauthorized") return } if acct.TOTPSecretEnc == nil { middleware.WriteError(w, http.StatusBadRequest, "TOTP enrollment not started", "bad_request") return } secret, err := crypto.OpenAESGCM(s.masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc) if err != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } valid, err := auth.ValidateTOTP(secret, req.Code) if err != nil || !valid { middleware.WriteError(w, http.StatusUnauthorized, "invalid TOTP code", "unauthorized") return } // Mark TOTP as confirmed and required. if err := s.db.SetTOTP(acct.ID, acct.TOTPSecretEnc, acct.TOTPSecretNonce); err != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } s.writeAudit(r, model.EventTOTPEnrolled, &acct.ID, nil, "") w.WriteHeader(http.StatusNoContent) } type totpRemoveRequest struct { AccountID string `json:"account_id"` } func (s *Server) handleTOTPRemove(w http.ResponseWriter, r *http.Request) { var req totpRemoveRequest if !decodeJSON(w, r, &req) { return } acct, err := s.db.GetAccountByUUID(req.AccountID) if err != nil { middleware.WriteError(w, http.StatusNotFound, "account not found", "not_found") return } if err := s.db.ClearTOTP(acct.ID); err != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } s.writeAudit(r, model.EventTOTPRemoved, nil, &acct.ID, "") w.WriteHeader(http.StatusNoContent) } // ---- Postgres credential endpoints ---- type pgCredRequest struct { Host string `json:"host"` Database string `json:"database"` Username string `json:"username"` Password string `json:"password"` Port int `json:"port"` } func (s *Server) handleGetPGCreds(w http.ResponseWriter, r *http.Request) { acct, ok := s.loadAccount(w, r) if !ok { return } cred, err := s.db.ReadPGCredentials(acct.ID) if err != nil { if errors.Is(err, db.ErrNotFound) { middleware.WriteError(w, http.StatusNotFound, "no credentials stored", "not_found") return } middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } // Decrypt the password to return it to the admin caller. password, err := crypto.OpenAESGCM(s.masterKey, cred.PGPasswordNonce, cred.PGPasswordEnc) if err != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } s.writeAudit(r, model.EventPGCredAccessed, nil, &acct.ID, "") // Return including password since this is an explicit admin retrieval. writeJSON(w, http.StatusOK, map[string]interface{}{ "host": cred.PGHost, "port": cred.PGPort, "database": cred.PGDatabase, "username": cred.PGUsername, "password": string(password), // included only for admin retrieval }) } func (s *Server) handleSetPGCreds(w http.ResponseWriter, r *http.Request) { acct, ok := s.loadAccount(w, r) if !ok { return } var req pgCredRequest if !decodeJSON(w, r, &req) { return } if req.Host == "" || req.Database == "" || req.Username == "" || req.Password == "" { middleware.WriteError(w, http.StatusBadRequest, "host, database, username, and password are required", "bad_request") return } if req.Port == 0 { req.Port = 5432 } enc, nonce, err := crypto.SealAESGCM(s.masterKey, []byte(req.Password)) if err != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } if err := s.db.WritePGCredentials(acct.ID, req.Host, req.Port, req.Database, req.Username, enc, nonce); err != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } s.writeAudit(r, model.EventPGCredUpdated, nil, &acct.ID, "") w.WriteHeader(http.StatusNoContent) } // ---- Audit endpoints ---- // handleListAudit returns paginated audit log entries with resolved usernames. // Query params: limit (1-200, default 50), offset, event_type, actor_id (UUID). func (s *Server) handleListAudit(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() limit := parseIntParam(q.Get("limit"), 50) if limit < 1 { limit = 1 } if limit > 200 { limit = 200 } offset := parseIntParam(q.Get("offset"), 0) if offset < 0 { offset = 0 } params := db.AuditQueryParams{ EventType: q.Get("event_type"), Limit: limit, Offset: offset, } // Resolve actor_id from UUID to internal int64. if actorUUID := q.Get("actor_id"); actorUUID != "" { acct, err := s.db.GetAccountByUUID(actorUUID) if err == nil { params.AccountID = &acct.ID } // If actor_id is provided but not found, return empty results (correct behaviour). } events, total, err := s.db.ListAuditEventsPaged(params) if err != nil { s.logger.Error("list audit events", "error", err) middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return } // Ensure a nil slice serialises as [] rather than null. if events == nil { events = []*db.AuditEventView{} } writeJSON(w, http.StatusOK, map[string]interface{}{ "events": events, "total": total, "limit": limit, "offset": offset, }) } // parseIntParam parses a query parameter as an int, returning defaultVal on failure. func parseIntParam(s string, defaultVal int) int { if s == "" { return defaultVal } var v int if _, err := fmt.Sscanf(s, "%d", &v); err != nil { return defaultVal } return v } // ---- Helpers ---- // loadAccount retrieves an account by the {id} path parameter (UUID). func (s *Server) loadAccount(w http.ResponseWriter, r *http.Request) (*model.Account, bool) { id := r.PathValue("id") if id == "" { middleware.WriteError(w, http.StatusBadRequest, "account id is required", "bad_request") return nil, false } acct, err := s.db.GetAccountByUUID(id) if err != nil { if errors.Is(err, db.ErrNotFound) { middleware.WriteError(w, http.StatusNotFound, "account not found", "not_found") return nil, false } middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return nil, false } return acct, true } // writeAudit appends an audit log entry, logging errors but not failing the request. func (s *Server) writeAudit(r *http.Request, eventType string, actorID, targetID *int64, details string) { ip := r.RemoteAddr if err := s.db.WriteAuditEvent(eventType, actorID, targetID, ip, details); err != nil { s.logger.Error("write audit event", "error", err, "event_type", eventType) } } // writeJSON encodes v as JSON and writes it to w with the given status code. func writeJSON(w http.ResponseWriter, status int, v interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) if err := json.NewEncoder(w).Encode(v); err != nil { // If encoding fails, the status is already written; log but don't panic. _ = err } } // decodeJSON decodes a JSON request body into v. // Returns false and writes a 400 response if decoding fails. func decodeJSON(w http.ResponseWriter, r *http.Request, v interface{}) bool { dec := json.NewDecoder(r.Body) dec.DisallowUnknownFields() if err := dec.Decode(v); err != nil { middleware.WriteError(w, http.StatusBadRequest, "invalid JSON request body", "bad_request") return false } return true } // extractBearerFromRequest extracts a Bearer token from the Authorization header. func extractBearerFromRequest(r *http.Request) (string, error) { auth := r.Header.Get("Authorization") if auth == "" { return "", fmt.Errorf("no Authorization header") } const prefix = "Bearer " if len(auth) <= len(prefix) { return "", fmt.Errorf("malformed Authorization header") } return auth[len(prefix):], nil } // encodeBase64URL encodes bytes as base64url without padding. func encodeBase64URL(b []byte) string { const table = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" result := make([]byte, 0, (len(b)*4+2)/3) for i := 0; i < len(b); i += 3 { switch { case i+2 < len(b): result = append(result, table[b[i]>>2], table[(b[i]&3)<<4|b[i+1]>>4], table[(b[i+1]&0xf)<<2|b[i+2]>>6], table[b[i+2]&0x3f], ) case i+1 < len(b): result = append(result, table[b[i]>>2], table[(b[i]&3)<<4|b[i+1]>>4], table[(b[i+1]&0xf)<<2], ) default: result = append(result, table[b[i]>>2], table[(b[i]&3)<<4], ) } } return string(result) }