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.
This commit is contained in:
Kristoffer Dalby 2026-06-27 12:20:41 +00:00
parent 7acf557102
commit 04e8fcda79
21 changed files with 530 additions and 234 deletions

View file

@ -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(),

View file

@ -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")

View file

@ -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.

View file

@ -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)`,
}

View file

@ -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")

View file

@ -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
}

View file

@ -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
}

View file

@ -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)

View file

@ -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-<client_id>-<secret>); 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(

View file

@ -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)
},
},

View file

@ -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{

View file

@ -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"),

View file

@ -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,
}

View file

@ -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 = &params.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

View file

@ -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"

View file

@ -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,
}
}

View file

@ -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

View file

@ -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"},
},
},

View file

@ -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
}{})

View file

@ -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
}{})

View file

@ -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)