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.
This commit is contained in:
2026-08-12 14:16:12 +00:00
parent a41f2b26cc
commit 6ad65a1242
4 changed files with 96 additions and 0 deletions
+4
View File
@@ -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.
+52
View File
@@ -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)
}
@@ -43,6 +43,7 @@ var ScopedCollections = []string{
"server_packages",
"vuln_findings",
"vuln_alert_rules",
"api_tokens",
"server_workloads",
}
+39
View File
@@ -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
}