identity

package
v0.2.0-alpha.2 Latest Latest
Warning

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

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

Documentation

Index

Constants

View Source
const (
	AccessTokenTTLDefault       = 7 * 24 * time.Hour
	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 (
	// SystemRoleAdmin may manage accounts, read deployment status, and search
	// the audit trail across teams. It grants no access to any team's issues,
	// conversations, artifacts, files, or run traces: those stay behind team
	// 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 team. 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 (
	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 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 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 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 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 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 session the token belongs to and
	// reports whose it was. 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)

	// RevokeSession revokes every live token in one session and returns how
	// many it retired.
	RevokeSession(ctx context.Context, sessionID string, now time.Time) (int64, error)

	// RevokeUserSessions revokes every live session the user has and returns
	// how many tokens it retired. This is what "sign them out everywhere"
	// means, and it is the strongest thing disabling an account can do to a
	// credential the server actually stores.
	RevokeUserSessions(ctx context.Context, userID string, now time.Time) (int64, error)

	// CountUserSessions counts the user's live sessions — distinct login
	// chains, not tokens, since a chain is what a person would recognise as
	// "signed in on my laptop".
	CountUserSessions(ctx context.Context, userID string, now time.Time) (int, 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 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 the active grant and reports whether one was
	// found. Revoking an absent grant is not an error: the end state is what
	// was asked for.
	RevokeSystemRole(ctx context.Context, userID, role string, now time.Time) (bool, error)
	// CountActiveSystemGrants counts live grants for role. It is what the API
	// checks before revoking the last one — see
	// docs/design/system-administration.md section 6.
	CountActiveSystemGrants(ctx context.Context, role string) (int, error)
}

SystemGrantStore persists deployment-scoped role grants.

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 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. A non-empty
	// query filters on email as a substring.
	ListUsers(ctx context.Context, query string, 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.
	SetUserDisabled(ctx context.Context, userID string, disabledAt *time.Time) error
}

UserStore looks up users by email and creates new users.

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