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
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:
260
server/handlers/api_tokens.go
Normal file
260
server/handlers/api_tokens.go
Normal 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 }
|
||||
Reference in New Issue
Block a user