mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-22 23:47:57 +00:00
db: add unified credentials table
One store for every credential kind, keyed by kind. Additive; cutover follows.
This commit is contained in:
parent
b126cff4f8
commit
30a0ddde4b
4 changed files with 186 additions and 0 deletions
48
hscontrol/db/credential_test.go
Normal file
48
hscontrol/db/credential_test.go
Normal 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")
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
61
hscontrol/types/credential.go
Normal file
61
hscontrol/types/credential.go
Normal 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" }
|
||||
Loading…
Add table
Add a link
Reference in a new issue