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>
261 lines
7.0 KiB
Go
261 lines
7.0 KiB
Go
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 }
|