db: add unified credentials table

One store for every credential kind, keyed by kind. Additive; cutover follows.
This commit is contained in:
Kristoffer Dalby 2026-06-27 12:19:46 +00:00
parent b126cff4f8
commit 30a0ddde4b
4 changed files with 186 additions and 0 deletions

View file

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

View file

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

View file

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

View file

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