Feat v0.7.7 api tokens ext permissions (#61)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-runners (push) Has been skipped
CI/CD / test-go-pg (push) Successful in 2m48s
CI/CD / test-sqlite (push) Successful in 2m49s
CI/CD / build-and-deploy (push) Successful in 28s

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #61.
This commit is contained in:
2026-04-02 19:11:47 +00:00
committed by xcaliber
parent e02b13dc12
commit e4f0bdbd36
34 changed files with 1769 additions and 58 deletions

View File

@@ -0,0 +1,260 @@
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 }