db: add API-key owner and pre-auth-key description for v2

Give API keys an optional owning user and pre-auth keys a free-text
description, plus the state accessors the v2 API needs to act as a
key's owner and to persist descriptions.
This commit is contained in:
Kristoffer Dalby
2026-06-20 20:16:51 +00:00
parent 4fa6af0102
commit 3d811f29a6
9 changed files with 180 additions and 35 deletions
+26
View File
@@ -25,6 +25,7 @@ const (
var (
ErrAPIKeyFailedToParse = errors.New("failed to parse ApiKey")
ErrAPIKeyGenerationFailed = errors.New("failed to generate API key")
ErrAPIKeyExpired = errors.New("API key expired")
)
// CreateAPIKey creates a new [types.APIKey] in a user, and returns it.
@@ -127,6 +128,31 @@ func (hsdb *HSDatabase) ValidateAPIKey(keyStr string) (bool, error) {
return true, nil
}
// AuthenticateAPIKey validates keyStr and returns the matching, unexpired
// [types.APIKey] (with its owning UserID populated). Unlike ValidateAPIKey it
// returns the key itself, so the v2 API can act as the key's owning user. A
// non-nil error means the key is missing, malformed, or expired.
func (hsdb *HSDatabase) AuthenticateAPIKey(keyStr string) (*types.APIKey, error) {
key, err := validateAPIKey(hsdb.DB, keyStr)
if err != nil {
return nil, err
}
if key.Expiration != nil && key.Expiration.Before(time.Now()) {
return nil, ErrAPIKeyExpired
}
return key, nil
}
// SetAPIKeyUser sets the owning user of an API key. Used when an admin mints a
// key on behalf of a user (headscale apikeys create --user).
func (hsdb *HSDatabase) SetAPIKeyUser(keyID uint64, userID types.UserID) error {
return hsdb.DB.Model(&types.APIKey{}).
Where("id = ?", keyID).
Update("user_id", uint(userID)).Error
}
// ParseAPIKeyPrefix extracts the database prefix from a display prefix.
// Handles formats: "hskey-api-{12chars}-***", "hskey-api-{12chars}", or just "{12chars}".
// Returns the 12-character prefix suitable for database lookup.
+37 -4
View File
@@ -38,9 +38,9 @@ var errDatabaseNotSupported = errors.New("database type not supported")
var errForeignKeyConstraintsViolated = errors.New("foreign key constraints violated")
const (
maxIdleConns = 100
maxOpenConns = 100
contextTimeoutSecs = 10
maxIdleConns = 100
maxOpenConns = 100
contextTimeout = 10 * time.Second
)
type HSDatabase struct {
@@ -781,6 +781,39 @@ WHERE user_id IS NULL
},
Rollback: func(db *gorm.DB) error { return nil },
},
{
// Add an optional owning user to API keys so the v2 API can
// create user-owned (untagged) auth keys, mirroring Tailscale's
// "key owned by the creating identity".
ID: "202606191500-api-key-user-id",
Migrate: func(tx *gorm.DB) error {
if !tx.Migrator().HasColumn(&types.APIKey{}, "user_id") {
err := tx.Migrator().AddColumn(&types.APIKey{}, "user_id")
if err != nil {
return fmt.Errorf("adding user_id to api_keys: %w", err)
}
}
return nil
},
Rollback: func(db *gorm.DB) error { return nil },
},
{
// Add a free-text description to pre-auth keys, set via the
// v2 keys API.
ID: "202606191501-pre-auth-key-description",
Migrate: func(tx *gorm.DB) error {
if !tx.Migrator().HasColumn(&types.PreAuthKey{}, "description") {
err := tx.Migrator().AddColumn(&types.PreAuthKey{}, "description")
if err != nil {
return fmt.Errorf("adding description to pre_auth_keys: %w", err)
}
}
return nil
},
Rollback: func(db *gorm.DB) error { return nil },
},
},
)
@@ -873,7 +906,7 @@ WHERE user_id IS NULL
defer sqlConn.SetMaxIdleConns(1)
defer sqlConn.SetMaxOpenConns(1)
ctx, cancel := context.WithTimeout(context.Background(), contextTimeoutSecs*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), contextTimeout)
defer cancel()
opts := squibble.DigestOptions{
+23
View File
@@ -133,6 +133,15 @@ func CreatePreAuthKey(
}, nil
}
// SetPreAuthKeyDescription sets the free-text description on a pre-auth key.
// 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).
Update("description", description).Error
}
func (hsdb *HSDatabase) ListPreAuthKeys() ([]types.PreAuthKey, error) {
return Read(hsdb.DB, ListPreAuthKeys)
}
@@ -295,6 +304,20 @@ func GetPreAuthKey(tx *gorm.DB, key string) (*types.PreAuthKey, error) {
return findAuthKey(tx, key)
}
// 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{}
// 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 {
return nil, result.Error
}
return &pak, nil
}
// DestroyPreAuthKey destroys a preauthkey. Returns error if the [types.PreAuthKey]
// does not exist. This also clears the auth_key_id on any nodes that reference
// this key.
+2
View File
@@ -44,6 +44,7 @@ CREATE TABLE pre_auth_keys(
prefix text,
hash blob,
user_id integer,
description text,
reusable numeric,
ephemeral numeric DEFAULT false,
used numeric DEFAULT false,
@@ -60,6 +61,7 @@ CREATE TABLE api_keys(
id integer PRIMARY KEY AUTOINCREMENT,
prefix text,
hash blob,
user_id integer,
expiration datetime,
last_seen datetime,
+25 -3
View File
@@ -1373,6 +1373,17 @@ func (s *State) ValidateAPIKey(keyStr string) (bool, error) {
return s.db.ValidateAPIKey(keyStr)
}
// AuthenticateAPIKey validates an API key and returns it (with its owning
// user), so callers like the v2 API can act as the key's owner.
func (s *State) AuthenticateAPIKey(keyStr string) (*types.APIKey, error) {
return s.db.AuthenticateAPIKey(keyStr)
}
// SetAPIKeyUser sets the owning user of an API key by its database ID.
func (s *State) SetAPIKeyUser(keyID uint64, userID types.UserID) error {
return s.db.SetAPIKeyUser(keyID, userID)
}
// CreateAPIKey generates a new API key with optional expiration.
func (s *State) CreateAPIKey(expiration *time.Time) (string, *types.APIKey, error) {
return s.db.CreateAPIKey(expiration)
@@ -1457,9 +1468,15 @@ func (s *State) DB() *hsdb.HSDatabase {
return s.db
}
// GetPreAuthKey retrieves a pre-authentication key by ID.
func (s *State) GetPreAuthKey(id string) (*types.PreAuthKey, error) {
return s.db.GetPreAuthKey(id)
// GetPreAuthKey retrieves a pre-authentication key by its secret. The caller is
// responsible for checking whether the key is usable (expired or used).
func (s *State) GetPreAuthKey(keyStr string) (*types.PreAuthKey, error) {
return s.db.GetPreAuthKey(keyStr)
}
// GetPreAuthKeyByID retrieves a pre-authentication key by its database id.
func (s *State) GetPreAuthKeyByID(id uint64) (*types.PreAuthKey, error) {
return s.db.GetPreAuthKeyByID(id)
}
// ListPreAuthKeys returns all pre-authentication keys for a user.
@@ -1467,6 +1484,11 @@ func (s *State) ListPreAuthKeys() ([]types.PreAuthKey, error) {
return s.db.ListPreAuthKeys()
}
// SetPreAuthKeyDescription sets the free-text description on a pre-auth key.
func (s *State) SetPreAuthKeyDescription(id uint64, description string) error {
return s.db.SetPreAuthKeyDescription(id, description)
}
// ExpirePreAuthKey marks a pre-authentication key as expired.
func (s *State) ExpirePreAuthKey(id uint64) error {
return s.db.ExpirePreAuthKey(id)
+7
View File
@@ -17,6 +17,13 @@ type APIKey struct {
Prefix string `gorm:"uniqueIndex"`
Hash []byte
// Optional owning user id. When set, an auth key created through the v2 API
// with no tags is owned by this user — mirroring Tailscale, where a key is
// owned by the identity that created it. Nil for legacy/admin keys, which
// can only create tagged keys. Kept as a plain column (no foreign key) so an
// upgraded database matches a freshly-migrated one.
UserID *uint
CreatedAt *time.Time
Expiration *time.Time
LastSeen *time.Time
+26
View File
@@ -1,8 +1,10 @@
package types
import (
"strconv"
"time"
"github.com/juanfont/headscale/hscontrol/util"
"github.com/juanfont/headscale/hscontrol/util/zlog/zf"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
@@ -12,6 +14,26 @@ type PAKError string
func (e PAKError) Error() string { return string(e) }
// StringID returns the key's id as a decimal string, the form the HTTP APIs
// render it as.
func (pak *PreAuthKey) StringID() string {
if pak == nil {
return ""
}
return strconv.FormatUint(pak.ID, util.Base10)
}
// StringID returns the key's id as a decimal string, the form the HTTP APIs
// render it as.
func (pak *PreAuthKeyNew) StringID() string {
if pak == nil {
return ""
}
return strconv.FormatUint(pak.ID, util.Base10)
}
// PreAuthKey describes a pre-authorization key usable in a particular user.
type PreAuthKey struct {
ID uint64 `gorm:"primary_key"`
@@ -29,6 +51,10 @@ type PreAuthKey struct {
UserID *uint
User *User `gorm:"constraint:OnDelete:SET NULL;"`
// Free-text description, set via the v2 API. Empty for keys created through
// the v1 API or CLI.
Description string
Reusable bool
Ephemeral bool `gorm:"default:false"`
Used bool `gorm:"default:false"`
+13 -12
View File
@@ -137,16 +137,17 @@ func (src *PreAuthKey) Clone() *PreAuthKey {
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _PreAuthKeyCloneNeedsRegeneration = PreAuthKey(struct {
ID uint64
Key string
Prefix string
Hash []byte
UserID *uint
User *User
Reusable bool
Ephemeral bool
Used bool
Tags []string
CreatedAt *time.Time
Expiration *time.Time
ID uint64
Key string
Prefix string
Hash []byte
UserID *uint
User *User
Description string
Reusable bool
Ephemeral bool
Used bool
Tags []string
CreatedAt *time.Time
Expiration *time.Time
}{})
+21 -16
View File
@@ -395,10 +395,14 @@ func (v PreAuthKeyView) Hash() views.ByteSlice[[]byte] { return views.ByteSliceO
// Can be nil for system-created tagged keys
func (v PreAuthKeyView) UserID() views.ValuePointer[uint] { return views.ValuePointerOf(v.ж.UserID) }
func (v PreAuthKeyView) User() UserView { return v.ж.User.View() }
func (v PreAuthKeyView) Reusable() bool { return v.ж.Reusable }
func (v PreAuthKeyView) Ephemeral() bool { return v.ж.Ephemeral }
func (v PreAuthKeyView) Used() bool { return v.ж.Used }
func (v PreAuthKeyView) User() UserView { return v.ж.User.View() }
// Free-text description, set via the v2 API. Empty for keys created through
// the v1 API or CLI.
func (v PreAuthKeyView) Description() string { return v.ж.Description }
func (v PreAuthKeyView) Reusable() bool { return v.ж.Reusable }
func (v PreAuthKeyView) Ephemeral() bool { return v.ж.Ephemeral }
func (v PreAuthKeyView) Used() bool { return v.ж.Used }
// Tags to assign to nodes registered with this key.
// Tags are copied to the node during registration.
@@ -414,16 +418,17 @@ func (v PreAuthKeyView) Expiration() views.ValuePointer[time.Time] {
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _PreAuthKeyViewNeedsRegeneration = PreAuthKey(struct {
ID uint64
Key string
Prefix string
Hash []byte
UserID *uint
User *User
Reusable bool
Ephemeral bool
Used bool
Tags []string
CreatedAt *time.Time
Expiration *time.Time
ID uint64
Key string
Prefix string
Hash []byte
UserID *uint
User *User
Description string
Reusable bool
Ephemeral bool
Used bool
Tags []string
CreatedAt *time.Time
Expiration *time.Time
}{})