Skip to content

Commit 04e8fcd

Browse files
committed
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.
1 parent 7acf557 commit 04e8fcd

21 files changed

Lines changed: 530 additions & 234 deletions

hscontrol/api/v1/nodes.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -626,12 +626,12 @@ func nodeFromView(view types.NodeView) Node {
626626
return n
627627
}
628628

629-
// nodePreAuthKeyFromView builds the embedded NodePreAuthKey, masking the key to
630-
// its prefix (legacy plaintext keys are shown in full).
631-
func nodePreAuthKeyFromView(key types.PreAuthKeyView) *NodePreAuthKey {
629+
// nodePreAuthKeyFromView builds the embedded NodePreAuthKey from a node's
630+
// AuthKey credential, masking the secret to its identifier prefix.
631+
func nodePreAuthKeyFromView(key types.CredentialView) *NodePreAuthKey {
632632
pak := &NodePreAuthKey{
633633
ID: formatID(key.ID()),
634-
Key: maskedPreAuthKey(key),
634+
Key: "hskey-auth-" + key.Identifier() + "-***",
635635
Reusable: key.Reusable(),
636636
Ephemeral: key.Ephemeral(),
637637
Used: key.Used(),

hscontrol/auth_test.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3398,8 +3398,8 @@ func TestIssue2830_ExistingNodeReregistersWithExpiredKey(t *testing.T) {
33983398
// Now expire the key by updating it in the database to have an expiry in the past.
33993399
// This simulates the real-world scenario where a key expires after initial registration.
34003400
pastExpiry := time.Now().Add(-1 * time.Hour)
3401-
err = app.state.DB().DB.Model(&types.PreAuthKey{}).
3402-
Where("id = ?", pak.ID).
3401+
err = app.state.DB().DB.Model(&types.Credential{}).
3402+
Where("kind = ? AND id = ?", types.CredentialPreAuthKey, pak.ID).
34033403
Update("expiration", pastExpiry).Error
34043404
require.NoError(t, err, "should be able to update key expiration")
34053405

@@ -3840,7 +3840,7 @@ func TestDeletedPreAuthKeyNotRecreatedOnNodeUpdate(t *testing.T) {
38403840
// Verify the PreAuthKey exists in the database
38413841
var pakCount int64
38423842

3843-
err = app.state.DB().DB.Model(&types.PreAuthKey{}).Where("id = ?", pakID).Count(&pakCount).Error
3843+
err = app.state.DB().DB.Model(&types.Credential{}).Where("kind = ? AND id = ?", types.CredentialPreAuthKey, pakID).Count(&pakCount).Error
38443844
require.NoError(t, err)
38453845
require.Equal(t, int64(1), pakCount, "PreAuthKey should exist in database")
38463846

@@ -3851,7 +3851,7 @@ func TestDeletedPreAuthKeyNotRecreatedOnNodeUpdate(t *testing.T) {
38513851
require.NoError(t, err, "deleting PreAuthKey should succeed")
38523852

38533853
// Verify the PreAuthKey is gone from the database
3854-
err = app.state.DB().DB.Model(&types.PreAuthKey{}).Where("id = ?", pakID).Count(&pakCount).Error
3854+
err = app.state.DB().DB.Model(&types.Credential{}).Where("kind = ? AND id = ?", types.CredentialPreAuthKey, pakID).Count(&pakCount).Error
38553855
require.NoError(t, err)
38563856
require.Equal(t, int64(0), pakCount, "PreAuthKey should be deleted from database")
38573857
t.Log("PreAuthKey deleted from database")
@@ -3886,7 +3886,7 @@ func TestDeletedPreAuthKeyNotRecreatedOnNodeUpdate(t *testing.T) {
38863886
t.Log("Simulated MapRequest update completed")
38873887

38883888
// THE CRITICAL CHECK: Verify the PreAuthKey was NOT recreated
3889-
err = app.state.DB().DB.Model(&types.PreAuthKey{}).Where("id = ?", pakID).Count(&pakCount).Error
3889+
err = app.state.DB().DB.Model(&types.Credential{}).Where("kind = ? AND id = ?", types.CredentialPreAuthKey, pakID).Count(&pakCount).Error
38903890
require.NoError(t, err)
38913891
require.Equal(t, int64(0), pakCount,
38923892
"BUG: PreAuthKey was recreated! The deleted PreAuthKey should NOT reappear after node update")

hscontrol/db/credential.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,27 @@ func credentialToOAuthClient(c *types.Credential) *types.OAuthClient {
3434
}
3535
}
3636

37+
// credentialToPreAuthKey projects a unified credentials row onto the
38+
// [types.PreAuthKey] shape. The lookup prefix is stored as the row's identifier;
39+
// the User association is carried through when preloaded.
40+
func credentialToPreAuthKey(c *types.Credential) *types.PreAuthKey {
41+
return &types.PreAuthKey{
42+
ID: c.ID,
43+
Prefix: c.Identifier,
44+
Hash: c.Hash,
45+
UserID: c.UserID,
46+
User: c.User,
47+
Description: c.Description,
48+
Reusable: c.Reusable,
49+
Ephemeral: c.Ephemeral,
50+
Used: c.Used,
51+
Tags: c.Tags,
52+
CreatedAt: c.CreatedAt,
53+
Expiration: c.Expiration,
54+
Revoked: c.Revoked,
55+
}
56+
}
57+
3758
// credentialToOAuthAccessToken projects a unified credentials row onto the
3859
// [types.OAuthAccessToken] shape. The token's lookup prefix is stored as the
3960
// row's identifier; ClientID links back to the issuing client's identifier.

hscontrol/db/db.go

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -948,20 +948,37 @@ WHERE user_id IS NULL
948948
},
949949
Rollback: func(db *gorm.DB) error { return nil },
950950
},
951+
{
952+
// Move every credential into the unified table and drop the
953+
// per-kind tables. Pre-auth keys are backfilled FIRST preserving
954+
// their ids so nodes.auth_key_id stays valid; the nodes FK is then
955+
// retargeted to credentials(id). Legacy plaintext pre-auth keys
956+
// (empty prefix) are not migrated (breaking change) and any node
957+
// referencing one has its auth_key_id cleared first.
958+
ID: "202606271300-migrate-to-credentials",
959+
Migrate: func(tx *gorm.DB) error {
960+
// Already migrated (e.g. fresh DB via InitSchema): nothing to do.
961+
if !tx.Migrator().HasTable("pre_auth_keys") &&
962+
!tx.Migrator().HasTable("api_keys") {
963+
return nil
964+
}
965+
966+
return migrateToCredentials(tx)
967+
},
968+
Rollback: func(db *gorm.DB) error { return nil },
969+
},
951970
},
952971
)
953972

954973
migrations.InitSchema(func(tx *gorm.DB) error {
955974
// Create all tables using AutoMigrate
975+
// Credential is migrated before Node so the nodes.auth_key_id foreign key
976+
// to credentials(id) can be created.
956977
err := tx.AutoMigrate(
957978
&types.User{},
958-
&types.PreAuthKey{},
959-
&types.APIKey{},
979+
&types.Credential{},
960980
&types.Node{},
961981
&types.Policy{},
962-
&types.OAuthClient{},
963-
&types.OAuthAccessToken{},
964-
&types.Credential{},
965982
)
966983
if err != nil {
967984
return err
@@ -971,14 +988,10 @@ WHERE user_id IS NULL
971988
// to ensure we can recreate them in the correct format
972989
dropIndexes := []string{
973990
`DROP INDEX IF EXISTS "idx_users_deleted_at"`,
974-
`DROP INDEX IF EXISTS "idx_api_keys_prefix"`,
975991
`DROP INDEX IF EXISTS "idx_policies_deleted_at"`,
976992
`DROP INDEX IF EXISTS "idx_provider_identifier"`,
977993
`DROP INDEX IF EXISTS "idx_name_provider_identifier"`,
978994
`DROP INDEX IF EXISTS "idx_name_no_provider_identifier"`,
979-
`DROP INDEX IF EXISTS "idx_pre_auth_keys_prefix"`,
980-
`DROP INDEX IF EXISTS "idx_oauth_clients_client_id"`,
981-
`DROP INDEX IF EXISTS "idx_oauth_access_tokens_prefix"`,
982995
`DROP INDEX IF EXISTS "idx_credentials_identifier"`,
983996
}
984997

@@ -992,14 +1005,10 @@ WHERE user_id IS NULL
9921005
// Recreate indexes without backticks to match schema.sql format
9931006
indexes := []string{
9941007
`CREATE INDEX idx_users_deleted_at ON users(deleted_at)`,
995-
`CREATE UNIQUE INDEX idx_api_keys_prefix ON api_keys(prefix)`,
9961008
`CREATE INDEX idx_policies_deleted_at ON policies(deleted_at)`,
9971009
`CREATE UNIQUE INDEX idx_provider_identifier ON users(provider_identifier) WHERE provider_identifier IS NOT NULL`,
9981010
`CREATE UNIQUE INDEX idx_name_provider_identifier ON users(name, provider_identifier)`,
9991011
`CREATE UNIQUE INDEX idx_name_no_provider_identifier ON users(name) WHERE provider_identifier IS NULL`,
1000-
`CREATE UNIQUE INDEX idx_pre_auth_keys_prefix ON pre_auth_keys(prefix) WHERE prefix IS NOT NULL AND prefix != ''`,
1001-
`CREATE UNIQUE INDEX idx_oauth_clients_client_id ON oauth_clients(client_id)`,
1002-
`CREATE UNIQUE INDEX idx_oauth_access_tokens_prefix ON oauth_access_tokens(prefix)`,
10031012
`CREATE UNIQUE INDEX idx_credentials_identifier ON credentials(kind, identifier)`,
10041013
}
10051014

hscontrol/db/db_test.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,11 @@ func TestSQLiteMigrationAndDataValidation(t *testing.T) {
4141
require.NoError(t, err)
4242
assert.Len(t, users, 1, "should preserve all 1 user from original schema")
4343

44-
// Verify api_keys data preservation
44+
// Verify api_keys data preservation (migrated into credentials).
4545
var apiKeyCount int
4646

47-
err = hsdb.DB.Raw("SELECT COUNT(*) FROM api_keys").Scan(&apiKeyCount).Error
47+
err = hsdb.DB.Raw("SELECT COUNT(*) FROM credentials WHERE kind = ?", types.CredentialAPIKey).
48+
Scan(&apiKeyCount).Error
4849
require.NoError(t, err)
4950
assert.Equal(t, 2, apiKeyCount, "should preserve all 2 api_keys from original schema")
5051

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
package db
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/juanfont/headscale/hscontrol/types"
7+
"gorm.io/gorm"
8+
)
9+
10+
// migrateToCredentials backfills the unified credentials table from the four
11+
// per-kind tables and drops them. Pre-auth keys are migrated first preserving
12+
// their ids so nodes.auth_key_id stays valid; the nodes FK is then retargeted to
13+
// credentials(id). Runs with foreign keys enabled on SQLite (per runMigrations),
14+
// so the steps are ordered to never leave a dangling reference.
15+
func migrateToCredentials(tx *gorm.DB) error {
16+
// Clear node references to legacy plaintext pre-auth keys (empty prefix);
17+
// these are not migrated, so a node pointing at one would dangle.
18+
err := tx.Exec(`UPDATE nodes SET auth_key_id = NULL
19+
WHERE auth_key_id IN (SELECT id FROM pre_auth_keys WHERE prefix IS NULL OR prefix = '')`).Error
20+
if err != nil {
21+
return fmt.Errorf("clearing plaintext auth_key references: %w", err)
22+
}
23+
24+
// Pre-auth keys first, preserving ids so nodes.auth_key_id stays valid.
25+
err = tx.Exec(`INSERT INTO credentials
26+
(id, kind, identifier, hash, user_id, description, reusable, ephemeral, used, tags, expiration, revoked, created_at)
27+
SELECT id, ?, prefix, hash, user_id, description, reusable, ephemeral, used, tags, expiration, revoked, created_at
28+
FROM pre_auth_keys WHERE prefix IS NOT NULL AND prefix != ''`, types.CredentialPreAuthKey).Error
29+
if err != nil {
30+
return fmt.Errorf("backfilling pre-auth keys: %w", err)
31+
}
32+
33+
// Postgres does not advance the id sequence on explicit-id inserts; nudge it
34+
// past the pre-auth ids before the auto-id inserts below. SQLite's
35+
// AUTOINCREMENT already tracks max(id).
36+
if tx.Name() != "sqlite" {
37+
err = tx.Exec(`SELECT setval(pg_get_serial_sequence('credentials','id'),
38+
GREATEST((SELECT COALESCE(MAX(id), 1) FROM credentials), 1))`).Error
39+
if err != nil {
40+
return fmt.Errorf("resetting credentials id sequence: %w", err)
41+
}
42+
}
43+
44+
err = tx.Exec(`INSERT INTO credentials (kind, identifier, hash, user_id, last_seen, expiration, created_at)
45+
SELECT ?, prefix, hash, user_id, last_seen, expiration, created_at FROM api_keys`, types.CredentialAPIKey).Error
46+
if err != nil {
47+
return fmt.Errorf("backfilling api keys: %w", err)
48+
}
49+
50+
err = tx.Exec(`INSERT INTO credentials (kind, identifier, hash, scopes, tags, description, user_id, revoked, created_at)
51+
SELECT ?, client_id, secret_hash, scopes, tags, description, user_id, revoked, created_at FROM oauth_clients`, types.CredentialOAuthClient).Error
52+
if err != nil {
53+
return fmt.Errorf("backfilling oauth clients: %w", err)
54+
}
55+
56+
err = tx.Exec(`INSERT INTO credentials (kind, identifier, hash, client_id, scopes, tags, expiration, created_at)
57+
SELECT ?, prefix, hash, client_id, scopes, tags, expiration, created_at FROM oauth_access_tokens`, types.CredentialOAuthToken).Error
58+
if err != nil {
59+
return fmt.Errorf("backfilling oauth access tokens: %w", err)
60+
}
61+
62+
if err := retargetNodesAuthKeyFK(tx); err != nil { //nolint:noinlineerr
63+
return err
64+
}
65+
66+
for _, table := range []string{"pre_auth_keys", "api_keys", "oauth_clients", "oauth_access_tokens"} {
67+
if err := tx.Migrator().DropTable(table); err != nil { //nolint:noinlineerr
68+
return fmt.Errorf("dropping %s: %w", table, err)
69+
}
70+
}
71+
72+
return nil
73+
}
74+
75+
// retargetNodesAuthKeyFK repoints the nodes.auth_key_id foreign key from
76+
// pre_auth_keys(id) to credentials(id). Postgres alters the constraint in place;
77+
// SQLite, which cannot alter a foreign key, rebuilds the table. The rebuild runs
78+
// with foreign keys enabled: no table references nodes, and every retained
79+
// auth_key_id now points at a credentials row, so no FK toggling is required.
80+
func retargetNodesAuthKeyFK(tx *gorm.DB) error {
81+
if tx.Name() != "sqlite" {
82+
err := tx.Exec(`ALTER TABLE nodes DROP CONSTRAINT IF EXISTS fk_nodes_auth_key`).Error
83+
if err != nil {
84+
return fmt.Errorf("dropping nodes auth_key constraint: %w", err)
85+
}
86+
87+
err = tx.Exec(`ALTER TABLE nodes ADD CONSTRAINT fk_nodes_auth_key
88+
FOREIGN KEY (auth_key_id) REFERENCES credentials(id)`).Error
89+
if err != nil {
90+
return fmt.Errorf("adding nodes auth_key constraint: %w", err)
91+
}
92+
93+
return nil
94+
}
95+
96+
stmts := []string{
97+
`CREATE TABLE nodes_new(
98+
id integer PRIMARY KEY AUTOINCREMENT,
99+
machine_key text,
100+
node_key text,
101+
disco_key text,
102+
endpoints text,
103+
host_info text,
104+
ipv4 text,
105+
ipv6 text,
106+
hostname text,
107+
given_name varchar(63),
108+
user_id integer,
109+
register_method text,
110+
tags text,
111+
auth_key_id integer,
112+
last_seen datetime,
113+
expiry datetime,
114+
approved_routes text,
115+
116+
created_at datetime,
117+
updated_at datetime,
118+
deleted_at datetime,
119+
120+
CONSTRAINT fk_nodes_user FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
121+
CONSTRAINT fk_nodes_auth_key FOREIGN KEY(auth_key_id) REFERENCES credentials(id)
122+
)`,
123+
`INSERT INTO nodes_new
124+
(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)
125+
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
126+
FROM nodes`,
127+
`DROP TABLE nodes`,
128+
`ALTER TABLE nodes_new RENAME TO nodes`,
129+
}
130+
131+
for _, stmt := range stmts {
132+
if err := tx.Exec(stmt).Error; err != nil { //nolint:noinlineerr
133+
return fmt.Errorf("rebuilding nodes table: %w", err)
134+
}
135+
}
136+
137+
return nil
138+
}

0 commit comments

Comments
 (0)