identity

package
v0.2.0-alpha.13 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 18, 2026 License: Apache-2.0 Imports: 10 Imported by: 0

Documentation

Index

Constants

View Source
const (
	// AccessTokenTTLDefault is short: the access token is a signed JWT the server
	// never stores, so this is the window a stolen one still works before the
	// guard's session check gets another chance to refuse it. The refresh token
	// carries the day-to-day usability, so shortening this does not sign anyone
	// out — it just means a client refreshes more often.
	AccessTokenTTLDefault       = 15 * time.Minute
	RefreshTokenTTLDefault      = 30 * 24 * time.Hour
	RefreshRotationGraceDefault = 30 * time.Second
)

Token lifetimes used when a deployment does not choose. The access token is a signed JWT the server never stores, so the only way to retire one early is to wait for it to expire; the refresh token is a stored row and can be revoked at any time. That asymmetry is why the long-lived half is the stored one.

View Source
const LoginCodeTTLDefault = time.Hour

LoginCodeTTLDefault is how long an issued code stays valid when the caller does not choose. Long enough to hand a code to someone over a chat message, short enough that a leaked one expires before it is useful.

View Source
const PasswordMaxLength = 1024

PasswordMaxLength bounds what will be hashed. Argon2 has no length limit of its own, but accepting unbounded input means accepting unbounded work.

View Source
const PasswordMinLength = 12

PasswordMinLength is the shortest password BuildMax accepts.

It is longer than the usual eight because BuildMax has no login throttling yet: an attacker who can reach the server can guess as fast as the server will hash. Length is the only defense that does not need infrastructure, so it carries more weight here than it would elsewhere. See docs/deploy/authentication.md.

View Source
const SessionAbsoluteTTLDefault = 90 * 24 * time.Hour

SessionAbsoluteTTLDefault bounds how long one login may live regardless of how often its refresh token rotates. Rotation keeps a session usable indefinitely otherwise, so the absolute cap is what makes "a session cannot renew forever" true. It is the local/password/login-code default; a stricter bound (an OIDC session_max_age, say) is a separate, shorter cap layered on top.

View Source
const (
	// SystemRoleAdmin may manage accounts, read deployment status, and search
	// the audit trail across spaces. It grants no access to any space's issues,
	// conversations, artifacts, files, or run traces: those stay behind space
	// membership, which a system grant never substitutes for.
	SystemRoleAdmin = "system_admin"
)

System roles are deployment-scoped: they are held by a user and attached to no space. A grant is an authority to operate the deployment, not a key to its contents — see docs/design/system-administration.md.

Variables

View Source
var (
	// ErrIdentityConflict means the (issuer, subject) or (issuer, user_id) pair
	// is already taken — a concurrent first login, or an account already linked
	// to this issuer under a different subject. The association service turns it
	// into an operator-investigation refusal rather than replacing either link.
	ErrIdentityConflict = errors.New("external identity already linked")
	// ErrUnlinkRequiresDisabled means an unlink was attempted while the account
	// was still enabled. Unlinking is an operator recovery step; enabling the
	// account again is the deliberate re-authorization.
	ErrUnlinkRequiresDisabled = errors.New("external identity can be unlinked only while the account is disabled")
	// ErrIdentityNotFound means the named link does not exist for that user.
	ErrIdentityNotFound = errors.New("external identity not found")
)

Refusals the external-identity store and association can produce.

View Source
var (
	ErrPasswordTooShort = fmt.Errorf("password must be at least %d characters", PasswordMinLength)
	ErrPasswordTooLong  = fmt.Errorf("password must be at most %d characters", PasswordMaxLength)
)

ErrPasswordTooShort and ErrPasswordTooLong report an unusable password. They are separate from a failed login: these mean "choose another", not "wrong".

View Source
var ErrEmailExists = errors.New("email already exists")

ErrEmailExists is returned by CreateUser when the email is already registered.

View Source
var ErrRefreshTokenInvalid = errors.New("refresh token invalid")

ErrRefreshTokenInvalid means the token is unknown, expired, or belongs to a session that has been revoked. The three are deliberately indistinguishable to the caller.

View Source
var ErrRefreshTokenReused = errors.New("refresh token reused")

ErrRefreshTokenReused means a token that had already been rotated was presented again after the grace window. Either the client is replaying a credential it should have discarded, or someone else has a copy — and there is no way to tell which. The session is revoked before this is returned.

View Source
var ErrSessionInactive = errors.New("session inactive")

ErrSessionInactive means the named session does not exist, was revoked, or has passed its absolute expiry. The three are deliberately indistinguishable to the caller: each means the same thing to a request presenting a token under that session — you are not signed in.

View Source
var ErrSystemGrantExists = errors.New("user already holds this system role")

ErrSystemGrantExists is returned when the user already holds an active grant for the role.

View Source
var ErrSystemGrantLastHolder = errors.New("cannot revoke the last effective holder of this system role")

ErrSystemGrantLastHolder is returned by RevokeSystemRole when keepLastHolder is set and the grant is the deployment's last effective holder of the role. Removing it would lock every signed-in caller out of the admin area with no way back through the same door, so only the operator shell — which reaches the database directly and can undo the lockout — may do it.

View Source
var ErrSystemRoleUnknown = errors.New("unknown system role")

ErrSystemRoleUnknown is returned when a grant names a role this build does not define. The role column exists so a second role can be added without a migration, but only roles with a caller are accepted.

View Source
var ErrUserNotFound = errors.New("user not found")

ErrUserNotFound is returned when an operation names an account that is not there.

Functions

func DummyVerifyPassword

func DummyVerifyPassword(plaintext string) bool

DummyVerifyPassword performs the same work as VerifyPassword and always fails.

Login calls it when the address has no account, so that a request for an unknown address costs the same as one for a known address with the wrong password. Without it the response time alone answers "does this person have an account here".

func HashPassword

func HashPassword(plaintext string) (string, error)

HashPassword returns a PHC-encoded argon2id hash of plaintext.

Argon2id rather than a plain SHA family: this is the one value in BuildMax that a person chose and may have reused elsewhere, so a leaked database must not turn into a list of working passwords for other services. Memory-hard hashing is what makes an offline attack on the dump expensive.

func ValidSystemRole

func ValidSystemRole(role string) bool

ValidSystemRole reports whether role is one this build authorizes.

func ValidatePassword

func ValidatePassword(plaintext string) error

ValidatePassword reports whether plaintext may be used as a password.

Length only. A composition rule — a digit, a symbol, a capital — pushes people toward short predictable passwords that satisfy it, which is the opposite of what the length minimum is for.

func VerifyPassword

func VerifyPassword(encodedHash, plaintext string) bool

VerifyPassword reports whether plaintext produced encodedHash.

A malformed or empty hash is a mismatch rather than an error. Callers use this to decide whether to authenticate, and a stored value that cannot be parsed must not become a way in.

Types

type AuthSession

type AuthSession struct {
	// SID is the public session identifier. It is not a secret: it is already a
	// claim (`sid`) in every access token issued under it, and it is the handle a
	// revoke names.
	SID string
	// UserID is the account whose authority the session exercises.
	UserID string
	// Platform is the surface that logged in ("portal", "cli", "desktop"), a
	// label for the operator reading the session list rather than something the
	// server enforces.
	Platform string
	// AuthMethod is the proof that opened the session ("password", "login_code").
	AuthMethod string
	// CreatedAt is when the login happened.
	CreatedAt time.Time
	// LastSeenAt is the most recent time a request or refresh used the session.
	// Zero until the first touch.
	LastSeenAt time.Time
	// AbsoluteExpiresAt is the hard ceiling: past it the session is inactive no
	// matter how recently it was used.
	AbsoluteExpiresAt time.Time
}

AuthSession is the durable authority for one login.

The access token is a signed JWT the server never stores, so before this row existed there was no way to stop honouring an issued one early: logout and revocation could only retire the refresh token, and the bearer kept working until it expired. The guard now resolves this row on every request, so revoking it, or its absolute expiry passing, stops an already-issued access token on the next call. The refresh token is subordinate — it belongs to a session and holds only rotation state.

type AuthSessionStore

type AuthSessionStore interface {
	// CreateSession opens a session and returns its public id, which becomes the
	// `sid` claim of the access token and the session_id of the first refresh
	// token.
	CreateSession(ctx context.Context, in NewAuthSession) (sid string, err error)

	// ActiveSession returns the session if it exists, is not revoked, and has not
	// passed its absolute expiry; otherwise ErrSessionInactive. This is the
	// per-request authority check the guard makes.
	ActiveSession(ctx context.Context, sid string, now time.Time) (AuthSession, error)

	// TouchSession records that the session was used, at most once per the store's
	// own throttle so an active session does not mean a write per request. A
	// missing or inactive session is not an error: touching something already
	// gone is a no-op.
	TouchSession(ctx context.Context, sid string, now time.Time) error

	// RevokeSession revokes one session and its refresh tokens, returning how many
	// sessions it retired (0 or 1). An unknown or already-revoked session is not
	// an error.
	RevokeSession(ctx context.Context, sid string, now time.Time) (int64, error)

	// RevokeUserSessions revokes every live session the user has, and their
	// refresh tokens, returning how many sessions it retired. This is what "sign
	// them out everywhere" and disabling an account do to the credential.
	RevokeUserSessions(ctx context.Context, userID string, now time.Time) (int64, error)

	// CountUserSessions counts the user's live sessions.
	CountUserSessions(ctx context.Context, userID string, now time.Time) (int, error)

	// ListUserSessions returns the user's live sessions as safe metadata, newest
	// first, so an administrator can revoke one device rather than all of them.
	ListUserSessions(ctx context.Context, userID string, now time.Time) ([]AuthSession, error)
}

AuthSessionStore owns the lifecycle of the durable session record.

Revocation cascades: revoking a session also retires the refresh tokens that belong to it, so the whole login dies at once rather than leaving a chain that still rotates.

type ExternalIdentity

type ExternalIdentity struct {
	// ID is the public identifier of the link, the handle an admin unlink names.
	ID     string
	UserID string
	Issuer string
	// Subject is the IdP's stable `sub` for this account.
	Subject string
	// LastSeenEmail and LastSeenName are the attributes from the most recent
	// successful sign-in, kept so Portal administration can surface drift from
	// the local account without rewriting it.
	LastSeenEmail string
	LastSeenName  string
	LastLoginAt   time.Time
	CreatedAt     time.Time
}

ExternalIdentity links a BuildMax account to one verified identity at an external IdP. The identity key is the exact OIDC (issuer, subject) pair, both case-sensitive protocol values; email and name are attributes captured for display and reconciliation, never identity. A changed email can never move a link to another account. See docs/design/enterprise-identity-and-access.md §5.

type ExternalIdentityStore

type ExternalIdentityStore interface {
	// IdentityBySubject returns the link for one (issuer, subject), or nil when
	// none exists. This is §5.2 rule 1.
	IdentityBySubject(ctx context.Context, issuer, subject string) (*ExternalIdentity, error)

	// IdentityByUserAndIssuer returns the account's link for one issuer, or nil.
	// It is what tells rule 3 (link an unlinked account) from rule 4 (that
	// account already has a different subject at this issuer — refuse).
	IdentityByUserAndIssuer(ctx context.Context, userID, issuer string) (*ExternalIdentity, error)

	// ListUserIdentities returns every link an account has, newest first.
	ListUserIdentities(ctx context.Context, userID string) ([]ExternalIdentity, error)

	// LinkExisting links an existing account to a subject and records the link in
	// one transaction. It returns ErrIdentityConflict if either uniqueness
	// constraint rejects the insert, so a concurrent first login cannot produce a
	// second link.
	LinkExisting(ctx context.Context, in LinkIdentity) (*ExternalIdentity, error)

	// CreateUserWithIdentity creates the account, its personal Space, the owner
	// membership, and the identity link atomically, recording the creation and
	// the link in the same transaction. It returns ErrIdentityConflict or
	// ErrEmailExists when a concurrent first login won the race.
	CreateUserWithIdentity(ctx context.Context, in ProvisionUser) (*User, *ExternalIdentity, error)

	// UnlinkIdentity deletes one link and records the deletion in one
	// transaction, only while the account is disabled. It returns
	// ErrUnlinkRequiresDisabled when the account is enabled and
	// ErrIdentityNotFound when the link is not the user's.
	UnlinkIdentity(ctx context.Context, in UnlinkIdentity) error

	// UpdateLastSeen refreshes the display attributes and last-login time after a
	// successful sign-in. It is best-effort: a missing link is not an error.
	UpdateLastSeen(ctx context.Context, issuer, subject string, seen SeenClaims, now time.Time) error
}

ExternalIdentityStore owns the link table and the atomic transactions that create, link, and remove a link along with the account it creates and the audit row that records it.

type LinkIdentity

type LinkIdentity struct {
	UserID  string
	Issuer  string
	Subject string
	Seen    SeenClaims
}

LinkIdentity describes linking an existing account to a subject on a first verified sign-in whose email matched that account (the migration path for operator-created accounts, §5.2 rule 3).

type LoginCodeStore

type LoginCodeStore interface {
	// CreateLoginCode issues a single-use code for userID and returns the
	// plaintext, which is never stored and cannot be recovered afterwards.
	CreateLoginCode(ctx context.Context, userID string, ttl time.Duration) (plaintext string, expiresAt time.Time, err error)

	// ConsumeLoginCode redeems a code that was issued to userID. A code that
	// is unknown, already used, expired, or issued to somebody else returns
	// (false, nil) — the caller cannot tell which, and neither can an
	// attacker. Redemption is atomic: concurrent calls with the same code
	// produce exactly one winner.
	//
	// The account is named by the caller rather than reported back, so that a
	// code submitted with the wrong address is left untouched. A redemption
	// that spent the code first and checked the account afterwards burned it
	// on a typo, and the person retrying with the right address was then
	// refused for a reason nobody could see.
	ConsumeLoginCode(ctx context.Context, plaintext, userID string, now time.Time) (redeemed bool, err error)
}

LoginCodeStore issues and redeems single-use login codes.

This is BuildMax's answer to having no mail channel: an operator issues a code out of band (`buildmax-server user login-code`) and delivers it however they already talk to the person.

It is not the everyday credential — a password is. A code is what claims a new account and what recovers a forgotten password, which is why it is single-use and short-lived: it exists to be spent once, on the way to setting a password.

type NewAuthSession

type NewAuthSession struct {
	UserID            string
	Platform          string
	AuthMethod        string
	AbsoluteExpiresAt time.Time
}

NewAuthSession describes a session to open.

type NewRefreshToken

type NewRefreshToken struct {
	UserID string
	// SessionID names one login chain. Every rotation keeps it, so revoking a
	// session retires the whole chain rather than one link of it.
	SessionID string
	// Platform records which surface logged in ("portal", "cli", "desktop").
	// It is a label for the operator reading the session list, not something
	// the server enforces.
	Platform string
	TTL      time.Duration
}

NewRefreshToken describes a token to issue.

type PasswordStore

type PasswordStore interface {
	// PasswordHash returns the stored hash for userID, or "" when the account
	// has no password and can only sign in with a login code.
	PasswordHash(ctx context.Context, userID string) (string, error)
	// SetPassword stores an already-hashed password. Hashing belongs to the
	// caller — this interface must not be a place where a plaintext password
	// can be passed by mistake.
	SetPassword(ctx context.Context, userID, encodedHash string, setAt time.Time) error
}

PasswordStore reads and writes the one credential a person chose themselves.

It is deliberately separate from UserStore. A password hash is the only value in the system whose exposure would reach beyond BuildMax — people reuse passwords — so it is fetched only by the code that verifies a login, and never rides along on a User that some handler might serialize.

type ProvisionUser

type ProvisionUser struct {
	Email     string
	Name      string
	QuotaTier string
	Issuer    string
	Subject   string
}

ProvisionUser describes a just-in-time account to create atomically with its identity link (§5.2 rule 5). Email is the verified address the domain allow-list already admitted; the caller owns that check.

type RefreshTokenStore

type RefreshTokenStore interface {
	// CreateRefreshToken issues a token and returns the plaintext, which is
	// never stored — the row holds a hash, so a database backup yields no
	// usable credentials.
	CreateRefreshToken(ctx context.Context, in NewRefreshToken) (plaintext string, expiresAt time.Time, err error)

	// RotateRefreshToken exchanges plaintext for a fresh token in the same
	// session, spending the presented one.
	//
	// Within grace of having been spent, a token may be exchanged again. That
	// window is not a concession to sloppy clients: BuildMax's CLI and Desktop
	// share one credentials file across independent processes, and two of them
	// refreshing at the same moment is normal rather than suspicious. Both
	// receive a usable token; both stay in the same session.
	//
	// Past the grace window a spent token means ErrRefreshTokenReused, and the
	// whole session is revoked first — logging out the legitimate holder is the
	// correct response when a credential may be in two hands. That error comes
	// back with UserID and SessionID populated and Plaintext empty, so the
	// caller can record what was revoked.
	RotateRefreshToken(ctx context.Context, plaintext string, now time.Time, ttl, grace time.Duration) (RotatedRefreshToken, error)

	// RevokeRefreshTokenSession revokes the refresh tokens the token belongs to
	// and reports whose session it was, so the caller can revoke the session
	// record too. An unknown token is not an error: logging out something already
	// gone is a success.
	RevokeRefreshTokenSession(ctx context.Context, plaintext string, now time.Time) (userID, sessionID string, err error)

	// DeleteExpiredRefreshTokens removes rows that can no longer be exchanged.
	DeleteExpiredRefreshTokens(ctx context.Context, before time.Time) (int64, error)
}

RefreshTokenStore issues, rotates, and revokes the stored half of a login.

Rotation is what makes a stolen refresh token detectable: each exchange spends the presented token and hands back a new one, so the same token appearing twice means two holders. See RotateRefreshToken for what the store does about that, and why a short grace window has to exist.

type RotatedRefreshToken

type RotatedRefreshToken struct {
	UserID    string
	SessionID string
	Plaintext string
	ExpiresAt time.Time
}

RotatedRefreshToken is the result of exchanging one refresh token for the next. Plaintext is returned once and never recoverable afterwards.

type SeenClaims

type SeenClaims struct {
	Email string
	Name  string
}

SeenClaims are the display attributes captured from a verified sign-in. They are snapshotted, not authoritative: the account handle stays user.email.

type SystemGrant

type SystemGrant struct {
	ID     string `json:"id"`
	UserID string `json:"user_id"`
	Role   string `json:"role"`
	// GrantedBy is the user_id of the admin who made the grant, or
	// AuditActorOperator when it came from the operator command, which runs
	// with database credentials and no signed-in identity. It is deliberately
	// the same string the matching audit event carries in ActorID: one act
	// should not have two names across two tables.
	GrantedBy string    `json:"granted_by"`
	GrantedAt time.Time `json:"granted_at"`
	// RevokedAt is nil while the grant is active. Revoking sets it rather than
	// deleting the row: who held authority and when is the question an
	// investigation asks, and a deleted row cannot answer it.
	RevokedAt *time.Time `json:"revoked_at,omitempty"`
}

SystemGrant is one deployment-scoped authority held by one user.

It is a row rather than a flag on User because a flag has no granting actor, no timestamp, and no history — and those three are the point. Authority that cannot be attributed or revoked is the thing this model exists to avoid.

func (SystemGrant) Active

func (g SystemGrant) Active() bool

Active reports whether the grant is currently in force.

type SystemGrantStore

type SystemGrantStore interface {
	// ActiveSystemRoles returns the roles userID currently holds, empty for
	// almost every caller. It is on the path of every authenticated admin
	// request, so it must stay a single indexed read.
	ActiveSystemRoles(ctx context.Context, userID string) ([]string, error)
	// ListSystemGrants returns grants newest first. includeRevoked adds the
	// retired ones, which is how the trail of who held authority is read.
	ListSystemGrants(ctx context.Context, includeRevoked bool) ([]SystemGrant, error)
	// GrantSystemRole grants role to userID. It returns ErrSystemGrantExists
	// when an active grant is already there, so a caller can report "already
	// an admin" rather than silently creating a second row.
	GrantSystemRole(ctx context.Context, userID, role, grantedBy string, now time.Time) (*SystemGrant, error)
	// RevokeSystemRole revokes userID's active grant for role and reports
	// whether one was found. Revoking an absent grant is not an error: the end
	// state is what was asked for.
	//
	// When keepLastHolder is true it refuses with ErrSystemGrantLastHolder
	// rather than remove the deployment's last effective holder, and it decides
	// and revokes in one atomic step so two concurrent revokes cannot both pass
	// the check and leave the role with nobody. When false — the operator shell,
	// which can undo a lockout — it revokes unconditionally.
	RevokeSystemRole(ctx context.Context, userID, role string, now time.Time, keepLastHolder bool) (bool, error)
	// CountActiveSystemGrants counts the effective holders of role: an active
	// grant on an account that is not disabled. A disabled account cannot
	// authorize a request, so it cannot be the holder that keeps the deployment
	// reachable — see docs/design/system-administration.md section 6.
	CountActiveSystemGrants(ctx context.Context, role string) (int, error)
}

SystemGrantStore persists deployment-scoped role grants.

type UnlinkIdentity

type UnlinkIdentity struct {
	UserID     string
	IdentityID string
	// ActorID is the administrator performing the unlink, for the atomic audit
	// row. Empty records the action against the system actor.
	ActorID string
}

UnlinkIdentity describes an administrator removing one link. The actor is the admin, recorded atomically with the deletion. Unlinking is permitted only while the account is disabled; the store enforces that inside the transaction so the check cannot race the delete.

type User

type User struct {
	ID                string     `json:"id"`
	Email             string     `json:"email"`
	Name              string     `json:"name"`
	QuotaTier         string     `json:"quota_tier,omitempty"`
	LastLoginAt       *time.Time `json:"last_login_at,omitempty"`
	LastLoginPlatform *string    `json:"last_login_platform,omitempty"`
	CreatedAt         time.Time  `json:"created_at"`
	// HasPassword reports whether this account can sign in with a password. The
	// hash itself never travels on this struct — see PasswordStore — so that no
	// handler can serialize it into a response by accident.
	HasPassword bool `json:"has_password"`
	// DisabledAt is nil for an ordinary account. Non-nil means every credential
	// this account holds is refused: password, login code, refresh token, the
	// access token it already has, and its webhook keys. Disabling is not
	// deletion — nothing is removed, and enabling reverses the state and
	// nothing else. See docs/design/system-administration.md section 8.
	DisabledAt *time.Time `json:"disabled_at,omitempty"`
}

User is the user model. JSON uses snake_case per project convention. Internal numeric ID is retained for compatibility but is not part of the public API.

func (User) Disabled

func (u User) Disabled() bool

Disabled reports whether the account is currently refused.

type UserFilter

type UserFilter struct {
	// Query matches the email as a substring.
	Query string
	// Disabled, when set, keeps only disabled (true) or only enabled (false)
	// accounts.
	Disabled *bool
	// HasPassword, when set, keeps only accounts that have set a password (true)
	// or have not (false) — the latter is who still needs a login code.
	HasPassword *bool
	// SystemRole, when non-empty, keeps only accounts holding that role as an
	// active grant.
	SystemRole string
	// Platform, when non-empty, keeps only accounts whose last login was on it.
	// An account that never logged in is excluded.
	Platform string
	// LastLoginAfter and LastLoginBefore, when set, bound the last-login time to
	// [after, before). An account that never logged in is excluded by either
	// bound, since it has no time to compare.
	LastLoginAfter  *time.Time
	LastLoginBefore *time.Time
}

UserStore looks up users by email and creates new users. UserFilter narrows a ListUsers result. A zero value matches every account; each set field is an AND.

type UserStore

type UserStore interface {
	// UserByEmail matches the address without regard to case, and returns
	// (nil, nil) when nobody has it. Login resolves the account this way and
	// compares nothing afterwards, so a case-sensitive implementation would
	// refuse people whose address is stored in another case.
	UserByEmail(ctx context.Context, email string) (*User, error)
	// GetUser returns the user by user_id, or (nil, nil) when not found.
	GetUser(ctx context.Context, userID string) (*User, error)
	// CreateUser creates a user with the given email. defaultQuotaTier is applied when non-empty. Returns ErrEmailExists if the email is already registered.
	CreateUser(ctx context.Context, email string, defaultQuotaTier string) (*User, error)
	// UpdateLoginMeta records the last login timestamp and platform for the user.
	UpdateLoginMeta(ctx context.Context, userID string, loginAt time.Time, platform string) error
	// ListUsers returns accounts newest first with the total count of accounts
	// the filter matched (not the page size), so a caller can page through them.
	ListUsers(ctx context.Context, filter UserFilter, limit, offset int) ([]User, int, error)
	// SetUserDisabled disables the account at the given time, or enables it
	// when disabledAt is nil. Returns ErrUserNotFound when there is no such
	// account.
	//
	// Disabling refuses with ErrSystemGrantLastHolder when the account is the
	// last effective holder of a system role: a disabled account cannot
	// authorize a request, so disabling the last one would leave the deployment
	// with nobody able to operate it. The check and the disable are one atomic
	// step so a concurrent grant revoke cannot slip between them.
	SetUserDisabled(ctx context.Context, userID string, disabledAt *time.Time) error
}

type UserWebhookKey

type UserWebhookKey struct {
	ID        string    `json:"id"`
	UserID    string    `json:"user_id"`
	KeyHash   string    `json:"-"` // SHA256 hex of plaintext key
	Name      string    `json:"name,omitempty"`
	CreatedAt time.Time `json:"created_at"`
}

UserWebhookKey is a webhook API key for a user. Plaintext key is returned only at creation; only key_hash is stored. JSON uses snake_case per project convention.

type UserWebhookKeyStore

type UserWebhookKeyStore interface {
	// CreateKey creates a new webhook key for the user. Returns plaintext key (e.g. whsec_...) and key_id. Caller must store plaintext securely; it is not persisted.
	CreateKey(ctx context.Context, userID, name string) (plaintextKey, keyID string, err error)
	// GetUserIDByKey looks up the user_id for the given plaintext key. Returns empty string if not found.
	GetUserIDByKey(ctx context.Context, plaintextKey string) (userID string, err error)
	// ListKeys returns key metadata for the user (no plaintext).
	ListKeys(ctx context.Context, userID string) ([]WebhookKeyMeta, error)
	// RevokeKey deletes the key by keyID if it belongs to the user.
	RevokeKey(ctx context.Context, userID, keyID string) error
}

UserWebhookKeyStore provides per-user webhook API key persistence. Keys are stored by hash; plaintext is returned only from CreateKey.

type WebhookKeyMeta

type WebhookKeyMeta struct {
	KeyID     string    `json:"key_id"`
	Name      string    `json:"name,omitempty"`
	CreatedAt time.Time `json:"created_at"`
}

WebhookKeyMeta is key metadata returned by ListKeys (no plaintext).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL