Files
pulse/internal/api/security_tokens.go
T
pulse-triage[bot] 73be3df3c5 Keep API token creation atomic
Restore the complete pre-creation token inventory when persistence fails so sorting cannot evict an older valid token or leave an unreturned credential active. Govern and test the durable creation boundary across API, security, agent lifecycle, and storage.

Change-source: pulse-maintainer
2026-08-28 07:08:08 +01:00

761 lines
26 KiB
Go

package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
"github.com/rs/zerolog/log"
)
type apiTokenDTO struct {
ID string `json:"id"`
Name string `json:"name"`
Prefix string `json:"prefix"`
Suffix string `json:"suffix"`
CreatedAt time.Time `json:"createdAt"`
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
Scopes []string `json:"scopes"`
OwnerUserID string `json:"ownerUserId,omitempty"`
}
const apiTokenMetadataOwnerUserID = "owner_user_id"
const apiTokenMetadataPurpose = "purpose"
const apiTokenPurposeRelayMobileAccess = "relay_mobile_access"
func mergeAPITokenMetadata(record *config.APITokenRecord, metadata map[string]string) error {
if record == nil || len(metadata) == 0 {
return nil
}
if record.Metadata == nil {
record.Metadata = make(map[string]string)
}
for key, value := range metadata {
key = strings.TrimSpace(key)
if key == "" {
continue
}
if key == apiTokenMetadataOwnerUserID {
return fmt.Errorf("reserved token metadata key %q cannot be supplied by caller metadata", apiTokenMetadataOwnerUserID)
}
record.Metadata[key] = value
}
return nil
}
func setAPITokenOwnerUserID(record *config.APITokenRecord, ownerUserID string) {
if record == nil {
return
}
ownerUserID = strings.TrimSpace(ownerUserID)
if ownerUserID == "" || strings.HasPrefix(ownerUserID, "token:") {
return
}
if record.Metadata == nil {
record.Metadata = make(map[string]string)
}
record.Metadata[apiTokenMetadataOwnerUserID] = ownerUserID
}
func apiTokenOwnerUserID(record config.APITokenRecord) string {
if record.Metadata == nil {
return ""
}
return strings.TrimSpace(record.Metadata[apiTokenMetadataOwnerUserID])
}
func apiTokenOwnerUserIDForRequest(cfg *config.Config, req *http.Request) string {
if req == nil {
return ""
}
if callerToken := getAPITokenRecordFromRequest(req); callerToken != nil {
if ownerUserID := apiTokenOwnerUserID(*callerToken); ownerUserID != "" {
return ownerUserID
}
}
if user := strings.TrimSpace(internalauth.GetUser(req.Context())); user != "" && !strings.HasPrefix(user, "token:") {
return user
}
if cfg != nil {
if user := strings.TrimSpace(getAuthUsername(cfg, req)); user != "" && !strings.HasPrefix(user, "token:") {
return user
}
}
return ""
}
func toAPITokenDTO(record config.APITokenRecord) apiTokenDTO {
return apiTokenDTO{
ID: record.ID,
Name: record.Name,
Prefix: record.Prefix,
Suffix: record.Suffix,
CreatedAt: record.CreatedAt,
LastUsedAt: record.LastUsedAt,
ExpiresAt: record.ExpiresAt,
Scopes: append([]string{}, record.Scopes...),
OwnerUserID: apiTokenOwnerUserID(record),
}
}
func normalizeRequestedScopes(raw *[]string) ([]string, error) {
if raw == nil {
return []string{config.ScopeWildcard}, nil
}
requested := *raw
if len(requested) == 0 {
return nil, fmt.Errorf("select at least one scope or omit the field for full access")
}
seen := make(map[string]struct{}, len(requested))
normalized := make([]string, 0, len(requested))
hasWildcard := false
for _, scope := range requested {
scope = strings.TrimSpace(scope)
if scope == "" {
return nil, fmt.Errorf("scope identifiers cannot be blank")
}
scope = canonicalizeRequestedScope(scope)
if scope == config.ScopeWildcard {
hasWildcard = true
continue
}
if !config.IsKnownScope(scope) {
return nil, fmt.Errorf("unknown scope %q", scope)
}
if _, exists := seen[scope]; exists {
continue
}
seen[scope] = struct{}{}
normalized = append(normalized, scope)
}
if hasWildcard {
if len(normalized) > 0 {
return nil, fmt.Errorf("wildcard '*' cannot be combined with other scopes")
}
return []string{config.ScopeWildcard}, nil
}
sort.Strings(normalized)
return normalized, nil
}
func canonicalizeRequestedScope(scope string) string {
switch scope {
case "host-agent:report":
return config.ScopeAgentReport
case "host-agent:config:read":
return config.ScopeAgentConfigRead
case "host-agent:manage":
return config.ScopeAgentManage
case "host-agent:enroll":
return config.ScopeAgentEnroll
default:
return scope
}
}
func relayMobileAccessTokenScopes() []string {
return []string{
config.ScopeRelayMobileAccess,
}
}
func defaultRelayMobileAccessTokenName(now time.Time) string {
return fmt.Sprintf("Pulse Mobile relay access %s", now.UTC().Format(time.RFC3339))
}
func (r *Router) createAPITokenRecord(
req *http.Request,
name string,
scopes []string,
expiresIn *string,
metadata map[string]string,
) (string, *config.APITokenRecord, error) {
if callerToken := getAPITokenRecordFromRequest(req); callerToken != nil {
for _, requested := range scopes {
if !callerToken.HasScope(requested) {
return "", nil, fmt.Errorf("Cannot grant scope %q: your token does not have this scope", requested)
}
}
}
rawToken, err := internalauth.GenerateAPIToken()
if err != nil {
return "", nil, fmt.Errorf("generate raw token: %w", err)
}
record, err := config.NewAPITokenRecord(rawToken, name, scopes)
if err != nil {
return "", nil, fmt.Errorf("construct token record: %w", err)
}
record.OrgID = strings.TrimSpace(GetOrgID(req.Context()))
if record.OrgID == "" {
record.OrgID = "default"
}
setAPITokenOwnerUserID(record, apiTokenOwnerUserIDForRequest(r.config, req))
if err := mergeAPITokenMetadata(record, metadata); err != nil {
return "", nil, err
}
if expiresIn != nil && *expiresIn != "" {
dur, err := time.ParseDuration(*expiresIn)
if err != nil {
return "", nil, fmt.Errorf("invalid expiresIn duration: %w", err)
}
if dur < time.Minute {
return "", nil, fmt.Errorf("Token expiration must be at least 1 minute")
}
exp := time.Now().UTC().Add(dur)
record.ExpiresAt = &exp
}
config.Mu.Lock()
defer config.Mu.Unlock()
previousTokens := append([]config.APITokenRecord(nil), r.config.APITokens...)
r.config.APITokens = append(r.config.APITokens, *record)
r.config.SortAPITokens()
if r.persistence != nil {
if err := r.persistence.SaveAPITokens(r.config.APITokens); err != nil {
// The new record sorts newest-first, so truncating the sorted slice
// would remove an older valid token and leave the unreturned token
// active. Restore the complete pre-mutation inventory instead.
r.config.APITokens = previousTokens
r.config.SortAPITokens()
return "", nil, fmt.Errorf("persist token: %w", err)
}
}
return rawToken, record, nil
}
// handleListAPITokens returns all configured API tokens (metadata only).
func (r *Router) handleListAPITokens(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
config.Mu.RLock()
defer config.Mu.RUnlock()
tokens := make([]apiTokenDTO, 0, len(r.config.APITokens))
for _, record := range r.config.APITokens {
tokens = append(tokens, toAPITokenDTO(record))
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"tokens": tokens,
})
}
// handleGetAPIToken returns a single configured API token by ID (metadata only).
func (r *Router) handleGetAPIToken(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
tokenID := strings.TrimSpace(strings.TrimPrefix(req.URL.Path, "/api/security/tokens/"))
if tokenID == "" || strings.Contains(tokenID, "/") {
http.Error(w, "Token ID required", http.StatusBadRequest)
return
}
config.Mu.RLock()
defer config.Mu.RUnlock()
for _, record := range r.config.APITokens {
if record.ID != tokenID {
continue
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"record": toAPITokenDTO(record),
})
return
}
http.Error(w, "Token not found", http.StatusNotFound)
}
type createTokenRequest struct {
Name string `json:"name"`
Scopes *[]string `json:"scopes"`
ExpiresIn *string `json:"expiresIn,omitempty"` // e.g. "24h", "720h", "8760h"
}
type updateTokenRequest struct {
Name *string `json:"name"`
Scopes *[]string `json:"scopes"`
}
func (r *Router) auditTokenEvent(req *http.Request, event string, success bool, details string) {
user := internalauth.GetUser(req.Context())
if user == "" && r != nil && r.config != nil {
user = getAuthUsername(r.config, req)
}
LogAuditEventForTenant(GetOrgID(req.Context()), event, user, GetClientIP(req), req.URL.Path, success, details)
}
// handleCreateAPIToken generates and stores a new API token.
func (r *Router) handleCreateAPIToken(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var payload createTokenRequest
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil && err != io.EOF {
r.auditTokenEvent(req, "token_created", false, "Failed to decode create token request body")
log.Warn().Err(err).Msg("Failed to decode API token create request")
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
name := strings.TrimSpace(payload.Name)
if name == "" {
name = "API token"
}
scopes, err := normalizeRequestedScopes(payload.Scopes)
if err != nil {
r.auditTokenEvent(req, "token_created", false, "Invalid scope request for token creation")
log.Warn().Err(err).Msg("Invalid scopes provided for API token creation")
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
rawToken, record, err := r.createAPITokenRecord(req, name, scopes, payload.ExpiresIn, nil)
if err != nil {
switch {
case strings.HasPrefix(err.Error(), "Cannot grant scope "):
r.auditTokenEvent(req, "token_created", false, "Scope escalation denied for token creation")
http.Error(w, err.Error(), http.StatusForbidden)
case strings.HasPrefix(err.Error(), "invalid expiresIn duration: "):
r.auditTokenEvent(req, "token_created", false, "Invalid token expiration duration")
http.Error(w, "Invalid expiresIn duration: "+strings.TrimPrefix(err.Error(), "invalid expiresIn duration: "), http.StatusBadRequest)
case err.Error() == "Token expiration must be at least 1 minute":
r.auditTokenEvent(req, "token_created", false, "Token expiration duration below minimum")
http.Error(w, err.Error(), http.StatusBadRequest)
case strings.HasPrefix(err.Error(), "persist token: "):
r.auditTokenEvent(req, "token_created", false, "Failed to persist API token")
log.Error().Err(err).Msg("Failed to persist API tokens after creation")
http.Error(w, "Failed to save token", http.StatusInternalServerError)
default:
r.auditTokenEvent(req, "token_created", false, "Failed to generate API token")
log.Error().Err(err).Str("token_name", name).Msg("Failed to construct API token record")
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
}
return
}
r.auditTokenEvent(req, "token_created", true, fmt.Sprintf("Created API token id=%s scopes=%s", record.ID, strings.Join(record.Scopes, ",")))
log.Info().
Str("audit_event", "token_created").
Str("token_id", record.ID).
Str("token_name", record.Name).
Str("client_ip", req.RemoteAddr).
Msg("API token created")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"token": rawToken,
"record": toAPITokenDTO(*record),
})
}
// handleUpdateAPIToken changes an API token's metadata without rotating its secret.
func (r *Router) handleUpdateAPIToken(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPatch {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
tokenID := strings.TrimSpace(strings.TrimPrefix(req.URL.Path, "/api/security/tokens/"))
if tokenID == "" || strings.Contains(tokenID, "/") {
r.auditTokenEvent(req, "token_updated", false, "Missing or invalid API token ID in update request")
http.Error(w, "Token ID required", http.StatusBadRequest)
return
}
var payload updateTokenRequest
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
r.auditTokenEvent(req, "token_updated", false, fmt.Sprintf("Failed to decode token update for token id=%s", tokenID))
log.Warn().Err(err).Str("token_id", tokenID).Msg("Failed to decode API token update request")
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if payload.Scopes == nil && payload.Name == nil {
r.auditTokenEvent(req, "token_updated", false, fmt.Sprintf("Missing scopes field for token id=%s", tokenID))
http.Error(w, "Scopes field required", http.StatusBadRequest)
return
}
var scopes []string
if payload.Scopes != nil {
var err error
scopes, err = normalizeRequestedScopes(payload.Scopes)
if err != nil {
r.auditTokenEvent(req, "token_updated", false, fmt.Sprintf("Invalid scope update for token id=%s", tokenID))
log.Warn().Err(err).Str("token_id", tokenID).Msg("Invalid scopes provided for API token update")
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
var name string
if payload.Name != nil {
name = strings.TrimSpace(*payload.Name)
if name == "" {
r.auditTokenEvent(req, "token_renamed", false, fmt.Sprintf("Blank name rejected for token id=%s", tokenID))
http.Error(w, "Token name is required", http.StatusBadRequest)
return
}
}
config.Mu.Lock()
defer config.Mu.Unlock()
tokenIndex := -1
for idx := range r.config.APITokens {
if r.config.APITokens[idx].ID == tokenID {
tokenIndex = idx
break
}
}
if tokenIndex == -1 {
r.auditTokenEvent(req, "token_updated", false, fmt.Sprintf("API token id=%s not found for update", tokenID))
http.Error(w, "Token not found", http.StatusNotFound)
return
}
storedRecord := r.config.APITokens[tokenIndex]
oldRecord := storedRecord.Clone()
// A token-authenticated caller must be able to control both sides of the
// transition. Checking the existing scopes prevents a limited caller from
// narrowing or otherwise mutating a more privileged credential; checking
// the requested scopes prevents widening a token beyond the caller's own
// authority, including when editing itself.
if payload.Scopes != nil {
if callerToken := getAPITokenRecordFromRequest(req); callerToken != nil {
for _, scope := range oldRecord.Scopes {
if !callerToken.HasScope(scope) {
r.auditTokenEvent(req, "token_updated", false,
fmt.Sprintf("Scope update denied: caller missing existing scope %q on target token id=%s", scope, tokenID))
http.Error(w, fmt.Sprintf("Cannot update token with scope %q: your token does not have this scope", scope), http.StatusForbidden)
return
}
}
for _, scope := range scopes {
if !callerToken.HasScope(scope) {
r.auditTokenEvent(req, "token_updated", false,
fmt.Sprintf("Scope update denied: caller cannot grant scope %q on target token id=%s", scope, tokenID))
http.Error(w, fmt.Sprintf("Cannot grant scope %q: your token does not have this scope", scope), http.StatusForbidden)
return
}
}
}
}
if payload.Scopes != nil {
r.config.APITokens[tokenIndex].Scopes = append([]string(nil), scopes...)
}
if payload.Name != nil {
r.config.APITokens[tokenIndex].Name = name
}
if r.persistence != nil {
if err := r.persistence.SaveAPITokens(r.config.APITokens); err != nil {
r.config.APITokens[tokenIndex] = storedRecord
r.auditTokenEvent(req, "token_updated", false, fmt.Sprintf("Failed to persist token update for token id=%s", tokenID))
log.Error().Err(err).Str("token_id", tokenID).Msg("Failed to persist API token update")
http.Error(w, "Failed to save token", http.StatusInternalServerError)
return
}
}
updatedRecord := r.config.APITokens[tokenIndex]
if payload.Scopes != nil {
r.auditTokenEvent(req, "token_updated", true,
fmt.Sprintf(
"Updated API token id=%s old_scopes=%s new_scopes=%s",
tokenID,
strings.Join(oldRecord.Scopes, ","),
strings.Join(updatedRecord.Scopes, ","),
))
}
if payload.Name != nil {
r.auditTokenEvent(req, "token_renamed", true, fmt.Sprintf("Renamed API token id=%s", tokenID))
}
logEvent := log.Info().
Str("audit_event", "token_updated").
Str("token_id", tokenID).
Str("token_name", updatedRecord.Name).
Str("client_ip", req.RemoteAddr)
if payload.Scopes != nil {
logEvent = logEvent.
Strs("old_scopes", oldRecord.Scopes).
Strs("new_scopes", updatedRecord.Scopes)
}
logEvent.Msg("API token updated")
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"record": toAPITokenDTO(updatedRecord),
})
}
// handleCreateRelayMobileAccessToken generates the canonical Pulse Mobile relay runtime token.
func (r *Router) handleCreateRelayMobileAccessToken(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
rawToken, record, err := r.createAPITokenRecord(
req,
defaultRelayMobileAccessTokenName(time.Now().UTC()),
relayMobileAccessTokenScopes(),
nil,
map[string]string{apiTokenMetadataPurpose: apiTokenPurposeRelayMobileAccess},
)
if err != nil {
switch {
case strings.HasPrefix(err.Error(), "Cannot grant scope "):
r.auditTokenEvent(req, "token_created", false, "Relay mobile scope escalation denied")
http.Error(w, err.Error(), http.StatusForbidden)
case strings.HasPrefix(err.Error(), "persist token: "):
r.auditTokenEvent(req, "token_created", false, "Failed to persist relay mobile access token")
log.Error().Err(err).Msg("Failed to persist relay mobile access token")
http.Error(w, "Failed to save token", http.StatusInternalServerError)
default:
r.auditTokenEvent(req, "token_created", false, "Failed to generate relay mobile access token")
log.Error().Err(err).Msg("Failed to generate relay mobile access token")
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
}
return
}
r.auditTokenEvent(req, "token_created", true, fmt.Sprintf("Created relay mobile access token id=%s scopes=%s", record.ID, strings.Join(record.Scopes, ",")))
log.Info().
Str("audit_event", "token_created").
Str("token_id", record.ID).
Str("token_name", record.Name).
Str("client_ip", req.RemoteAddr).
Msg("Relay mobile access token created")
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"token": rawToken,
"record": toAPITokenDTO(*record),
})
}
// handleRenameAPIToken changes token metadata without rotating the credential.
func (r *Router) handleRenameAPIToken(w http.ResponseWriter, req *http.Request) {
r.handleUpdateAPIToken(w, req)
}
// handleDeleteAPIToken removes an API token by ID.
func (r *Router) handleDeleteAPIToken(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodDelete {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
tokenID := strings.TrimPrefix(req.URL.Path, "/api/security/tokens/")
if tokenID == "" {
r.auditTokenEvent(req, "token_deleted", false, "Missing API token ID in delete request")
http.Error(w, "Token ID required", http.StatusBadRequest)
return
}
config.Mu.Lock()
defer config.Mu.Unlock()
previousTokens := append([]config.APITokenRecord(nil), r.config.APITokens...)
removed := r.config.RemoveAPIToken(tokenID)
if removed == nil {
r.auditTokenEvent(req, "token_deleted", false, fmt.Sprintf("API token id=%s not found for deletion", tokenID))
http.Error(w, "Token not found", http.StatusNotFound)
return
}
// Scope escalation prevention: if the caller is an API token, they can only
// delete tokens whose scopes are a subset of their own. This prevents a
// limited-scope token from deleting a broader token and forcing rotation or
// lockout on a more privileged credential.
if callerToken := getAPITokenRecordFromRequest(req); callerToken != nil {
for _, targetScope := range removed.Scopes {
if !callerToken.HasScope(targetScope) {
// Put the record back before returning so the in-memory config is unchanged.
r.config.APITokens = append(r.config.APITokens, *removed)
r.config.SortAPITokens()
r.auditTokenEvent(req, "token_deleted", false,
fmt.Sprintf("Scope escalation denied: caller missing scope %q on target token id=%s", targetScope, tokenID))
http.Error(w, fmt.Sprintf("Cannot delete token with scope %q: your token does not have this scope", targetScope), http.StatusForbidden)
return
}
}
}
r.config.SortAPITokens()
if r.persistence != nil {
if err := r.persistence.SaveAPITokens(r.config.APITokens); err != nil {
r.config.APITokens = previousTokens
r.config.SortAPITokens()
r.auditTokenEvent(req, "token_deleted", false,
fmt.Sprintf("Failed to persist API token deletion for id=%s", tokenID))
log.Error().Err(err).Msg("Failed to persist API tokens after deletion")
http.Error(w, "Failed to save token deletion", http.StatusInternalServerError)
return
}
}
r.auditTokenEvent(req, "token_deleted", true, fmt.Sprintf("Deleted API token id=%s", removed.ID))
log.Info().
Str("audit_event", "token_deleted").
Str("token_id", removed.ID).
Str("token_name", removed.Name).
Str("client_ip", req.RemoteAddr).
Msg("API token deleted")
w.WriteHeader(http.StatusNoContent)
}
// handleRotateAPIToken atomically rotates an API token: creates a new token and removes the old one.
func (r *Router) handleRotateAPIToken(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Extract token ID: /api/security/tokens/{id}/rotate
path := strings.TrimPrefix(req.URL.Path, "/api/security/tokens/")
parts := strings.Split(path, "/")
if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" || parts[1] != "rotate" {
r.auditTokenEvent(req, "token_rotated", false, "Missing or invalid API token ID for rotate request")
http.Error(w, "Token ID required", http.StatusBadRequest)
return
}
tokenID := strings.TrimSpace(parts[0])
config.Mu.Lock()
defer config.Mu.Unlock()
// Find existing token
var oldRecord config.APITokenRecord
found := false
for idx := range r.config.APITokens {
if r.config.APITokens[idx].ID == tokenID {
oldRecord = r.config.APITokens[idx] // copy for safety (slice may reallocate)
found = true
break
}
}
if !found {
r.auditTokenEvent(req, "token_rotated", false, fmt.Sprintf("API token id=%s not found for rotation", tokenID))
http.Error(w, "Token not found", http.StatusNotFound)
return
}
// Scope escalation prevention: if the caller is an API token, they can only
// rotate tokens whose scopes are a subset of their own. This prevents a
// limited-scope token from rotating a wildcard token to obtain its raw value.
if callerToken := getAPITokenRecordFromRequest(req); callerToken != nil {
for _, targetScope := range oldRecord.Scopes {
if !callerToken.HasScope(targetScope) {
r.auditTokenEvent(req, "token_rotated", false,
fmt.Sprintf("Scope escalation denied: caller missing scope %q on target token id=%s", targetScope, tokenID))
http.Error(w, fmt.Sprintf("Cannot rotate token with scope %q: your token does not have this scope", targetScope), http.StatusForbidden)
return
}
}
}
// Generate new token
rawToken, err := internalauth.GenerateAPIToken()
if err != nil {
r.auditTokenEvent(req, "token_rotated", false, fmt.Sprintf("Failed to generate replacement token for id=%s", tokenID))
log.Error().Err(err).Msg("Failed to generate token during rotation")
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
return
}
newRecord, err := config.NewAPITokenRecord(rawToken, oldRecord.Name, oldRecord.Scopes)
if err != nil {
r.auditTokenEvent(req, "token_rotated", false, fmt.Sprintf("Failed to construct replacement token for id=%s", tokenID))
log.Error().Err(err).Msg("Failed to construct token record during rotation")
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
return
}
// Preserve org bindings from old token
newRecord.OrgID = oldRecord.OrgID
newRecord.OrgIDs = append([]string{}, oldRecord.OrgIDs...)
if oldRecord.Metadata != nil {
newRecord.Metadata = make(map[string]string, len(oldRecord.Metadata))
for k, v := range oldRecord.Metadata {
newRecord.Metadata[k] = v
}
}
// Preserve expiration policy if set
if oldRecord.ExpiresAt != nil {
t := *oldRecord.ExpiresAt
newRecord.ExpiresAt = &t
}
// Remove old, add new (rollback if persistence fails)
prevTokens := append([]config.APITokenRecord{}, r.config.APITokens...)
r.config.RemoveAPIToken(tokenID)
r.config.APITokens = append(r.config.APITokens, *newRecord)
r.config.SortAPITokens()
if r.persistence != nil {
if err := r.persistence.SaveAPITokens(r.config.APITokens); err != nil {
// Rollback the in-memory rotation so the in-memory config stays consistent with disk.
r.config.APITokens = prevTokens
r.config.SortAPITokens()
r.auditTokenEvent(req, "token_rotated", false, fmt.Sprintf("Failed to persist rotated token for id=%s", tokenID))
log.Error().Err(err).Msg("Failed to persist API tokens after rotation")
http.Error(w, "Failed to save rotated token", http.StatusInternalServerError)
return
}
}
r.auditTokenEvent(req, "token_rotated", true, fmt.Sprintf("Rotated API token old_id=%s new_id=%s", tokenID, newRecord.ID))
log.Info().
Str("audit_event", "token_rotated").
Str("old_token_id", tokenID).
Str("new_token_id", newRecord.ID).
Str("token_name", newRecord.Name).
Str("client_ip", req.RemoteAddr).
Msg("API token rotated")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"token": rawToken,
"record": toAPITokenDTO(*newRecord),
})
}