feat: Add the API token endpoints

Create, list and revoke, with no update: editing what a credential already
deployed in CI can do, with no record of what it could do before, is worse
than requiring a rotation. Revoking a token that is not yours answers
not-found, since a 403 confirms it exists.

The audit actor stays the human and names the credential alongside, so a
person clicking and their CI job are told apart.
This commit is contained in:
2026-08-12 14:44:50 +00:00
parent be4f488db3
commit 524ccc6412
4 changed files with 122 additions and 3 deletions
+15 -3
View File
@@ -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"))
{
+1
View File
@@ -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",
}
+101
View File
@@ -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})
}
+5
View File
@@ -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<string, string> = {
"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 {