diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index d656af9..13f232d 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -14,10 +14,17 @@ import ( ) func actorFromCtx(c *gin.Context) string { - if sess := auth.GetSessionFromContext(c); sess != nil && sess.Email != "" { - return sess.Email + sess := auth.GetSessionFromContext(c) + if sess == nil || sess.Email == "" { + return "admin" } - return "admin" + // The actor stays the human, because a token acts on their behalf and the + // log has to name somebody. The credential is appended so a person clicking + // and their CI job are told apart. + if sess.TokenID != "" { + return fmt.Sprintf("%s (via token:%s)", sess.Email, sess.TokenName) + } + return sess.Email } func RegisterRoutes(r *gin.Engine) { @@ -72,6 +79,11 @@ func RegisterRoutes(r *gin.Engine) { apiGroup.GET("/audit", listAuditEvents) + apiGroup.GET("/tokens", listTokens) + apiGroup.GET("/tokens/scopes", listTokenScopes) + apiGroup.POST("/tokens", createToken) + apiGroup.DELETE("/tokens/:id", revokeToken) + settings := apiGroup.Group("/settings") settings.Use(auth.RequireRole("owner", "admin")) { diff --git a/server/internal/api/scopes.go b/server/internal/api/scopes.go index ef7cc65..9a37337 100644 --- a/server/internal/api/scopes.go +++ b/server/internal/api/scopes.go @@ -145,6 +145,7 @@ var routeScopes = map[string]string{ "GET /api/servers/:id/workloads/:wid/logs": "workloads:write", "GET /api/tokens": "settings:read", + "GET /api/tokens/scopes": "settings:read", "POST /api/tokens": "settings:write", "DELETE /api/tokens/:id": "settings:write", } diff --git a/server/internal/api/tokens.go b/server/internal/api/tokens.go new file mode 100644 index 0000000..4d76341 --- /dev/null +++ b/server/internal/api/tokens.go @@ -0,0 +1,101 @@ +package api + +import ( + "errors" + "fmt" + "net/http" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth" + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models" + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services" + "github.com/gin-gonic/gin" +) + +func elevated(c *gin.Context) bool { + r := auth.Role(c) + return r == models.RoleOwner || r == models.RoleAdmin +} + +// listTokens returns the caller's own tokens. Owner and admin may ask for every +// token in the instance with ?all=true. +func listTokens(c *gin.Context) { + all := c.Query("all") == "true" && elevated(c) + tokens, err := services.ListAPITokens(auth.InstanceID(c), auth.UserID(c), all) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"tokens": tokens, "all": all}) +} + +// listTokenScopes advertises the vocabulary so the UI never hardcodes it. +func listTokenScopes(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"scopes": services.AllScopes()}) +} + +func createToken(c *gin.Context) { + var body struct { + Name string `json:"name" binding:"required"` + Role string `json:"role" binding:"required"` + Scopes []string `json:"scopes" binding:"required"` + ExpiresInDays *int `json:"expires_in_days"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + tok, plaintext, err := services.CreateAPIToken( + auth.InstanceID(c), auth.UserID(c), + body.Name, body.Role, body.Scopes, body.ExpiresInDays, c.ClientIP(), + ) + switch { + case errors.Is(err, services.ErrTokenNameTaken): + c.JSON(http.StatusConflict, gin.H{"error": err.Error(), "code": "name_taken"}) + return + case errors.Is(err, services.ErrTokenRoleTooHigh): + c.JSON(http.StatusForbidden, gin.H{"error": err.Error(), "code": "role_too_high"}) + return + case errors.Is(err, services.ErrTokenExpiryPolicy): + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error(), "code": "expiry_policy"}) + return + case errors.Is(err, services.ErrInvalidScope): + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "invalid_scope"}) + return + case err != nil: + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + expiry := "no expiry" + if tok.ExpiresAt != nil { + expiry = "expires " + tok.ExpiresAt.Format("2006-01-02") + } + services.LogEvent(auth.InstanceID(c), "token.created", actorFromCtx(c), "", "", + fmt.Sprintf("API token '%s' created with role %s, scopes %v, %s", tok.Name, tok.Role, tok.Scopes, expiry)) + + // The plaintext is returned exactly once and is not stored anywhere. + c.JSON(http.StatusCreated, gin.H{"token": plaintext, "record": tok}) +} + +func revokeToken(c *gin.Context) { + requester, err := services.GetUserInInstance(auth.InstanceID(c), auth.UserID(c)) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"}) + return + } + + tok, err := services.RevokeAPIToken(auth.InstanceID(c), c.Param("id"), requester) + if errors.Is(err, services.ErrTokenNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "token not found"}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + services.LogEvent(auth.InstanceID(c), "token.revoked", actorFromCtx(c), "", "", + fmt.Sprintf("API token '%s' revoked", tok.Name)) + c.JSON(http.StatusOK, gin.H{"revoked": true}) +} diff --git a/web/lib/auditEvents.ts b/web/lib/auditEvents.ts index 4a5b09e..8f5e535 100644 --- a/web/lib/auditEvents.ts +++ b/web/lib/auditEvents.ts @@ -38,6 +38,7 @@ export const AUDIT_CATEGORIES: { value: string; label: string }[] = [ { value: "updates", label: "OS updates" }, { value: "auth_provider", label: "Single sign-on" }, { value: "settings", label: "Settings" }, + { value: "token", label: "API tokens" }, { value: "license", label: "Licence" }, { value: "instance", label: "Instance" }, ]; @@ -98,6 +99,10 @@ const OVERRIDES: Record = { "instance.reaped": "Instance deleted", "vuln.rescan": "Rescan requested", "workload.logs_read": "Workload logs read", + "token.created": "API token created", + "token.revoked": "API token revoked", + "token.expired_use": "Expired API token used", + "settings.token_policy_updated": "API token policy updated", }; export interface AuditEventDisplay {