From 30a0ddde4bdf0fb3e61bacf10d281c9ea0f7f8ff Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 27 Jun 2026 12:19:46 +0000 Subject: [PATCH] db: add unified credentials table One store for every credential kind, keyed by kind. Additive; cutover follows. --- hscontrol/db/credential_test.go | 48 ++++++++++++++++++++++++++ hscontrol/db/db.go | 51 +++++++++++++++++++++++++++ hscontrol/db/schema.sql | 26 ++++++++++++++ hscontrol/types/credential.go | 61 +++++++++++++++++++++++++++++++++ 4 files changed, 186 insertions(+) create mode 100644 hscontrol/db/credential_test.go create mode 100644 hscontrol/types/credential.go diff --git a/hscontrol/db/credential_test.go b/hscontrol/db/credential_test.go new file mode 100644 index 000000000..d51d82d20 --- /dev/null +++ b/hscontrol/db/credential_test.go @@ -0,0 +1,48 @@ +package db + +import ( + "testing" + "time" + + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestCredentialTableRoundTrip confirms the unified credentials table is created +// by migration (newSQLiteTestDB validates the schema with squibble) and stores +// and reads back a credential of each kind. +func TestCredentialTableRoundTrip(t *testing.T) { + db, err := newSQLiteTestDB() + require.NoError(t, err) + + now := time.Now().UTC() + creds := []types.Credential{ + {Kind: types.CredentialAPIKey, Identifier: "apikey000001", Hash: []byte("h1"), CreatedAt: &now}, + {Kind: types.CredentialPreAuthKey, Identifier: "authkey00001", Hash: []byte("h2"), Reusable: true, Tags: []string{"tag:a"}, CreatedAt: &now}, + {Kind: types.CredentialOAuthClient, Identifier: "client000001", Hash: []byte("h3"), Scopes: []string{"devices:read"}, CreatedAt: &now}, + {Kind: types.CredentialOAuthToken, Identifier: "oauthtok0001", Hash: []byte("h4"), ClientID: "client000001", CreatedAt: &now}, + } + + for i := range creds { + require.NoError(t, db.DB.Save(&creds[i]).Error) + } + + var got []types.Credential + require.NoError(t, db.DB.Order("id").Find(&got).Error) + require.Len(t, got, 4) + assert.Equal(t, types.CredentialPreAuthKey, got[1].Kind) + assert.Equal(t, []string{"tag:a"}, got[1].Tags) + assert.Equal(t, "client000001", got[3].ClientID) + + // The composite (kind, identifier) index permits the same identifier under a + // different kind but rejects a duplicate within a kind. + require.NoError(t, db.DB.Save(&types.Credential{ + Kind: types.CredentialAPIKey, Identifier: "client000001", Hash: []byte("h5"), CreatedAt: &now, + }).Error) + + err = db.DB.Save(&types.Credential{ + Kind: types.CredentialAPIKey, Identifier: "apikey000001", Hash: []byte("dup"), CreatedAt: &now, + }).Error + require.Error(t, err, "duplicate (kind, identifier) must be rejected") +} diff --git a/hscontrol/db/db.go b/hscontrol/db/db.go index c9b94ef6c..2355a85e2 100644 --- a/hscontrol/db/db.go +++ b/hscontrol/db/db.go @@ -900,6 +900,54 @@ WHERE user_id IS NULL }, Rollback: func(db *gorm.DB) error { return nil }, }, + { + // Create the unified credentials table. Backfill from the existing + // per-kind tables and their removal land in a later migration, so + // this step is purely additive. SQLite uses explicit DDL matching + // schema.sql; Postgres uses dialect-aware AutoMigrate. + ID: "202606271200-create-credentials", + Migrate: func(tx *gorm.DB) error { + if tx.Migrator().HasTable(&types.Credential{}) { + return nil + } + + if tx.Name() != "sqlite" { + return tx.AutoMigrate(&types.Credential{}) + } + + err := tx.Exec(`CREATE TABLE credentials( + id integer PRIMARY KEY AUTOINCREMENT, + kind text, + identifier text, + hash blob, + user_id integer, + description text, + scopes text, + tags text, + reusable numeric, + ephemeral numeric DEFAULT false, + used numeric DEFAULT false, + last_seen datetime, + client_id text, + created_at datetime, + expiration datetime, + revoked datetime, + + CONSTRAINT fk_credentials_user FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL +)`).Error + if err != nil { + return fmt.Errorf("creating credentials table: %w", err) + } + + err = tx.Exec(`CREATE UNIQUE INDEX idx_credentials_identifier ON credentials(kind, identifier)`).Error + if err != nil { + return fmt.Errorf("creating credentials index: %w", err) + } + + return nil + }, + Rollback: func(db *gorm.DB) error { return nil }, + }, }, ) @@ -913,6 +961,7 @@ WHERE user_id IS NULL &types.Policy{}, &types.OAuthClient{}, &types.OAuthAccessToken{}, + &types.Credential{}, ) if err != nil { return err @@ -930,6 +979,7 @@ WHERE user_id IS NULL `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"`, } for _, dropSQL := range dropIndexes { @@ -950,6 +1000,7 @@ WHERE user_id 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)`, } for _, indexSQL := range indexes { diff --git a/hscontrol/db/schema.sql b/hscontrol/db/schema.sql index 59b601601..852103d9d 100644 --- a/hscontrol/db/schema.sql +++ b/hscontrol/db/schema.sql @@ -100,6 +100,32 @@ CREATE TABLE oauth_access_tokens( ); 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 +-- a kind. Per-kind columns are sparse by design. +CREATE TABLE credentials( + id integer PRIMARY KEY AUTOINCREMENT, + kind text, + identifier text, + hash blob, + user_id integer, + description text, + scopes text, + tags text, + reusable numeric, + ephemeral numeric DEFAULT false, + used numeric DEFAULT false, + last_seen datetime, + client_id text, + created_at datetime, + expiration datetime, + revoked datetime, + + CONSTRAINT fk_credentials_user FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL +); +CREATE UNIQUE INDEX idx_credentials_identifier ON credentials(kind, identifier); + CREATE TABLE nodes( id integer PRIMARY KEY AUTOINCREMENT, machine_key text, diff --git a/hscontrol/types/credential.go b/hscontrol/types/credential.go new file mode 100644 index 000000000..a9dfc626f --- /dev/null +++ b/hscontrol/types/credential.go @@ -0,0 +1,61 @@ +package types + +import ( + "time" +) + +// Credential kinds. Every authenticatable secret in headscale is stored in the +// single credentials table, discriminated by Kind. +const ( + CredentialAPIKey = "api" + CredentialPreAuthKey = "authkey" + CredentialOAuthClient = "oauth_client" //nolint:gosec // discriminator value, not a credential + CredentialOAuthToken = "oauth_token" //nolint:gosec // discriminator value, not a credential +) + +// Credential is the unified storage model for every authenticatable secret: +// API keys, pre-auth keys, OAuth clients, and OAuth access tokens. Each row is +// discriminated by [Credential.Kind]. The secret is stored only as an Argon2id +// hash (legacy rows may still hold a bcrypt hash until first re-authentication). +// Identifier is the public, indexed lookup value — the 12-char prefix for API +// keys, pre-auth keys and access tokens, and the client id for OAuth clients — +// and is unique within a kind. +// +// Per-kind fields are sparse by design: Reusable/Ephemeral/Used apply to +// pre-auth keys, LastSeen to API keys, Scopes to OAuth credentials, ClientID +// links an OAuth token to its issuing client, and Tags to pre-auth keys and +// OAuth credentials. +type Credential struct { + ID uint64 `gorm:"primary_key"` + Kind string `gorm:"index:idx_credentials_identifier,unique,priority:1"` + Identifier string `gorm:"index:idx_credentials_identifier,unique,priority:2"` + Hash []byte + + // 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. + UserID *uint + User *User `gorm:"constraint:OnDelete:SET NULL;"` + + Description string + + Scopes []string `gorm:"serializer:json"` + Tags []string `gorm:"serializer:json"` + + Reusable bool + Ephemeral bool `gorm:"default:false"` + Used bool `gorm:"default:false"` + + LastSeen *time.Time + + // ClientID links an OAuth access token (Kind == CredentialOAuthToken) back to + // the Identifier of its issuing OAuth client. + ClientID string + + CreatedAt *time.Time + Expiration *time.Time + Revoked *time.Time +} + +// 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" }