From 6ad65a12428c0aa2106f611b383ee5543cc7675a Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Wed, 12 Aug 2026 14:16:12 +0000 Subject: [PATCH] feat: Add the api_tokens collection and its indexes The unique index on token_hash is what makes authentication an indexed lookup rather than a scan, so this builder is fatal on failure like EnsureAuthIndexes rather than warning like the secrets one. Registered in ScopedCollections so instance purge reaches it. --- server/cmd/main.go | 4 ++ server/internal/models/api_token.go | 52 ++++++++++++++++++++ server/internal/services/migrate_instance.go | 1 + server/internal/services/tokenindexes.go | 39 +++++++++++++++ 4 files changed, 96 insertions(+) create mode 100644 server/internal/models/api_token.go create mode 100644 server/internal/services/tokenindexes.go diff --git a/server/cmd/main.go b/server/cmd/main.go index 3768f9b..0211c20 100644 --- a/server/cmd/main.go +++ b/server/cmd/main.go @@ -109,6 +109,10 @@ func runSchemaSetup() { log.Fatalf("failed to ensure auth indexes: %v", err) } + if err := services.EnsureAPITokenIndexes(); err != nil { + log.Fatalf("api token indexes: %v", err) + } + // 0005 runs AFTER EnsureAuthIndexes: the unique (instance_id, provider_id) // index must exist before anything inserts providers, or a concurrent // re-run could double-insert before the index is there to refuse it. diff --git a/server/internal/models/api_token.go b/server/internal/models/api_token.go new file mode 100644 index 0000000..996a7d8 --- /dev/null +++ b/server/internal/models/api_token.go @@ -0,0 +1,52 @@ +package models + +import ( + "time" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +// APIToken is a personal access token for the REST API. +// +// The plaintext is shown once at creation and never stored: only TokenHash, +// which is sha256 hex of the value, exactly as servers.agent_token_hash and the +// ESO read token already are. bcrypt is deliberately not used — the value is +// full-entropy random rather than a chosen password, and a per-token salt would +// force a collection scan where an indexed lookup is wanted. +// +// Role and Scopes are immutable after creation. There is no update endpoint: +// editing what a credential already deployed in CI can do, with no record of +// what it could do before, is worse than requiring a rotation. +type APIToken struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"-"` + TokenID string `bson:"token_id" json:"token_id"` + InstanceID string `bson:"instance_id" json:"instance_id"` + UserID string `bson:"user_id" json:"user_id"` + + Name string `bson:"name" json:"name"` + // Hint is the first 8 characters of the plaintext, stored in clear so the + // list can identify a token without revealing it. + Hint string `bson:"hint" json:"hint"` + // TokenHash is never serialised to JSON. + TokenHash string `bson:"token_hash" json:"-"` + + Role string `bson:"role" json:"role"` + Scopes []string `bson:"scopes" json:"scopes"` + + // ExpiresAt nil means the token never expires. Whether that is allowed is + // a per-instance policy, settings.api_token_max_days. + ExpiresAt *time.Time `bson:"expires_at,omitempty" json:"expires_at,omitempty"` + + CreatedAt time.Time `bson:"created_at" json:"created_at"` + LastUsedAt *time.Time `bson:"last_used_at,omitempty" json:"last_used_at,omitempty"` + CreatedByIP string `bson:"created_by_ip,omitempty" json:"created_by_ip,omitempty"` + + // Email of the owning user, joined at read time for the list. Never stored. + UserEmail string `bson:"-" json:"user_email,omitempty"` +} + +// Expired reports whether the token's expiry has passed. A nil ExpiresAt never +// expires. +func (t *APIToken) Expired(now time.Time) bool { + return t.ExpiresAt != nil && now.After(*t.ExpiresAt) +} diff --git a/server/internal/services/migrate_instance.go b/server/internal/services/migrate_instance.go index 77143ae..1149d81 100644 --- a/server/internal/services/migrate_instance.go +++ b/server/internal/services/migrate_instance.go @@ -43,6 +43,7 @@ var ScopedCollections = []string{ "server_packages", "vuln_findings", "vuln_alert_rules", + "api_tokens", "server_workloads", } diff --git a/server/internal/services/tokenindexes.go b/server/internal/services/tokenindexes.go new file mode 100644 index 0000000..8dfecee --- /dev/null +++ b/server/internal/services/tokenindexes.go @@ -0,0 +1,39 @@ +package services + +import ( + "context" + "time" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +// EnsureAPITokenIndexes declares the indexes the token path depends on. +// +// The unique index on token_hash is a security property, not an optimisation: +// it is what makes authentication a single indexed lookup rather than a scan, +// and what makes two tokens hashing to one value impossible to store. +// +// Fatal on failure, like EnsureAuthIndexes and unlike the secrets and workflow +// builders: without the unique index the auth path would still answer, which is +// exactly the wrong kind of degradation. +func EnsureAPITokenIndexes() error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if _, err := db.Col("api_tokens").Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: "token_hash", Value: 1}}, + Options: options.Index().SetUnique(true), + }); err != nil { + return err + } + + if _, err := db.Col("api_tokens").Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "user_id", Value: 1}}, + }); err != nil { + return err + } + return nil +}