From 72e52283516b399ecb682c7002f961ea34720100 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Wed, 12 Aug 2026 14:28:10 +0000 Subject: [PATCH] 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. --- server/internal/models/settings.go | 4 + server/internal/services/tokens.go | 255 +++++++++++++++++++++++++++++ server/internal/services/users.go | 12 +- 3 files changed, 270 insertions(+), 1 deletion(-) create mode 100644 server/internal/services/tokens.go diff --git a/server/internal/models/settings.go b/server/internal/models/settings.go index d2815e0..56621d0 100644 --- a/server/internal/models/settings.go +++ b/server/internal/models/settings.go @@ -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) } diff --git a/server/internal/services/tokens.go b/server/internal/services/tokens.go new file mode 100644 index 0000000..fcf76f4 --- /dev/null +++ b/server/internal/services/tokens.go @@ -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 +} diff --git a/server/internal/services/users.go b/server/internal/services/users.go index fa99677..4adf494 100644 --- a/server/internal/services/users.go +++ b/server/internal/services/users.go @@ -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 }