From 04e8fcda7949523bdc42c4e80f6dd6adb253605f Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 27 Jun 2026 12:20:41 +0000 Subject: [PATCH] db: collapse pre-auth keys into the credentials table Backfill keeping ids, retarget the nodes FK to credentials(id), drop the four per-kind tables. Node.AuthKey becomes a *Credential association. --- hscontrol/api/v1/nodes.go | 8 +- hscontrol/auth_test.go | 10 +- hscontrol/db/credential.go | 21 +++++ hscontrol/db/db.go | 35 ++++--- hscontrol/db/db_test.go | 5 +- hscontrol/db/migrate_credentials.go | 138 ++++++++++++++++++++++++++++ hscontrol/db/preauth_keys.go | 94 +++++++++++-------- hscontrol/db/preauth_keys_test.go | 84 +++-------------- hscontrol/db/schema.sql | 64 +------------ hscontrol/db/users_test.go | 6 +- hscontrol/mapper/tail_test.go | 2 +- hscontrol/policy/v2/types_test.go | 2 +- hscontrol/state/ephemeral_test.go | 9 +- hscontrol/state/state.go | 19 +++- hscontrol/types/common.go | 4 +- hscontrol/types/credential.go | 32 +++++++ hscontrol/types/node.go | 10 +- hscontrol/types/node_tags_test.go | 14 +-- hscontrol/types/types_clone.go | 55 ++++++++++- hscontrol/types/types_view.go | 137 +++++++++++++++++++++++++-- integration/cli_apikeys_test.go | 15 ++- 21 files changed, 530 insertions(+), 234 deletions(-) create mode 100644 hscontrol/db/migrate_credentials.go diff --git a/hscontrol/api/v1/nodes.go b/hscontrol/api/v1/nodes.go index dbc1adfc5..c3088a013 100644 --- a/hscontrol/api/v1/nodes.go +++ b/hscontrol/api/v1/nodes.go @@ -626,12 +626,12 @@ func nodeFromView(view types.NodeView) Node { return n } -// nodePreAuthKeyFromView builds the embedded NodePreAuthKey, masking the key to -// its prefix (legacy plaintext keys are shown in full). -func nodePreAuthKeyFromView(key types.PreAuthKeyView) *NodePreAuthKey { +// nodePreAuthKeyFromView builds the embedded NodePreAuthKey from a node's +// AuthKey credential, masking the secret to its identifier prefix. +func nodePreAuthKeyFromView(key types.CredentialView) *NodePreAuthKey { pak := &NodePreAuthKey{ ID: formatID(key.ID()), - Key: maskedPreAuthKey(key), + Key: "hskey-auth-" + key.Identifier() + "-***", Reusable: key.Reusable(), Ephemeral: key.Ephemeral(), Used: key.Used(), diff --git a/hscontrol/auth_test.go b/hscontrol/auth_test.go index ed294f3ca..605dd87e0 100644 --- a/hscontrol/auth_test.go +++ b/hscontrol/auth_test.go @@ -3398,8 +3398,8 @@ func TestIssue2830_ExistingNodeReregistersWithExpiredKey(t *testing.T) { // Now expire the key by updating it in the database to have an expiry in the past. // This simulates the real-world scenario where a key expires after initial registration. pastExpiry := time.Now().Add(-1 * time.Hour) - err = app.state.DB().DB.Model(&types.PreAuthKey{}). - Where("id = ?", pak.ID). + err = app.state.DB().DB.Model(&types.Credential{}). + Where("kind = ? AND id = ?", types.CredentialPreAuthKey, pak.ID). Update("expiration", pastExpiry).Error require.NoError(t, err, "should be able to update key expiration") @@ -3840,7 +3840,7 @@ func TestDeletedPreAuthKeyNotRecreatedOnNodeUpdate(t *testing.T) { // Verify the PreAuthKey exists in the database var pakCount int64 - err = app.state.DB().DB.Model(&types.PreAuthKey{}).Where("id = ?", pakID).Count(&pakCount).Error + err = app.state.DB().DB.Model(&types.Credential{}).Where("kind = ? AND id = ?", types.CredentialPreAuthKey, pakID).Count(&pakCount).Error require.NoError(t, err) require.Equal(t, int64(1), pakCount, "PreAuthKey should exist in database") @@ -3851,7 +3851,7 @@ func TestDeletedPreAuthKeyNotRecreatedOnNodeUpdate(t *testing.T) { require.NoError(t, err, "deleting PreAuthKey should succeed") // Verify the PreAuthKey is gone from the database - err = app.state.DB().DB.Model(&types.PreAuthKey{}).Where("id = ?", pakID).Count(&pakCount).Error + err = app.state.DB().DB.Model(&types.Credential{}).Where("kind = ? AND id = ?", types.CredentialPreAuthKey, pakID).Count(&pakCount).Error require.NoError(t, err) require.Equal(t, int64(0), pakCount, "PreAuthKey should be deleted from database") t.Log("PreAuthKey deleted from database") @@ -3886,7 +3886,7 @@ func TestDeletedPreAuthKeyNotRecreatedOnNodeUpdate(t *testing.T) { t.Log("Simulated MapRequest update completed") // THE CRITICAL CHECK: Verify the PreAuthKey was NOT recreated - err = app.state.DB().DB.Model(&types.PreAuthKey{}).Where("id = ?", pakID).Count(&pakCount).Error + err = app.state.DB().DB.Model(&types.Credential{}).Where("kind = ? AND id = ?", types.CredentialPreAuthKey, pakID).Count(&pakCount).Error require.NoError(t, err) require.Equal(t, int64(0), pakCount, "BUG: PreAuthKey was recreated! The deleted PreAuthKey should NOT reappear after node update") diff --git a/hscontrol/db/credential.go b/hscontrol/db/credential.go index 39e95d8ee..979a5817d 100644 --- a/hscontrol/db/credential.go +++ b/hscontrol/db/credential.go @@ -34,6 +34,27 @@ func credentialToOAuthClient(c *types.Credential) *types.OAuthClient { } } +// credentialToPreAuthKey projects a unified credentials row onto the +// [types.PreAuthKey] shape. The lookup prefix is stored as the row's identifier; +// the User association is carried through when preloaded. +func credentialToPreAuthKey(c *types.Credential) *types.PreAuthKey { + return &types.PreAuthKey{ + ID: c.ID, + Prefix: c.Identifier, + Hash: c.Hash, + UserID: c.UserID, + User: c.User, + Description: c.Description, + Reusable: c.Reusable, + Ephemeral: c.Ephemeral, + Used: c.Used, + Tags: c.Tags, + CreatedAt: c.CreatedAt, + Expiration: c.Expiration, + Revoked: c.Revoked, + } +} + // credentialToOAuthAccessToken projects a unified credentials row onto the // [types.OAuthAccessToken] shape. The token's lookup prefix is stored as the // row's identifier; ClientID links back to the issuing client's identifier. diff --git a/hscontrol/db/db.go b/hscontrol/db/db.go index 2355a85e2..07c370e32 100644 --- a/hscontrol/db/db.go +++ b/hscontrol/db/db.go @@ -948,20 +948,37 @@ WHERE user_id IS NULL }, Rollback: func(db *gorm.DB) error { return nil }, }, + { + // Move every credential into the unified table and drop the + // per-kind tables. Pre-auth keys are backfilled FIRST preserving + // their ids so nodes.auth_key_id stays valid; the nodes FK is then + // retargeted to credentials(id). Legacy plaintext pre-auth keys + // (empty prefix) are not migrated (breaking change) and any node + // referencing one has its auth_key_id cleared first. + ID: "202606271300-migrate-to-credentials", + Migrate: func(tx *gorm.DB) error { + // Already migrated (e.g. fresh DB via InitSchema): nothing to do. + if !tx.Migrator().HasTable("pre_auth_keys") && + !tx.Migrator().HasTable("api_keys") { + return nil + } + + return migrateToCredentials(tx) + }, + Rollback: func(db *gorm.DB) error { return nil }, + }, }, ) migrations.InitSchema(func(tx *gorm.DB) error { // Create all tables using AutoMigrate + // Credential is migrated before Node so the nodes.auth_key_id foreign key + // to credentials(id) can be created. err := tx.AutoMigrate( &types.User{}, - &types.PreAuthKey{}, - &types.APIKey{}, + &types.Credential{}, &types.Node{}, &types.Policy{}, - &types.OAuthClient{}, - &types.OAuthAccessToken{}, - &types.Credential{}, ) if err != nil { return err @@ -971,14 +988,10 @@ WHERE user_id IS NULL // to ensure we can recreate them in the correct format dropIndexes := []string{ `DROP INDEX IF EXISTS "idx_users_deleted_at"`, - `DROP INDEX IF EXISTS "idx_api_keys_prefix"`, `DROP INDEX IF EXISTS "idx_policies_deleted_at"`, `DROP INDEX IF EXISTS "idx_provider_identifier"`, `DROP INDEX IF EXISTS "idx_name_provider_identifier"`, `DROP INDEX IF EXISTS "idx_name_no_provider_identifier"`, - `DROP INDEX IF EXISTS "idx_pre_auth_keys_prefix"`, - `DROP INDEX IF EXISTS "idx_oauth_clients_client_id"`, - `DROP INDEX IF EXISTS "idx_oauth_access_tokens_prefix"`, `DROP INDEX IF EXISTS "idx_credentials_identifier"`, } @@ -992,14 +1005,10 @@ WHERE user_id IS NULL // Recreate indexes without backticks to match schema.sql format indexes := []string{ `CREATE INDEX idx_users_deleted_at ON users(deleted_at)`, - `CREATE UNIQUE INDEX idx_api_keys_prefix ON api_keys(prefix)`, `CREATE INDEX idx_policies_deleted_at ON policies(deleted_at)`, `CREATE UNIQUE INDEX idx_provider_identifier ON users(provider_identifier) WHERE provider_identifier IS NOT NULL`, `CREATE UNIQUE INDEX idx_name_provider_identifier ON users(name, provider_identifier)`, `CREATE UNIQUE INDEX idx_name_no_provider_identifier ON users(name) WHERE provider_identifier IS NULL`, - `CREATE UNIQUE INDEX idx_pre_auth_keys_prefix ON pre_auth_keys(prefix) WHERE prefix IS NOT NULL AND prefix != ''`, - `CREATE UNIQUE INDEX idx_oauth_clients_client_id ON oauth_clients(client_id)`, - `CREATE UNIQUE INDEX idx_oauth_access_tokens_prefix ON oauth_access_tokens(prefix)`, `CREATE UNIQUE INDEX idx_credentials_identifier ON credentials(kind, identifier)`, } diff --git a/hscontrol/db/db_test.go b/hscontrol/db/db_test.go index 88d282c46..23f99b18d 100644 --- a/hscontrol/db/db_test.go +++ b/hscontrol/db/db_test.go @@ -41,10 +41,11 @@ func TestSQLiteMigrationAndDataValidation(t *testing.T) { require.NoError(t, err) assert.Len(t, users, 1, "should preserve all 1 user from original schema") - // Verify api_keys data preservation + // Verify api_keys data preservation (migrated into credentials). var apiKeyCount int - err = hsdb.DB.Raw("SELECT COUNT(*) FROM api_keys").Scan(&apiKeyCount).Error + err = hsdb.DB.Raw("SELECT COUNT(*) FROM credentials WHERE kind = ?", types.CredentialAPIKey). + Scan(&apiKeyCount).Error require.NoError(t, err) assert.Equal(t, 2, apiKeyCount, "should preserve all 2 api_keys from original schema") diff --git a/hscontrol/db/migrate_credentials.go b/hscontrol/db/migrate_credentials.go new file mode 100644 index 000000000..f82568068 --- /dev/null +++ b/hscontrol/db/migrate_credentials.go @@ -0,0 +1,138 @@ +package db + +import ( + "fmt" + + "github.com/juanfont/headscale/hscontrol/types" + "gorm.io/gorm" +) + +// migrateToCredentials backfills the unified credentials table from the four +// per-kind tables and drops them. Pre-auth keys are migrated first preserving +// their ids so nodes.auth_key_id stays valid; the nodes FK is then retargeted to +// credentials(id). Runs with foreign keys enabled on SQLite (per runMigrations), +// so the steps are ordered to never leave a dangling reference. +func migrateToCredentials(tx *gorm.DB) error { + // Clear node references to legacy plaintext pre-auth keys (empty prefix); + // these are not migrated, so a node pointing at one would dangle. + err := tx.Exec(`UPDATE nodes SET auth_key_id = NULL +WHERE auth_key_id IN (SELECT id FROM pre_auth_keys WHERE prefix IS NULL OR prefix = '')`).Error + if err != nil { + return fmt.Errorf("clearing plaintext auth_key references: %w", err) + } + + // Pre-auth keys first, preserving ids so nodes.auth_key_id stays valid. + err = tx.Exec(`INSERT INTO credentials + (id, kind, identifier, hash, user_id, description, reusable, ephemeral, used, tags, expiration, revoked, created_at) +SELECT id, ?, prefix, hash, user_id, description, reusable, ephemeral, used, tags, expiration, revoked, created_at +FROM pre_auth_keys WHERE prefix IS NOT NULL AND prefix != ''`, types.CredentialPreAuthKey).Error + if err != nil { + return fmt.Errorf("backfilling pre-auth keys: %w", err) + } + + // Postgres does not advance the id sequence on explicit-id inserts; nudge it + // past the pre-auth ids before the auto-id inserts below. SQLite's + // AUTOINCREMENT already tracks max(id). + if tx.Name() != "sqlite" { + err = tx.Exec(`SELECT setval(pg_get_serial_sequence('credentials','id'), +GREATEST((SELECT COALESCE(MAX(id), 1) FROM credentials), 1))`).Error + if err != nil { + return fmt.Errorf("resetting credentials id sequence: %w", err) + } + } + + err = tx.Exec(`INSERT INTO credentials (kind, identifier, hash, user_id, last_seen, expiration, created_at) +SELECT ?, prefix, hash, user_id, last_seen, expiration, created_at FROM api_keys`, types.CredentialAPIKey).Error + if err != nil { + return fmt.Errorf("backfilling api keys: %w", err) + } + + err = tx.Exec(`INSERT INTO credentials (kind, identifier, hash, scopes, tags, description, user_id, revoked, created_at) +SELECT ?, client_id, secret_hash, scopes, tags, description, user_id, revoked, created_at FROM oauth_clients`, types.CredentialOAuthClient).Error + if err != nil { + return fmt.Errorf("backfilling oauth clients: %w", err) + } + + err = tx.Exec(`INSERT INTO credentials (kind, identifier, hash, client_id, scopes, tags, expiration, created_at) +SELECT ?, prefix, hash, client_id, scopes, tags, expiration, created_at FROM oauth_access_tokens`, types.CredentialOAuthToken).Error + if err != nil { + return fmt.Errorf("backfilling oauth access tokens: %w", err) + } + + if err := retargetNodesAuthKeyFK(tx); err != nil { //nolint:noinlineerr + return err + } + + for _, table := range []string{"pre_auth_keys", "api_keys", "oauth_clients", "oauth_access_tokens"} { + if err := tx.Migrator().DropTable(table); err != nil { //nolint:noinlineerr + return fmt.Errorf("dropping %s: %w", table, err) + } + } + + return nil +} + +// retargetNodesAuthKeyFK repoints the nodes.auth_key_id foreign key from +// pre_auth_keys(id) to credentials(id). Postgres alters the constraint in place; +// SQLite, which cannot alter a foreign key, rebuilds the table. The rebuild runs +// with foreign keys enabled: no table references nodes, and every retained +// auth_key_id now points at a credentials row, so no FK toggling is required. +func retargetNodesAuthKeyFK(tx *gorm.DB) error { + if tx.Name() != "sqlite" { + err := tx.Exec(`ALTER TABLE nodes DROP CONSTRAINT IF EXISTS fk_nodes_auth_key`).Error + if err != nil { + return fmt.Errorf("dropping nodes auth_key constraint: %w", err) + } + + err = tx.Exec(`ALTER TABLE nodes ADD CONSTRAINT fk_nodes_auth_key +FOREIGN KEY (auth_key_id) REFERENCES credentials(id)`).Error + if err != nil { + return fmt.Errorf("adding nodes auth_key constraint: %w", err) + } + + return nil + } + + stmts := []string{ + `CREATE TABLE nodes_new( + id integer PRIMARY KEY AUTOINCREMENT, + machine_key text, + node_key text, + disco_key text, + endpoints text, + host_info text, + ipv4 text, + ipv6 text, + hostname text, + given_name varchar(63), + user_id integer, + register_method text, + tags text, + auth_key_id integer, + last_seen datetime, + expiry datetime, + approved_routes text, + + created_at datetime, + updated_at datetime, + deleted_at datetime, + + CONSTRAINT fk_nodes_user FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_nodes_auth_key FOREIGN KEY(auth_key_id) REFERENCES credentials(id) +)`, + `INSERT INTO nodes_new + (id, machine_key, node_key, disco_key, endpoints, host_info, ipv4, ipv6, hostname, given_name, user_id, register_method, tags, auth_key_id, last_seen, expiry, approved_routes, created_at, updated_at, deleted_at) +SELECT id, machine_key, node_key, disco_key, endpoints, host_info, ipv4, ipv6, hostname, given_name, user_id, register_method, tags, auth_key_id, last_seen, expiry, approved_routes, created_at, updated_at, deleted_at +FROM nodes`, + `DROP TABLE nodes`, + `ALTER TABLE nodes_new RENAME TO nodes`, + } + + for _, stmt := range stmts { + if err := tx.Exec(stmt).Error; err != nil { //nolint:noinlineerr + return fmt.Errorf("rebuilding nodes table: %w", err) + } + } + + return nil +} diff --git a/hscontrol/db/preauth_keys.go b/hscontrol/db/preauth_keys.go index 5bb802cc5..b8d806d54 100644 --- a/hscontrol/db/preauth_keys.go +++ b/hscontrol/db/preauth_keys.go @@ -101,36 +101,38 @@ func CreatePreAuthKey( now := time.Now().UTC() - keyStr, prefix, hash, err := generateSecret(authKeyPrefix) + keyStr, identifier, hash, err := generateSecret(authKeyPrefix) if err != nil { return nil, err } - key := types.PreAuthKey{ + // Set only UserID for the write so GORM does not upsert the User row; the + // User is attached afterwards for the returned projection. + cred := types.Credential{ + Kind: types.CredentialPreAuthKey, + Identifier: identifier, + Hash: hash, UserID: userID, // nil for system-created keys, or "created by" for tagged keys - User: user, // nil for system-created keys Reusable: reusable, Ephemeral: ephemeral, CreatedAt: &now, Expiration: expiration, Tags: aclTags, // empty for user-owned keys - Prefix: prefix, // Store prefix - Hash: hash, // Store hash } - if err := tx.Save(&key).Error; err != nil { //nolint:noinlineerr + if err := tx.Save(&cred).Error; err != nil { //nolint:noinlineerr return nil, fmt.Errorf("creating key in database: %w", err) } return &types.PreAuthKeyNew{ - ID: key.ID, + ID: cred.ID, Key: keyStr, - Reusable: key.Reusable, - Ephemeral: key.Ephemeral, - Tags: key.Tags, - Expiration: key.Expiration, - CreatedAt: key.CreatedAt, - User: key.User, + Reusable: cred.Reusable, + Ephemeral: cred.Ephemeral, + Tags: cred.Tags, + Expiration: cred.Expiration, + CreatedAt: cred.CreatedAt, + User: user, // nil for system-created keys }, nil } @@ -138,8 +140,8 @@ func CreatePreAuthKey( // The v2 keys API sets it after creation rather than threading it through the // many-armed CreatePreAuthKey signature shared by every other caller. func (hsdb *HSDatabase) SetPreAuthKeyDescription(id uint64, description string) error { - return hsdb.DB.Model(&types.PreAuthKey{}). - Where("id = ?", id). + return hsdb.DB.Model(&types.Credential{}). + Where("kind = ? AND id = ?", types.CredentialPreAuthKey, id). Update("description", description).Error } @@ -149,25 +151,39 @@ func (hsdb *HSDatabase) ListPreAuthKeys() ([]types.PreAuthKey, error) { // ListPreAuthKeys returns all [types.PreAuthKey] values in the database. func ListPreAuthKeys(tx *gorm.DB) ([]types.PreAuthKey, error) { - var keys []types.PreAuthKey + var creds []types.Credential - err := tx.Preload("User").Find(&keys).Error + err := tx.Preload("User"). + Where("kind = ?", types.CredentialPreAuthKey). + Find(&creds).Error if err != nil { return nil, err } + keys := make([]types.PreAuthKey, 0, len(creds)) + for i := range creds { + keys = append(keys, *credentialToPreAuthKey(&creds[i])) + } + return keys, nil } // ListPreAuthKeysByUser returns all [types.PreAuthKey] values belonging to a specific user. func ListPreAuthKeysByUser(tx *gorm.DB, uid types.UserID) ([]types.PreAuthKey, error) { - var keys []types.PreAuthKey + var creds []types.Credential - err := tx.Preload("User").Where("user_id = ?", uint(uid)).Find(&keys).Error + err := tx.Preload("User"). + Where("kind = ? AND user_id = ?", types.CredentialPreAuthKey, uint(uid)). + Find(&creds).Error if err != nil { return nil, err } + keys := make([]types.PreAuthKey, 0, len(creds)) + for i := range creds { + keys = append(keys, *credentialToPreAuthKey(&creds[i])) + } + return keys, nil } @@ -177,8 +193,6 @@ var ( ) func findAuthKey(tx *gorm.DB, keyStr string) (*types.PreAuthKey, error) { - var pak types.PreAuthKey - // Validate input is not empty if keyStr == "" { return nil, ErrPreAuthKeyFailedToParse @@ -203,22 +217,24 @@ func findAuthKey(tx *gorm.DB, keyStr string) (*types.PreAuthKey, error) { return nil, err } - // Look up key by prefix - err = tx.Preload("User").First(&pak, "prefix = ?", prefix).Error + // Look up key by identifier + var cred types.Credential + + err = tx.Preload("User").First(&cred, "kind = ? AND identifier = ?", types.CredentialPreAuthKey, prefix).Error if err != nil { return nil, ErrPreAuthKeyNotFound } - needsRehash, err := verifySecret(pak.Hash, secret) + needsRehash, err := verifySecret(cred.Hash, secret) if err != nil { return nil, fmt.Errorf("invalid auth key: %w", err) } if needsRehash { - rehashToArgon2id(tx, &pak, secret) + rehashToArgon2id(tx, &cred, secret) } - return &pak, nil + return credentialToPreAuthKey(&cred), nil } // parsePrefixedKey splits the prefix-and-secret portion of a new-format key @@ -308,15 +324,16 @@ func GetPreAuthKey(tx *gorm.DB, key string) (*types.PreAuthKey, error) { // GetPreAuthKeyByID returns a [types.PreAuthKey] by its primary key, with the // owning user preloaded. func (hsdb *HSDatabase) GetPreAuthKeyByID(id uint64) (*types.PreAuthKey, error) { - pak := types.PreAuthKey{} + var cred types.Credential // Explicit primary-key clause: a struct condition would drop a zero-valued // ID, making the lookup unconditional and returning the first row instead // of not-found. - if result := hsdb.DB.Preload("User").First(&pak, "id = ?", id); result.Error != nil { + if result := hsdb.DB.Preload("User"). + First(&cred, "kind = ? AND id = ?", types.CredentialPreAuthKey, id); result.Error != nil { return nil, result.Error } - return &pak, nil + return credentialToPreAuthKey(&cred), nil } // DestroyPreAuthKey destroys a preauthkey. Returns error if the [types.PreAuthKey] @@ -333,7 +350,8 @@ func DestroyPreAuthKey(tx *gorm.DB, id uint64) error { } // Then delete the pre-auth key - res := tx.Unscoped().Delete(&types.PreAuthKey{}, id) + res := tx.Unscoped(). + Delete(&types.Credential{}, "kind = ? AND id = ?", types.CredentialPreAuthKey, id) if res.Error != nil { return res.Error } @@ -370,8 +388,8 @@ func (hsdb *HSDatabase) RevokePreAuthKey(id uint64) error { // window. An already-revoked or unknown id returns [ErrPreAuthKeyNotFound], so a // repeated DELETE is a clean 404. func RevokePreAuthKey(tx *gorm.DB, id uint64) error { - res := tx.Model(&types.PreAuthKey{}). - Where("id = ? AND revoked IS NULL", id). + res := tx.Model(&types.Credential{}). + Where("kind = ? AND id = ? AND revoked IS NULL", types.CredentialPreAuthKey, id). Update("revoked", time.Now()) if res.Error != nil { return res.Error @@ -393,8 +411,8 @@ func (hsdb *HSDatabase) DestroyRevokedPreAuthKeysBefore(cutoff time.Time) (int, err := hsdb.Write(func(tx *gorm.DB) error { var ids []uint64 - err := tx.Model(&types.PreAuthKey{}). - Where("revoked IS NOT NULL AND revoked < ?", cutoff). + err := tx.Model(&types.Credential{}). + Where("kind = ? AND revoked IS NOT NULL AND revoked < ?", types.CredentialPreAuthKey, cutoff). Pluck("id", &ids).Error if err != nil { return err @@ -422,8 +440,8 @@ func (hsdb *HSDatabase) DestroyRevokedPreAuthKeysBefore(cutoff time.Time) (int, // guard the previous code (Update("used", true) with no WHERE) would // silently let both transactions claim the key. func UsePreAuthKey(tx *gorm.DB, k *types.PreAuthKey) error { - res := tx.Model(&types.PreAuthKey{}). - Where("id = ? AND used = ?", k.ID, false). + res := tx.Model(&types.Credential{}). + Where("kind = ? AND id = ? AND used = ?", types.CredentialPreAuthKey, k.ID, false). Update("used", true) if res.Error != nil { return fmt.Errorf("updating key used status in database: %w", res.Error) @@ -443,7 +461,9 @@ func UsePreAuthKey(tx *gorm.DB, k *types.PreAuthKey) error { func ExpirePreAuthKey(tx *gorm.DB, id uint64) error { now := time.Now() - res := tx.Model(&types.PreAuthKey{}).Where("id = ?", id).Update("expiration", now) + res := tx.Model(&types.Credential{}). + Where("kind = ? AND id = ?", types.CredentialPreAuthKey, id). + Update("expiration", now) if res.Error != nil { return res.Error } diff --git a/hscontrol/db/preauth_keys_test.go b/hscontrol/db/preauth_keys_test.go index bddfde607..256c1879a 100644 --- a/hscontrol/db/preauth_keys_test.go +++ b/hscontrol/db/preauth_keys_test.go @@ -1,7 +1,6 @@ package db import ( - "fmt" "slices" "strings" "testing" @@ -132,7 +131,8 @@ func TestCannotDeleteAssignedPreAuthKey(t *testing.T) { } db.DB.Save(&node) - err = db.DB.Delete(&types.PreAuthKey{ID: key.ID}).Error + err = db.DB.Where("kind = ? AND id = ?", types.CredentialPreAuthKey, key.ID). + Delete(&types.Credential{}).Error require.ErrorContains(t, err, "constraint failed: FOREIGN KEY constraint failed") } @@ -152,19 +152,9 @@ func TestPreAuthKeyAuthentication(t *testing.T) { { name: "legacy_plaintext_rejected", setupKey: func() string { - // Plaintext pre-auth keys (pre-0.30) are no longer supported. Seed - // one directly, the way it would exist in an upgraded production - // database, and confirm it can no longer be found. - legacyKey := "abc123def456ghi789jkl012mno345pqr678stu901vwx234yz" - now := time.Now() - - err := db.DB.Exec(` - INSERT INTO pre_auth_keys (key, user_id, reusable, ephemeral, used, created_at) - VALUES (?, ?, ?, ?, ?, ?) - `, legacyKey, user.ID, true, false, false, now).Error - require.NoError(t, err) - - return legacyKey + // Plaintext pre-auth keys (pre-0.30) are no longer supported: a + // key string without the hskey-auth- prefix is treated as unknown. + return "abc123def456ghi789jkl012mno345pqr678stu901vwx234yz" }, wantFindErr: true, wantValidateErr: false, @@ -331,74 +321,30 @@ func TestPreAuthKeyAuthentication(t *testing.T) { } } -func TestMultipleLegacyKeysAllowed(t *testing.T) { +// TestPreAuthKeysHaveUniqueIdentifiers verifies that freshly created pre-auth +// keys get distinct identifiers in the unified credentials table. +func TestPreAuthKeysHaveUniqueIdentifiers(t *testing.T) { db, err := newSQLiteTestDB() require.NoError(t, err) - user, err := db.CreateUser(types.User{Name: "test-legacy"}) + user, err := db.CreateUser(types.User{Name: "test-unique"}) require.NoError(t, err) - // Create multiple legacy keys by directly inserting with empty prefix - // This simulates the migration scenario where existing databases have multiple - // plaintext keys without prefix/hash fields - now := time.Now() - - for i := range 5 { - legacyKey := fmt.Sprintf("legacy_key_%d_%s", i, strings.Repeat("x", 40)) - - err := db.DB.Exec(` - INSERT INTO pre_auth_keys (key, prefix, hash, user_id, reusable, ephemeral, used, created_at) - VALUES (?, '', NULL, ?, ?, ?, ?, ?) - `, legacyKey, user.ID, true, false, false, now).Error - require.NoError(t, err, "should allow multiple legacy keys with empty prefix") - } - - // Verify all legacy keys can be retrieved - var legacyKeys []types.PreAuthKey - - err = db.DB.Where("prefix = '' OR prefix IS NULL").Find(&legacyKeys).Error - require.NoError(t, err) - assert.Len(t, legacyKeys, 5, "should have created 5 legacy keys") - - // Now create new bcrypt-based keys - these should have unique prefixes key1, err := db.CreatePreAuthKey(user.TypedID(), true, false, nil, nil) require.NoError(t, err) - assert.NotEmpty(t, key1.Key) key2, err := db.CreatePreAuthKey(user.TypedID(), true, false, nil, nil) require.NoError(t, err) - assert.NotEmpty(t, key2.Key) - // Verify the new keys have different prefixes pak1, err := db.GetPreAuthKey(key1.Key) require.NoError(t, err) - assert.NotEmpty(t, pak1.Prefix) pak2, err := db.GetPreAuthKey(key2.Key) require.NoError(t, err) + + assert.NotEmpty(t, pak1.Prefix) assert.NotEmpty(t, pak2.Prefix) - - assert.NotEqual(t, pak1.Prefix, pak2.Prefix, "new keys should have unique prefixes") - - // Verify we cannot manually insert duplicate non-empty prefixes - duplicatePrefix := "test_prefix1" - hash1 := []byte("hash1") - hash2 := []byte("hash2") - - // First insert should succeed - err = db.DB.Exec(` - INSERT INTO pre_auth_keys (key, prefix, hash, user_id, reusable, ephemeral, used, created_at) - VALUES ('', ?, ?, ?, ?, ?, ?, ?) - `, duplicatePrefix, hash1, user.ID, true, false, false, now).Error - require.NoError(t, err, "first key with prefix should succeed") - - // Second insert with same prefix should fail - err = db.DB.Exec(` - INSERT INTO pre_auth_keys (key, prefix, hash, user_id, reusable, ephemeral, used, created_at) - VALUES ('', ?, ?, ?, ?, ?, ?, ?) - `, duplicatePrefix, hash2, user.ID, true, false, false, now).Error - require.Error(t, err, "duplicate non-empty prefix should be rejected") - assert.Contains(t, err.Error(), "UNIQUE constraint failed", "should fail with UNIQUE constraint error") + assert.NotEqual(t, pak1.Prefix, pak2.Prefix, "new keys should have unique identifiers") } // TestUsePreAuthKeyAtomicCAS verifies that UsePreAuthKey is an atomic @@ -473,9 +419,9 @@ func TestPreAuthKeyLazyRehashesBcrypt(t *testing.T) { require.NoError(t, err) err = db.DB.Exec( - `INSERT INTO pre_auth_keys (prefix, hash, user_id, reusable, ephemeral, used, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - prefix, hash, user.ID, true, false, false, time.Now(), + `INSERT INTO credentials (kind, identifier, hash, user_id, reusable, ephemeral, used, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + types.CredentialPreAuthKey, prefix, hash, user.ID, true, false, false, time.Now(), ).Error require.NoError(t, err) diff --git a/hscontrol/db/schema.sql b/hscontrol/db/schema.sql index 852103d9d..f4716b371 100644 --- a/hscontrol/db/schema.sql +++ b/hscontrol/db/schema.sql @@ -38,68 +38,6 @@ CREATE UNIQUE INDEX idx_provider_identifier ON users(provider_identifier) WHERE CREATE UNIQUE INDEX idx_name_provider_identifier ON users(name, provider_identifier); CREATE UNIQUE INDEX idx_name_no_provider_identifier ON users(name) WHERE provider_identifier IS NULL; -CREATE TABLE pre_auth_keys( - id integer PRIMARY KEY AUTOINCREMENT, - key text, - prefix text, - hash blob, - user_id integer, - description text, - reusable numeric, - ephemeral numeric DEFAULT false, - used numeric DEFAULT false, - tags text, - expiration datetime, - revoked datetime, - - created_at datetime, - - CONSTRAINT fk_pre_auth_keys_user FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL -); -CREATE UNIQUE INDEX idx_pre_auth_keys_prefix ON pre_auth_keys(prefix) WHERE prefix IS NOT NULL AND prefix != ''; - -CREATE TABLE api_keys( - id integer PRIMARY KEY AUTOINCREMENT, - prefix text, - hash blob, - user_id integer, - expiration datetime, - last_seen datetime, - - created_at datetime -); -CREATE UNIQUE INDEX idx_api_keys_prefix ON api_keys(prefix); - --- OAuth 2.0 client-credentials clients for the v2 API. client_id is public and --- embedded in the secret (hskey-client--); only the bcrypt --- hash of the secret is stored. Mirrors the api_keys security model. -CREATE TABLE oauth_clients( - id integer PRIMARY KEY AUTOINCREMENT, - client_id text, - secret_hash blob, - scopes text, - tags text, - description text, - user_id integer, - created_at datetime, - revoked datetime -); -CREATE UNIQUE INDEX idx_oauth_clients_client_id ON oauth_clients(client_id); - --- Short-lived bearer access tokens minted by an oauth_client. Stored as a bcrypt --- hash of the secret, looked up by prefix. -CREATE TABLE oauth_access_tokens( - id integer PRIMARY KEY AUTOINCREMENT, - prefix text, - hash blob, - client_id text, - scopes text, - tags text, - expiration datetime, - created_at datetime -); -CREATE UNIQUE INDEX idx_oauth_access_tokens_prefix ON oauth_access_tokens(prefix); - -- Unified store for every authenticatable secret (API keys, pre-auth keys, -- OAuth clients and access tokens), discriminated by kind. The secret is stored -- only as an Argon2id hash; identifier is the public lookup value, unique within @@ -153,7 +91,7 @@ CREATE TABLE nodes( deleted_at datetime, CONSTRAINT fk_nodes_user FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, - CONSTRAINT fk_nodes_auth_key FOREIGN KEY(auth_key_id) REFERENCES pre_auth_keys(id) + CONSTRAINT fk_nodes_auth_key FOREIGN KEY(auth_key_id) REFERENCES credentials(id) ); CREATE TABLE policies( diff --git a/hscontrol/db/users_test.go b/hscontrol/db/users_test.go index 2a755ff3c..3f58bcd4f 100644 --- a/hscontrol/db/users_test.go +++ b/hscontrol/db/users_test.go @@ -55,10 +55,10 @@ func TestDestroyUserErrors(t *testing.T) { err = db.DestroyUser(types.UserID(user.ID)) require.NoError(t, err) - // Verify preauth key was deleted (need to search by prefix for new keys) - var foundPak types.PreAuthKey + // Verify preauth key credential was deleted. + var foundPak types.Credential - result := db.DB.First(&foundPak, "id = ?", pak.ID) + result := db.DB.First(&foundPak, "kind = ? AND id = ?", types.CredentialPreAuthKey, pak.ID) assert.ErrorIs(t, result.Error, gorm.ErrRecordNotFound) }, }, diff --git a/hscontrol/mapper/tail_test.go b/hscontrol/mapper/tail_test.go index ee5da85c9..cb85aea41 100644 --- a/hscontrol/mapper/tail_test.go +++ b/hscontrol/mapper/tail_test.go @@ -103,7 +103,7 @@ func TestTailNode(t *testing.T) { Name: "mini", }, Tags: []string{}, - AuthKey: &types.PreAuthKey{}, + AuthKey: &types.Credential{}, LastSeen: &lastSeen, Expiry: &expire, Hostinfo: &tailcfg.Hostinfo{ diff --git a/hscontrol/policy/v2/types_test.go b/hscontrol/policy/v2/types_test.go index 19134e5b3..530d15bd8 100644 --- a/hscontrol/policy/v2/types_test.go +++ b/hscontrol/policy/v2/types_test.go @@ -2476,7 +2476,7 @@ func TestResolvePolicy(t *testing.T) { }, // not matching pak tag { - AuthKey: &types.PreAuthKey{ + AuthKey: &types.Credential{ Tags: []string{"tag:alsotagged"}, }, IPv4: ap("100.100.101.11"), diff --git a/hscontrol/state/ephemeral_test.go b/hscontrol/state/ephemeral_test.go index 5c7556878..96745c87f 100644 --- a/hscontrol/state/ephemeral_test.go +++ b/hscontrol/state/ephemeral_test.go @@ -204,9 +204,8 @@ func TestPersistNodeToDBPreventsRaceCondition(t *testing.T) { // the node could be re-inserted into the database even though it was deleted func TestEphemeralNodeLogoutRaceCondition(t *testing.T) { ephemeralNode := createTestNode(4, 1, "test-user", "ephemeral-node") - ephemeralNode.AuthKey = &types.PreAuthKey{ + ephemeralNode.AuthKey = &types.Credential{ ID: 1, - Key: "test-key", Ephemeral: true, } @@ -284,9 +283,8 @@ func TestEphemeralNodeLogoutRaceCondition(t *testing.T) { // 8. Node gets re-inserted into database instead of staying deleted. func TestUpdateNodeFromMapRequestEphemeralLogoutSequence(t *testing.T) { ephemeralNode := createTestNode(5, 1, "test-user", "ephemeral-node-5") - ephemeralNode.AuthKey = &types.PreAuthKey{ + ephemeralNode.AuthKey = &types.Credential{ ID: 2, - Key: "test-key-2", Ephemeral: true, } @@ -418,9 +416,8 @@ func TestUpdateNodeDeletedInSameBatchReturnsInvalid(t *testing.T) { // 6. persistNodeToDB must detect the node is deleted and refuse to persist. func TestPersistNodeToDBChecksNodeStoreBeforePersist(t *testing.T) { ephemeralNode := createTestNode(7, 1, "test-user", "ephemeral-node-7") - ephemeralNode.AuthKey = &types.PreAuthKey{ + ephemeralNode.AuthKey = &types.Credential{ ID: 3, - Key: "test-key-3", Ephemeral: true, } diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go index 7432e2bbb..622bb5a24 100644 --- a/hscontrol/state/state.go +++ b/hscontrol/state/state.go @@ -1895,7 +1895,7 @@ func (s *State) createAndSaveNewNode(params newNodeParams) (types.NodeView, erro nodeToRegister.Tags = nil } - nodeToRegister.AuthKey = params.PreAuthKey + nodeToRegister.AuthKey = params.PreAuthKey.AsCredential() nodeToRegister.AuthKeyID = ¶ms.PreAuthKey.ID } else { // Non-PreAuthKey registration (OIDC, CLI) - always user-owned @@ -1976,7 +1976,10 @@ func (s *State) createAndSaveNewNode(params newNodeParams) (types.NodeView, erro // New node - database first to get ID, then [NodeStore] savedNode, err := hsdb.Write(s.db.DB, func(tx *gorm.DB) (*types.Node, error) { - err := tx.Save(&nodeToRegister).Error + // Omit the AuthKey association: only auth_key_id is persisted here, the + // credential row is owned by the credential CRUD and must not be + // upserted from this node's (possibly stale) in-memory copy (#2862). + err := tx.Omit("AuthKey").Save(&nodeToRegister).Error if err != nil { return nil, fmt.Errorf("saving node: %w", err) } @@ -1986,6 +1989,10 @@ func (s *State) createAndSaveNewNode(params newNodeParams) (types.NodeView, erro if err != nil { return nil, fmt.Errorf("using pre auth key: %w", err) } + + // UsePreAuthKey marked the key used; refresh the node's in-memory + // AuthKey so the NodeStore copy matches the database. + nodeToRegister.AuthKey = params.PreAuthKey.AsCredential() } return &nodeToRegister, nil @@ -2524,8 +2531,14 @@ func (s *State) HandleNodeFromPreAuthKey( node.Expiry = nil } - node.AuthKey = pak + node.AuthKey = pak.AsCredential() node.AuthKeyID = &pak.ID + // If this registration will consume a single-use key (the tx below + // calls UsePreAuthKey under the same condition), reflect that in the + // cached AuthKey so the NodeStore copy matches the database. + if !pak.Reusable && !pak.Used { + node.AuthKey.Used = true + } // Do NOT reset IsOnline here. Online status is managed exclusively by // [State.Connect]/[State.Disconnect] in the poll session lifecycle. // Resetting it during re-registration causes a false offline blip diff --git a/hscontrol/types/common.go b/hscontrol/types/common.go index e96aef9be..69cd1279e 100644 --- a/hscontrol/types/common.go +++ b/hscontrol/types/common.go @@ -1,7 +1,7 @@ -//go:generate go tool viewer --type=User,Node,PreAuthKey +//go:generate go tool viewer --type=User,Node,PreAuthKey,Credential package types -//go:generate go run tailscale.com/cmd/viewer --type=User,Node,PreAuthKey +//go:generate go run tailscale.com/cmd/viewer --type=User,Node,PreAuthKey,Credential import ( "errors" diff --git a/hscontrol/types/credential.go b/hscontrol/types/credential.go index a9dfc626f..a337a8d4d 100644 --- a/hscontrol/types/credential.go +++ b/hscontrol/types/credential.go @@ -59,3 +59,35 @@ type Credential struct { // TableName pins the table name so GORM's naming strategy does not pluralise it // unexpectedly and so it matches the hand-written migration DDL and schema.sql. func (*Credential) TableName() string { return "credentials" } + +// IsTagged reports whether this credential carries tags. For a pre-auth key +// credential, a node registered with it becomes a tagged node. +func (c *Credential) IsTagged() bool { + return len(c.Tags) > 0 +} + +// AsCredential projects a pre-auth key back onto a [Credential] (kind +// authkey), used to set a node's AuthKey association during registration from a +// pre-auth key projection. +func (pak *PreAuthKey) AsCredential() *Credential { + if pak == nil { + return nil + } + + return &Credential{ + ID: pak.ID, + Kind: CredentialPreAuthKey, + Identifier: pak.Prefix, + Hash: pak.Hash, + UserID: pak.UserID, + User: pak.User, + Description: pak.Description, + Reusable: pak.Reusable, + Ephemeral: pak.Ephemeral, + Used: pak.Used, + Tags: pak.Tags, + CreatedAt: pak.CreatedAt, + Expiration: pak.Expiration, + Revoked: pak.Revoked, + } +} diff --git a/hscontrol/types/node.go b/hscontrol/types/node.go index dd3dc92d1..3d2dc997a 100644 --- a/hscontrol/types/node.go +++ b/hscontrol/types/node.go @@ -156,12 +156,12 @@ type Node struct { // Tags cannot be removed once set (one-way transition). Tags Strings `gorm:"column:tags;serializer:json"` - // When a node has been created with a [PreAuthKey], we need to - // prevent the preauthkey from being deleted before the node. - // The preauthkey can define "tags" of the node so we need it - // around. + // When a node has been created with a pre-auth key, we keep the key + // credential around: it can define the node's tags and must not be deleted + // before the node. The association is to the unified [Credential] (kind + // authkey), which is what auth_key_id references. AuthKeyID *uint64 `sql:"DEFAULT:NULL"` - AuthKey *PreAuthKey + AuthKey *Credential Expiry *time.Time diff --git a/hscontrol/types/node_tags_test.go b/hscontrol/types/node_tags_test.go index a401d71d8..a9e84d31e 100644 --- a/hscontrol/types/node_tags_test.go +++ b/hscontrol/types/node_tags_test.go @@ -49,7 +49,7 @@ func TestNodeIsTagged(t *testing.T) { // [Node.IsTagged] only checks [Node.Tags], not [PreAuthKey.Tags]. name: "node registered with tagged authkey only - not tagged (tags should be copied)", node: Node{ - AuthKey: &PreAuthKey{ + AuthKey: &Credential{ Tags: []string{"tag:database"}, }, }, @@ -59,7 +59,7 @@ func TestNodeIsTagged(t *testing.T) { name: "node with both tags and authkey tags - is tagged", node: Node{ Tags: []string{"tag:server"}, - AuthKey: &PreAuthKey{ + AuthKey: &Credential{ Tags: []string{"tag:database"}, }, }, @@ -102,7 +102,7 @@ func TestNodeViewIsTagged(t *testing.T) { // with only [PreAuthKey.Tags] and no [Node.Tags] would be invalid in practice. name: "node with only AuthKey tags - not tagged (tags should be copied)", node: Node{ - AuthKey: &PreAuthKey{ + AuthKey: &Credential{ Tags: []string{"tag:web"}, }, }, @@ -155,7 +155,7 @@ func TestNodeHasTag(t *testing.T) { // [Node.HasTag] only checks [Node.Tags], not [PreAuthKey.Tags] name: "node has tag only in authkey - returns false", node: Node{ - AuthKey: &PreAuthKey{ + AuthKey: &Credential{ Tags: []string{"tag:database"}, }, }, @@ -167,7 +167,7 @@ func TestNodeHasTag(t *testing.T) { name: "node has tag in Tags but not in AuthKey", node: Node{ Tags: []string{"tag:server"}, - AuthKey: &PreAuthKey{ + AuthKey: &Credential{ Tags: []string{"tag:database"}, }, }, @@ -206,7 +206,7 @@ func TestNodeTagsImmutableAfterRegistration(t *testing.T) { taggedNode := Node{ ID: 1, Tags: []string{"tag:server"}, - AuthKey: &PreAuthKey{ + AuthKey: &Credential{ Tags: []string{"tag:server"}, }, RegisterMethod: util.RegisterMethodAuthKey, @@ -265,7 +265,7 @@ func TestNodeOwnershipModel(t *testing.T) { node: Node{ ID: 3, UserID: new(uint(5)), // "created by" user 5 - AuthKey: &PreAuthKey{ + AuthKey: &Credential{ Tags: []string{"tag:database"}, }, }, diff --git a/hscontrol/types/types_clone.go b/hscontrol/types/types_clone.go index 1040e2142..577dd740a 100644 --- a/hscontrol/types/types_clone.go +++ b/hscontrol/types/types_clone.go @@ -97,7 +97,7 @@ var _NodeCloneNeedsRegeneration = Node(struct { RegisterMethod string Tags Strings AuthKeyID *uint64 - AuthKey *PreAuthKey + AuthKey *Credential Expiry *time.Time LastSeen *time.Time ApprovedRoutes Prefixes @@ -155,3 +155,56 @@ var _PreAuthKeyCloneNeedsRegeneration = PreAuthKey(struct { Expiration *time.Time Revoked *time.Time }{}) + +// Clone makes a deep copy of Credential. +// The result aliases no memory with the original. +func (src *Credential) Clone() *Credential { + if src == nil { + return nil + } + dst := new(Credential) + *dst = *src + dst.Hash = append(src.Hash[:0:0], src.Hash...) + if dst.UserID != nil { + dst.UserID = new(*src.UserID) + } + if dst.User != nil { + dst.User = new(*src.User) + } + dst.Scopes = append(src.Scopes[:0:0], src.Scopes...) + dst.Tags = append(src.Tags[:0:0], src.Tags...) + if dst.LastSeen != nil { + dst.LastSeen = new(*src.LastSeen) + } + if dst.CreatedAt != nil { + dst.CreatedAt = new(*src.CreatedAt) + } + if dst.Expiration != nil { + dst.Expiration = new(*src.Expiration) + } + if dst.Revoked != nil { + dst.Revoked = new(*src.Revoked) + } + return dst +} + +// A compilation failure here means this code must be regenerated, with the command at the top of this file. +var _CredentialCloneNeedsRegeneration = Credential(struct { + ID uint64 + Kind string + Identifier string + Hash []byte + UserID *uint + User *User + Description string + Scopes []string + Tags []string + Reusable bool + Ephemeral bool + Used bool + LastSeen *time.Time + ClientID string + CreatedAt *time.Time + Expiration *time.Time + Revoked *time.Time +}{}) diff --git a/hscontrol/types/types_view.go b/hscontrol/types/types_view.go index 6adcdda12..6b44b6c70 100644 --- a/hscontrol/types/types_view.go +++ b/hscontrol/types/types_view.go @@ -20,7 +20,7 @@ import ( "tailscale.com/types/views" ) -//go:generate go run tailscale.com/cmd/cloner -clonefunc=false -type=User,Node,PreAuthKey +//go:generate go run tailscale.com/cmd/cloner -clonefunc=false -type=User,Node,PreAuthKey,Credential // View returns a read-only view of User. func (p *User) View() UserView { @@ -230,13 +230,13 @@ func (v NodeView) RegisterMethod() string { return v.ж.RegisterMethod } // Tags cannot be removed once set (one-way transition). func (v NodeView) Tags() views.Slice[string] { return views.SliceOf(v.ж.Tags) } -// When a node has been created with a [PreAuthKey], we need to -// prevent the preauthkey from being deleted before the node. -// The preauthkey can define "tags" of the node so we need it -// around. +// When a node has been created with a pre-auth key, we keep the key +// credential around: it can define the node's tags and must not be deleted +// before the node. The association is to the unified [Credential] (kind +// authkey), which is what auth_key_id references. func (v NodeView) AuthKeyID() views.ValuePointer[uint64] { return views.ValuePointerOf(v.ж.AuthKeyID) } -func (v NodeView) AuthKey() PreAuthKeyView { return v.ж.AuthKey.View() } +func (v NodeView) AuthKey() CredentialView { return v.ж.AuthKey.View() } func (v NodeView) Expiry() views.ValuePointer[time.Time] { return views.ValuePointerOf(v.ж.Expiry) } // LastSeen is when the node was last in contact with @@ -299,7 +299,7 @@ var _NodeViewNeedsRegeneration = Node(struct { RegisterMethod string Tags Strings AuthKeyID *uint64 - AuthKey *PreAuthKey + AuthKey *Credential Expiry *time.Time LastSeen *time.Time ApprovedRoutes Prefixes @@ -440,3 +440,126 @@ var _PreAuthKeyViewNeedsRegeneration = PreAuthKey(struct { Expiration *time.Time Revoked *time.Time }{}) + +// View returns a read-only view of Credential. +func (p *Credential) View() CredentialView { + return CredentialView{ж: p} +} + +// CredentialView provides a read-only view over Credential. +// +// Its methods should only be called if `Valid()` returns true. +type CredentialView struct { + // ж is the underlying mutable value, named with a hard-to-type + // character that looks pointy like a pointer. + // It is named distinctively to make you think of how dangerous it is to escape + // to callers. You must not let callers be able to mutate it. + ж *Credential +} + +// Valid reports whether v's underlying value is non-nil. +func (v CredentialView) Valid() bool { return v.ж != nil } + +// AsStruct returns a clone of the underlying value which aliases no memory with +// the original. +func (v CredentialView) AsStruct() *Credential { + if v.ж == nil { + return nil + } + return v.ж.Clone() +} + +// MarshalJSON implements [jsonv1.Marshaler]. +func (v CredentialView) MarshalJSON() ([]byte, error) { + return jsonv1.Marshal(v.ж) +} + +// MarshalJSONTo implements [jsonv2.MarshalerTo]. +func (v CredentialView) MarshalJSONTo(enc *jsontext.Encoder) error { + return jsonv2.MarshalEncode(enc, v.ж) +} + +// UnmarshalJSON implements [jsonv1.Unmarshaler]. +func (v *CredentialView) UnmarshalJSON(b []byte) error { + if v.ж != nil { + return errors.New("already initialized") + } + if len(b) == 0 { + return nil + } + var x Credential + if err := jsonv1.Unmarshal(b, &x); err != nil { + return err + } + v.ж = &x + return nil +} + +// UnmarshalJSONFrom implements [jsonv2.UnmarshalerFrom]. +func (v *CredentialView) UnmarshalJSONFrom(dec *jsontext.Decoder) error { + if v.ж != nil { + return errors.New("already initialized") + } + var x Credential + if err := jsonv2.UnmarshalDecode(dec, &x); err != nil { + return err + } + v.ж = &x + return nil +} + +func (v CredentialView) ID() uint64 { return v.ж.ID } +func (v CredentialView) Kind() string { return v.ж.Kind } +func (v CredentialView) Identifier() string { return v.ж.Identifier } +func (v CredentialView) Hash() views.ByteSlice[[]byte] { return views.ByteSliceOf(v.ж.Hash) } + +// UserID records the owning or creating user. Kept as a plain column with no +// foreign key for API/OAuth kinds; pre-auth keys keep the user association. +func (v CredentialView) UserID() views.ValuePointer[uint] { return views.ValuePointerOf(v.ж.UserID) } + +func (v CredentialView) User() UserView { return v.ж.User.View() } +func (v CredentialView) Description() string { return v.ж.Description } +func (v CredentialView) Scopes() views.Slice[string] { return views.SliceOf(v.ж.Scopes) } +func (v CredentialView) Tags() views.Slice[string] { return views.SliceOf(v.ж.Tags) } +func (v CredentialView) Reusable() bool { return v.ж.Reusable } +func (v CredentialView) Ephemeral() bool { return v.ж.Ephemeral } +func (v CredentialView) Used() bool { return v.ж.Used } +func (v CredentialView) LastSeen() views.ValuePointer[time.Time] { + return views.ValuePointerOf(v.ж.LastSeen) +} + +// ClientID links an OAuth access token (Kind == CredentialOAuthToken) back to +// the Identifier of its issuing OAuth client. +func (v CredentialView) ClientID() string { return v.ж.ClientID } +func (v CredentialView) CreatedAt() views.ValuePointer[time.Time] { + return views.ValuePointerOf(v.ж.CreatedAt) +} + +func (v CredentialView) Expiration() views.ValuePointer[time.Time] { + return views.ValuePointerOf(v.ж.Expiration) +} + +func (v CredentialView) Revoked() views.ValuePointer[time.Time] { + return views.ValuePointerOf(v.ж.Revoked) +} + +// A compilation failure here means this code must be regenerated, with the command at the top of this file. +var _CredentialViewNeedsRegeneration = Credential(struct { + ID uint64 + Kind string + Identifier string + Hash []byte + UserID *uint + User *User + Description string + Scopes []string + Tags []string + Reusable bool + Ephemeral bool + Used bool + LastSeen *time.Time + ClientID string + CreatedAt *time.Time + Expiration *time.Time + Revoked *time.Time +}{}) diff --git a/integration/cli_apikeys_test.go b/integration/cli_apikeys_test.go index fab3a1c7d..af657e0cf 100644 --- a/integration/cli_apikeys_test.go +++ b/integration/cli_apikeys_test.go @@ -72,11 +72,16 @@ func TestApiKeyCommand(t *testing.T) { assert.Len(t, listedAPIKeys, 5) - assert.Equal(t, "1", listedAPIKeys[0].Id) - assert.Equal(t, "2", listedAPIKeys[1].Id) - assert.Equal(t, "3", listedAPIKeys[2].Id) - assert.Equal(t, "4", listedAPIKeys[3].Id) - assert.Equal(t, "5", listedAPIKeys[4].Id) + // IDs are drawn from the shared credentials table, so they are not + // guaranteed to start at 1 (pre-auth keys created during env setup take the + // first ids). Assert the five keys have distinct, non-empty ids instead. + seenIDs := make(map[string]bool) + + for _, key := range listedAPIKeys { + assert.NotEmpty(t, key.Id) + assert.False(t, seenIDs[key.Id], "duplicate API key id %s", key.Id) + seenIDs[key.Id] = true + } assert.NotEmpty(t, listedAPIKeys[0].Prefix) assert.NotEmpty(t, listedAPIKeys[1].Prefix)