package handlers import ( "crypto/rand" "encoding/hex" "net/http" "time" "github.com/gin-gonic/gin" "armature/auth" "armature/models" "armature/store" ) // tokenPrefix is the prefix for all personal access tokens. const tokenPrefix = "arm_pat_" // APITokenHandler manages personal access tokens. type APITokenHandler struct { stores store.Stores } // NewAPITokenHandler creates a new API token handler. func NewAPITokenHandler(s store.Stores) *APITokenHandler { return &APITokenHandler{stores: s} } // CreateToken creates a new personal access token for the current user. // POST /api/v1/auth/tokens func (h *APITokenHandler) CreateToken(c *gin.Context) { var req models.APITokenCreateRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) return } if req.Name == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) return } userID := getUserID(c) // Parse optional expiry var expiresAt *time.Time if req.ExpiresAt != "" { t, err := time.Parse(time.RFC3339, req.ExpiresAt) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "expires_at must be RFC3339 format"}) return } if t.Before(time.Now()) { c.JSON(http.StatusBadRequest, gin.H{"error": "expires_at must be in the future"}) return } expiresAt = &t } // Validate permissions: must be a subset of the creating user's resolved permissions if err := h.validatePermissionSubset(c, userID, req.Permissions); err != nil { return // error already sent } // Generate token rawToken, tokenHash, prefix := generateToken() token := &models.APIToken{ UserID: userID, Name: req.Name, TokenHash: tokenHash, Prefix: prefix, Permissions: req.Permissions, ExpiresAt: expiresAt, CreatedBy: &userID, } if err := h.stores.APITokens.Create(c.Request.Context(), token); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create token"}) return } // Audit log AuditLog(h.stores.Audit, c, "token.create", "api_token", token.ID, map[string]interface{}{ "name": req.Name, "permissions": req.Permissions, }) c.JSON(http.StatusCreated, models.APITokenCreateResponse{ Token: rawToken, ID: token.ID, Name: token.Name, Prefix: token.Prefix, Permissions: token.Permissions, ExpiresAt: token.ExpiresAt, CreatedAt: token.CreatedAt, }) } // ListTokens lists all tokens for the current user. // GET /api/v1/auth/tokens func (h *APITokenHandler) ListTokens(c *gin.Context) { userID := getUserID(c) tokens, err := h.stores.APITokens.ListForUser(c.Request.Context(), userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list tokens"}) return } c.JSON(http.StatusOK, gin.H{"data": tokens}) } // RevokeToken revokes a token owned by the current user. // DELETE /api/v1/auth/tokens/:id func (h *APITokenHandler) RevokeToken(c *gin.Context) { userID := getUserID(c) id := c.Param("id") n, err := h.stores.APITokens.Revoke(c.Request.Context(), id, userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to revoke token"}) return } if n == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "token not found"}) return } AuditLog(h.stores.Audit, c, "token.revoke", "api_token", id, nil) c.JSON(http.StatusOK, gin.H{"ok": true}) } // AdminCreateToken creates a token for any user (admin only). // POST /api/v1/admin/tokens func (h *APITokenHandler) AdminCreateToken(c *gin.Context) { var req models.AdminTokenCreateRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) return } if req.UserID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "user_id is required"}) return } if req.Name == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) return } // Verify target user exists targetUser, err := h.stores.Users.GetByID(c.Request.Context(), req.UserID) if err != nil || targetUser == nil { c.JSON(http.StatusNotFound, gin.H{"error": "target user not found"}) return } // Parse optional expiry var expiresAt *time.Time if req.ExpiresAt != "" { t, err := time.Parse(time.RFC3339, req.ExpiresAt) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "expires_at must be RFC3339 format"}) return } if t.Before(time.Now()) { c.JSON(http.StatusBadRequest, gin.H{"error": "expires_at must be in the future"}) return } expiresAt = &t } // Validate permissions: must be subset of TARGET user's permissions if err := h.validatePermissionSubset(c, req.UserID, req.Permissions); err != nil { return } rawToken, tokenHash, prefix := generateToken() adminID := getUserID(c) token := &models.APIToken{ UserID: req.UserID, Name: req.Name, TokenHash: tokenHash, Prefix: prefix, Permissions: req.Permissions, ExpiresAt: expiresAt, CreatedBy: &adminID, } if err := h.stores.APITokens.Create(c.Request.Context(), token); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create token"}) return } AuditLog(h.stores.Audit, c, "admin.token.create", "api_token", token.ID, map[string]interface{}{ "target_user": req.UserID, "name": req.Name, "permissions": req.Permissions, }) c.JSON(http.StatusCreated, models.APITokenCreateResponse{ Token: rawToken, ID: token.ID, Name: token.Name, Prefix: token.Prefix, Permissions: token.Permissions, ExpiresAt: token.ExpiresAt, CreatedAt: token.CreatedAt, }) } // ── Helpers ────────────────────────────────── // generateToken creates a raw token string, its SHA-256 hash, and the prefix. // Format: arm_pat_ + 32 random bytes hex-encoded (72 chars total). func generateToken() (raw, hash, prefix string) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { panic("crypto/rand failed: " + err.Error()) } hexPart := hex.EncodeToString(b) raw = tokenPrefix + hexPart hash = auth.HashToken(raw) prefix = hexPart[:8] return } // validatePermissionSubset checks that the requested permissions are a subset // of the specified user's resolved permissions. Sends an error response and // returns a non-nil error if validation fails. func (h *APITokenHandler) validatePermissionSubset(c *gin.Context, userID string, requested []string) error { if len(requested) == 0 { return nil } userPerms, err := auth.ResolvePermissions(c.Request.Context(), h.stores, userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to resolve permissions"}) return err } for _, p := range requested { if !userPerms[p] { c.JSON(http.StatusBadRequest, gin.H{ "error": "permission not available: " + p, }) return errPermissionNotAvailable } } return nil } var errPermissionNotAvailable = &apiError{message: "permission not available"} type apiError struct { message string } func (e *apiError) Error() string { return e.message }