feat: Add the API token service

Mint, resolve, list and revoke, with the effective role capped at the
owner's and recomputed per request rather than frozen at creation.

Deleting a user deletes their tokens in the same call, so offboarding is
one action. Revoking somebody else's token answers not-found rather than
forbidden, since a 403 confirms the credential exists.

Also re-exports shared.APITokenMaxDays into server/internal/models,
following the existing ValidRole wrapper pattern, since the token
service needs it and it was never re-exported.
This commit is contained in:
2026-08-12 14:28:10 +00:00
parent 33b5ec0788
commit 72e5228351
3 changed files with 270 additions and 1 deletions
+4
View File
@@ -7,3 +7,7 @@ type (
AlertSettings = shared.AlertSettings
SecretsSettings = shared.SecretsSettings
)
// APITokenMaxDays re-exports shared.APITokenMaxDays so server/internal/services
// can read the token lifetime cap without importing shared/models directly.
func APITokenMaxDays(s *Settings) int { return shared.APITokenMaxDays(s) }
+255
View File
@@ -0,0 +1,255 @@
package services
import (
"context"
"errors"
"fmt"
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
var (
ErrTokenNotFound = errors.New("token not found")
ErrTokenExpired = errors.New("token expired")
ErrTokenNameTaken = errors.New("a token with that name already exists")
ErrTokenRoleTooHigh = errors.New("cannot create a token above your own role")
ErrTokenExpiryPolicy = errors.New("expiry exceeds this instance's maximum token lifetime")
)
// TokenPrefix is on every plaintext so a leaked value is recognisable in a log
// or a paste, and so a wrong credential fails at the prefix check rather than
// as an anonymous 401.
const TokenPrefix = "vt_"
const tokenNameMax = 64
// roleRank orders the three roles so a token can be capped at its owner's.
func roleRank(role string) int {
switch role {
case models.RoleOwner:
return 3
case models.RoleAdmin:
return 2
case models.RoleMember:
return 1
}
return 0
}
// LowerRole returns whichever of the two roles grants less. It is what makes a
// token's authority follow its owner: demote the person and the token demotes
// with them, because this is recomputed on every request rather than frozen at
// creation.
func LowerRole(a, b string) string {
if roleRank(a) <= roleRank(b) {
return a
}
return b
}
// CreateAPIToken mints a token and returns the document plus the plaintext.
// The plaintext is the only copy: it is returned once and never stored.
func CreateAPIToken(instanceID, userID, name, role string, scopes []string, expiresInDays *int, ip string) (*models.APIToken, string, error) {
name = strings.TrimSpace(name)
if name == "" || len(name) > tokenNameMax {
return nil, "", fmt.Errorf("token name must be 1 to %d characters", tokenNameMax)
}
if !models.ValidRole(role) {
return nil, "", fmt.Errorf("invalid role %q", role)
}
if err := ValidScopes(scopes); err != nil {
return nil, "", err
}
owner, err := GetUserInInstance(instanceID, userID)
if err != nil {
return nil, "", fmt.Errorf("user not found")
}
if roleRank(role) > roleRank(owner.Role) {
return nil, "", ErrTokenRoleTooHigh
}
settings, err := GetSettings(instanceID)
if err != nil {
return nil, "", err
}
maxDays := models.APITokenMaxDays(settings)
var expiresAt *time.Time
switch {
case expiresInDays != nil:
if *expiresInDays <= 0 {
return nil, "", fmt.Errorf("expires_in_days must be positive")
}
if maxDays > 0 && *expiresInDays > maxDays {
return nil, "", fmt.Errorf("%w: maximum is %d day(s)", ErrTokenExpiryPolicy, maxDays)
}
t := time.Now().UTC().AddDate(0, 0, *expiresInDays)
expiresAt = &t
case maxDays > 0:
// A policy is set, so a token with no expiry is refused rather than
// silently capped: the caller asked for something the instance does not
// allow, and quietly giving them something else is worse than a 422.
return nil, "", fmt.Errorf("%w: an expiry of at most %d day(s) is required", ErrTokenExpiryPolicy, maxDays)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
existing := db.Col("api_tokens").FindOne(ctx, bson.M{"instance_id": instanceID, "user_id": userID, "name": name})
if existing.Err() == nil {
return nil, "", ErrTokenNameTaken
} else if !errors.Is(existing.Err(), mongo.ErrNoDocuments) {
return nil, "", existing.Err()
}
secret, err := generateToken(32)
if err != nil {
return nil, "", err
}
plaintext := TokenPrefix + secret
tok := &models.APIToken{
TokenID: uuid.NewString(),
InstanceID: instanceID,
UserID: userID,
Name: name,
Hint: plaintext[:8],
TokenHash: HashToken(plaintext),
Role: role,
Scopes: scopes,
ExpiresAt: expiresAt,
CreatedAt: time.Now().UTC(),
CreatedByIP: ip,
}
if _, err := db.Col("api_tokens").InsertOne(ctx, tok); err != nil {
return nil, "", err
}
return tok, plaintext, nil
}
// ResolveAPIToken looks a plaintext up by hash.
//
// It returns ErrTokenExpired distinctly from ErrTokenNotFound so the auth layer
// can say which happened: a forgotten CI job hitting an expired token is worth
// seeing in the audit log, and an anonymous 401 hides it.
func ResolveAPIToken(plaintext string) (*models.APIToken, error) {
if !strings.HasPrefix(plaintext, TokenPrefix) {
return nil, ErrTokenNotFound
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var tok models.APIToken
err := db.Col("api_tokens").FindOne(ctx, bson.M{"token_hash": HashToken(plaintext)}).Decode(&tok)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, ErrTokenNotFound
}
if err != nil {
return nil, err
}
if tok.Expired(time.Now().UTC()) {
return &tok, ErrTokenExpired
}
return &tok, nil
}
// TouchAPIToken records use, but only when the stored value is more than a
// minute stale. Without the check this is a Mongo write on every API call.
func TouchAPIToken(tok *models.APIToken) {
now := time.Now().UTC()
if tok.LastUsedAt != nil && now.Sub(*tok.LastUsedAt) < time.Minute {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, _ = db.Col("api_tokens").UpdateOne(ctx,
bson.M{"token_id": tok.TokenID, "instance_id": tok.InstanceID},
bson.M{"$set": bson.M{"last_used_at": now}},
)
tok.LastUsedAt = &now
}
// ListAPITokens returns a user's own tokens, or every token in the instance
// when all is true. The caller decides whether all is permitted.
func ListAPITokens(instanceID string, userID string, all bool) ([]models.APIToken, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
filter := bson.M{"instance_id": instanceID}
if !all {
filter["user_id"] = userID
}
cursor, err := db.Col("api_tokens").Find(ctx, filter)
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
var tokens []models.APIToken
if err := cursor.All(ctx, &tokens); err != nil {
return nil, err
}
if tokens == nil {
tokens = []models.APIToken{}
}
// Join the owning email so an admin's list names people rather than UUIDs.
users, err := ListUsers(instanceID)
if err == nil {
byID := make(map[string]string, len(users))
for _, u := range users {
byID[u.UserID] = u.Email
}
for i := range tokens {
tokens[i].UserEmail = byID[tokens[i].UserID]
}
}
return tokens, nil
}
// RevokeAPIToken deletes a token. A member may revoke only their own; owner and
// admin may revoke any token in the instance.
func RevokeAPIToken(instanceID, tokenID string, requester *models.User) (*models.APIToken, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var tok models.APIToken
err := db.Col("api_tokens").FindOne(ctx, bson.M{"instance_id": instanceID, "token_id": tokenID}).Decode(&tok)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, ErrTokenNotFound
}
if err != nil {
return nil, err
}
elevated := requester.Role == models.RoleOwner || requester.Role == models.RoleAdmin
if tok.UserID != requester.UserID && !elevated {
// Not 403: confirming the token exists tells a member about somebody
// else's credential. Same argument as admin's customer endpoints.
return nil, ErrTokenNotFound
}
if _, err := db.Col("api_tokens").DeleteOne(ctx, bson.M{"instance_id": instanceID, "token_id": tokenID}); err != nil {
return nil, err
}
return &tok, nil
}
// DeleteTokensForUser removes every token belonging to a user. Offboarding is
// one action, not two: a token that outlives its owner is an access path with
// nobody attached to it.
func DeleteTokensForUser(instanceID, userID string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := db.Col("api_tokens").DeleteMany(ctx, bson.M{"instance_id": instanceID, "user_id": userID})
return err
}
+11 -1
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"log"
"strings"
"time"
@@ -175,5 +176,14 @@ func DeleteUser(instanceID, userID string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err = db.Col("users").DeleteOne(ctx, bson.M{"user_id": userID, "instance_id": instanceID})
return err
if err != nil {
return err
}
// Offboarding is one action. A token outliving its owner is an access path
// with nobody attached to it.
if err := DeleteTokensForUser(instanceID, userID); err != nil {
log.Printf("delete tokens for user %s: %v", userID, err)
}
return nil
}