store

package
v1.35.0 Latest Latest
Warning

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

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

Documentation

Overview

Package store is the SQLite-backed home of the users table, the schema migration engine, and the user lifecycle every gogo app shares. The driver is modernc.org/sqlite (pure Go), so a binary built on gogo stays CGO-free.

An application creates its own domain tables through its own migrations, applied against the same handle, and cascades the user lifecycle into them through an OwnershipModel. The store enforces the identity invariants itself, so no API path can bypass them, and it names every refusal as a sentinel: see docs/design.md, FR-4.3 and FR-10.1.

Index

Constants

View Source
const (
	// PurposeEmailVerify proves that the address on an account reaches the person
	// holding the link.
	PurposeEmailVerify = "email_verify"
	// PurposeSignupValidate proves a self-service applicant's address.
	PurposeSignupValidate = "signup_validate"
	// PurposeSignupReview carries an admin's approve/reject review of a signup.
	PurposeSignupReview = "signup_review"
	// PurposePasswordReset resets a forgotten password.
	PurposePasswordReset = "password_reset"
	// PurposeSignupFinish sets the password of an approved applicant, which creates
	// the account. It is deliberately not a password reset -- a reset acts on a user
	// that exists, this acts on a signup -- and the closed purpose set is what keeps
	// one from being spent as the other.
	PurposeSignupFinish = "signup_finish"
)

The purposes an action token may carry. The set is closed: a purpose the library does not know is refused at mint and at use, so a token minted for one flow can never be spent in another (FR-7.3).

View Source
const (
	LoginMaxFailures = 3
	LoginLockout     = 10 * time.Minute
)

Lockout: three strikes, ten minutes. A password is the one credential worth guessing -- a 256-bit token is not -- so it is the one that needs a brake.

Two properties, both enforced above this file (FR-5.6):

  • The lock is invisible. A locked account answers a sign-in exactly as a wrong password does, and an argon2 hash (real or dummy) runs either way, so the login form is no oracle for which accounts exist or which are under attack.
  • The lock binds passwords only. A PAT and a client certificate authenticate a locked account normally, so nobody can lock an admin out of their own service by spraying passwords at a public username.

It is per account, and composes with the per-client rate limiter rather than replacing it.

View Source
const (
	// SignupPending is an application whose address has not been proved.
	SignupPending = "pending"
	// SignupValidated is one whose address has been proved, and which is now
	// waiting on an admin. The sweeper never touches these: it is not stale, it is
	// queued (FR-9.8).
	SignupValidated = "validated"
	// SignupApproved is one an admin said yes to (or that was auto-approved with
	// review off). It is waiting for its applicant to choose a password.
	SignupApproved = "approved"
	// SignupRejected is one an admin said no to. It lingers for a grace period and
	// is then swept.
	SignupRejected = "rejected"
)

The states a signup passes through. The set is closed, and every transition is guarded in this file: pending -> validated -> approved|rejected -> (a users row).

View Source
const (
	RoleUser  = "user"
	RoleAdmin = "admin"
)

Roles. The non-admin role is "user"; a client certificate can only ever produce the non-admin role (a certificate never grants admin).

View Source
const (
	TOTPOff      = "off"      // no second factor, none asked for
	TOTPEnabled  = "enabled"  // required; the next password login enrolls
	TOTPEnrolled = "enrolled" // a confirmed secret; every password login is challenged
	TOTPDisabled = "disabled" // a confirmed secret, prompt suspended by an admin
)

The four second-factor states. The set is closed: a value the library does not know is refused on write.

View Source
const AnonymousName = "anonymous"

AnonymousName is the reserved name of the shared account that backs the GOGO_ANONYMOUS_SUBNET grant. It is seeded, never deletable, and cannot be claimed by any other user; it holds no credential, so it is only ever reached via the grant.

View Source
const DriverName = "sqlite-gogo"

DriverName is the database/sql driver gogo opens SQLite with. An application that opens a second database of its own -- a cache, a rollup, anything outside the shared schema -- can name it here to have those statements counted too:

db, err := sql.Open(store.DriverName, dsn)
View Source
const LegacyTokenLabel = "legacy"

LegacyTokenLabel names the PAT that carries an account's single pre-PAT bearer token (FR-5.2). CreateUser mints one, because the create API returns a token exactly once, and a regenerate replaces exactly this one. Its owner may revoke it once they hold another.

View Source
const MaxPrefValue = 4096

MaxPrefValue bounds one stored value.

Variables

View Source
var ErrBadPrefValue = errors.New("store: that is not a legal value for this preference")

ErrBadPrefValue is returned when a key's validator refuses the value, or it is longer than MaxPrefValue.

View Source
var ErrBadSettingValue = errors.New("store: that is not a legal value for this setting")

ErrBadSettingValue is returned when a key's validator refuses the value, or it is longer than MaxPrefValue.

View Source
var ErrEmailChanged = errors.New("store: the address has changed since that link was sent")

ErrEmailChanged is returned when a validation is claimed for an address the account no longer holds -- a verification link followed after the address moved. Nobody proved the new address, so the claim is refused.

View Source
var ErrEmailTaken = errors.New("store: that e-mail address is already in use")

ErrEmailTaken is returned when an address is already held by another account. One address belongs to one account: verification and recovery are undefined if two accounts may claim one.

View Source
var ErrLastAdmin = errors.New("store: cannot remove the last admin")

ErrLastAdmin is returned when deleting or demoting the final admin user.

View Source
var ErrMergeIntoSelf = errors.New("store: cannot merge a user into itself")

ErrMergeIntoSelf is returned when a user is merged into itself.

View Source
var ErrNameTaken = errors.New("store: that name is taken")

ErrNameTaken means the username is already an account, or already spoken for by a live signup. Unlike a taken address, this one is reported to the applicant (FR-9.3).

View Source
var ErrNoEmail = errors.New("store: cannot validate an empty e-mail address")

ErrNoEmail is returned when an empty address is marked validated.

View Source
var ErrNoPassword = fmt.Errorf("store: that account has no password")

ErrNoPassword is returned when a password operation names an account that has none, or an empty hash.

View Source
var ErrNoSecret = errors.New("store: a second factor needs a secret")

ErrNoSecret is returned when an enrollment is begun with an empty secret.

View Source
var ErrNotCertOnly = errors.New("store: source is not a certificate-only user")

ErrNotCertOnly is returned when a merge source is not a cert-only user.

View Source
var ErrNotFound = errors.New("store: not found")

ErrNotFound is returned when a keyed row does not exist.

View Source
var ErrPATExpired = errors.New("store: that token has expired")

ErrPATExpired is returned when a presented token is genuine but its lifetime has run out. The resolver turns it into the same 401 an unknown token gets; the difference shows only in the metric and the log line.

View Source
var ErrPATLabel = errors.New("store: a token needs a short, printable label")

ErrPATLabel is returned for an empty or implausible label.

View Source
var ErrPrefRegistered = errors.New("store: that preference is already registered")

ErrPrefRegistered is returned when a key is registered twice.

View Source
var ErrReservedName = errors.New("store: that name is reserved")

ErrReservedName is returned when a create or rename would use a reserved name.

View Source
var ErrReservedUser = errors.New("store: that user is reserved and cannot be removed")

ErrReservedUser is returned when an operation targets a reserved account that must not be removed.

View Source
var ErrSettingRegistered = errors.New("store: that setting is already registered")

ErrSettingRegistered is returned when a key is registered twice.

View Source
var ErrSignupExists = errors.New("store: a signup for that address is already in flight")

ErrSignupExists means an application for this address is already in flight and past the point where a fresh one would replace it. The caller must not say so: see CreateSignup.

View Source
var ErrSignupState = errors.New("store: that signup has already been decided")

ErrSignupState means the signup is not in the state this transition needs -- an approval of something already rejected, a second decision on a decided one. It is how the second of two admins is told who won, rather than overruling them (FR-9.5).

View Source
var ErrTOTPEnrolled = errors.New("store: that account already has a second factor")

ErrTOTPEnrolled is returned when enrollment is begun on an account that already holds a confirmed secret. Clear it first, so two live secrets never exist.

View Source
var ErrTOTPReplay = errors.New("store: that code has already been used")

ErrTOTPReplay is returned when a code, or a step, has already been spent.

View Source
var ErrTOTPState = errors.New("store: that is not a legal second-factor state")

ErrTOTPState is returned for an unknown state, or a transition that is not legal from the account's current one.

View Source
var ErrTargetHasCert = errors.New("store: target already has a certificate")

ErrTargetHasCert is returned when a merge target already holds a certificate.

View Source
var ErrTokenExpired = errors.New("store: that link has expired")

ErrTokenExpired is returned when a token's lifetime has run out.

View Source
var ErrTokenInvalid = errors.New("store: that link is not valid")

ErrTokenInvalid is returned when a presented token is unknown, or was minted for a different purpose. The two are one error deliberately: a caller must not learn which of them it was.

View Source
var ErrTokenUsed = errors.New("store: that link has already been used")

ErrTokenUsed is returned when a token has already been spent. Single use is enforced in a transaction, so a link raced twice succeeds exactly once.

View Source
var ErrUnknownPref = errors.New("store: no such preference")

ErrUnknownPref is returned for a key that is not registered.

View Source
var ErrUnknownSetting = errors.New("store: no such setting")

ErrUnknownSetting is returned for a key that is not registered.

Functions

func EnumPref added in v1.31.0

func EnumPref(allowed ...string) func(string) error

EnumPref builds a validator admitting exactly the given values.

func Exists

func Exists(path string) bool

Exists reports whether a database file already exists at path.

func LibSchemaHead

func LibSchemaHead() string

LibSchemaHead returns the latest migration version the library embeds, without opening a database. An app combines it with its own head for `-version`.

func NormalizeEmail added in v1.2.0

func NormalizeEmail(s string) (string, error)

NormalizeEmail trims, lowercases, and validates an address. The empty string is valid and normalizes to empty ("no address"). Otherwise the address must be a bare RFC 5322 addr-spec -- no display name, no angle brackets, no comment, no whitespace -- with a dotted domain, at most 254 bytes in total and 64 in the local part. See docs/design.md, FR-4.3.

The whole address is case-folded, not just the domain, which is what lets "one address, one account" be a plain UNIQUE index rather than a functional one.

The display name is refused, not stripped: "Anyone <a@b.c>" parses, and accepting it would let attacker-chosen text ride in the stored value and into the From or To of a mail the library later sends.

func NormalizePersonName added in v1.2.0

func NormalizePersonName(s string) (string, error)

NormalizePersonName trims a first or last name. Empty is valid. A name is at most 64 runes and carries no control character; it is otherwise unrestricted.

func ParseSignupReviewSubject added in v1.2.0

func ParseSignupReviewSubject(subject string) (signupID, adminID int64, ok bool)

ParseSignupReviewSubject reads back what SignupReviewSubject wrote.

func ParseSignupSubject added in v1.2.0

func ParseSignupSubject(subject string) (int64, bool)

ParseSignupSubject reads back what SignupSubject wrote.

func ParseUserEmailSubject added in v1.2.0

func ParseUserEmailSubject(subject string) (id int64, email string, ok bool)

ParseUserEmailSubject reads back what UserEmailSubject wrote.

func SignupReviewSubject added in v1.2.0

func SignupReviewSubject(signupID, adminID int64) string

SignupReviewSubject names one application and one admin: "signup:7/admin:3".

The admin is in the subject for two reasons. Minting invalidates the outstanding tokens for the same subject, so a subject shared by every admin would mean only the last one mailed could act. And it records who decided: the review page needs no session (FR-9.5).

func SignupSubject added in v1.2.0

func SignupSubject(id int64) string

SignupSubject is the subject of a token that acts on one application: "signup:7". It names no address: unlike a verification or a reset, the token claims nothing about an existing account's address. The address lives in the row.

func UserEmailSubject added in v1.2.0

func UserEmailSubject(id int64, email string) string

UserEmailSubject is the subject of a token that acts on one account and one exact address: "user:42/pim@ipng.ch".

Binding the address, not just the account, is what makes a stale link safe: a token naming only the account would validate whatever address the account held when the link was finally followed -- an address nobody proved. A link outstanding across a change of address names an address the account no longer holds, and the consumer refuses it (FR-7.4).

func ValidateSetting added in v1.32.0

func ValidateSetting(k SettingKey, value string) error

ValidateSetting checks a value against a key's Kind and then against the key's own Validate. It is exported because an app registering its own settings validates the same way the library does, and because the settings screen's error text comes from here.

An empty value is admitted by every kind: it is how a setting says "not configured", and the loader's own rules -- mail needs a From address once a host is set -- catch the combinations a per-key check cannot see.

Types

type ActionToken added in v1.2.0

type ActionToken struct {
	ID        int64
	Purpose   string
	Subject   string
	Created   int64
	ExpiresAt int64
	UsedAt    *int64
}

ActionToken is one minted, single-use secret: what it is for, what it acts on, when it dies, and whether it has been spent. It never carries the plaintext -- that exists in exactly one place in the system, the body of one mail (FR-7.2).

func (*ActionToken) Spent added in v1.2.0

func (t *ActionToken) Spent() bool

Spent reports whether the token has been consumed.

type NopOwnership

type NopOwnership struct{}

NopOwnership is the ownership model for an app that keys ownership by immutable user id and relies on an ON DELETE SET NULL foreign key: every hook is a no-op. It is the default when Open is passed a nil model.

func (NopOwnership) Reassign

func (NopOwnership) Reassign(*sql.Tx, int64, int64, string, string) error

func (NopOwnership) Release

func (NopOwnership) Release(*sql.Tx, int64, string) error

func (NopOwnership) Rename

func (NopOwnership) Rename(*sql.Tx, int64, string, string) error

type OwnershipModel

type OwnershipModel interface {
	// Reassign moves every resource owned by the source user to the target user,
	// during a certificate-into-token merge.
	Reassign(tx *sql.Tx, srcID, dstID int64, srcName, dstName string) error

	// Rename updates ownership references after a user is renamed.
	Rename(tx *sql.Tx, userID int64, oldName, newName string) error

	// Release detaches a deleted user's resources.
	Release(tx *sql.Tx, userID int64, name string) error
}

OwnershipModel connects the user lifecycle to an application's resource tables. The library manages users -- create, rename, disable, delete, and merge a certificate identity into a token identity -- but only the app knows how a resource records its owner. Each method runs inside the library's own transaction (tx), so an app must not commit or roll back; returning an error aborts the whole operation.

Two ownership models cover the family:

  • By immutable user id (an integer foreign key with ON DELETE SET NULL): Rename and Release are no-ops, and Reassign updates the id column.
  • By free-text owner name: Rename rewrites the name, Release is a no-op (rows go dormant, revived if the name returns), and Reassign rewrites the name from source to target.

type PAT added in v1.2.0

type PAT struct {
	ID        int64
	UserID    int64
	Label     string
	Created   int64
	ExpiresAt *int64 // nil = never
	LastUsed  *int64
}

PAT is one personal access token: what it is for, when it dies, and when it was last used. It never carries the secret -- only its SHA-256 hash is stored, so a stolen database yields no working credential (NFR-5).

func (*PAT) Expired added in v1.2.0

func (p *PAT) Expired() bool

Expired reports whether the token's lifetime has run out. Expiry is judged on read, so a token is dead the moment it expires whether or not anything has swept.

type PrefKey added in v1.31.0

type PrefKey struct {
	Key string
	// Default is what GetPref answers when the account has chosen nothing. It is not
	// passed through Validate.
	Default string
	// Validate refuses a value; nil admits anything within MaxPrefValue.
	Validate func(string) error
}

PrefKey is one preference this app admits.

type SettingKey added in v1.32.0

type SettingKey struct {
	Key  string
	Kind SettingKind
	// Default is the value a key reads as while it has no row. It is not passed through
	// Validate.
	Default string
	// Label and Help are what the screen shows. Label is a short noun phrase; Help is one
	// sentence, and says what a wrong value does rather than restating the label.
	Label string
	Help  string
	// Group collects related keys into one fieldset on the screen ("brand", "mail").
	Group string
	// Scope is the tab: empty is the library's, otherwise the app's slug.
	Scope string
	// Enum is the admissible set for KindEnum, and the options the screen offers.
	Enum []string
	// EnumLabels are what the screen shows for each Enum value, in the same order. Empty
	// shows the raw values, which is right for a vocabulary that already reads as English
	// ("light", "dark") and wrong for one that does not ("auto", "true", "false").
	EnumLabels []string
	// Restart says a change is not picked up until the process restarts, so the screen
	// can say so on the row rather than implying everything is live.
	Restart bool
	// Validate refuses a value the kind alone cannot. It runs after the kind's own check.
	Validate func(string) error
	// contains filtered or unexported fields
}

SettingKey is one setting this deployment admits.

The registry is what makes the settings screen generic: a new key needs an entry here and no frontend work at all.

func (SettingKey) Secret added in v1.32.0

func (k SettingKey) Secret() bool

Secret reports whether this key holds a credential. A secret key's value leaves the process only to the service it authenticates against: the API answers "set" or "unset" for it and never the value itself.

type SettingKind added in v1.32.0

type SettingKind string

SettingKind is what a setting holds. It decides which widget the settings screen renders and, for everything but KindString, what the built-in validator checks -- so a key's Validate only ever carries a rule its type cannot express.

const (
	KindString   SettingKind = "string"
	KindBool     SettingKind = "bool"
	KindInt      SettingKind = "int"
	KindFloat    SettingKind = "float"
	KindDuration SettingKind = "duration"
	KindEnum     SettingKind = "enum"
	KindEmail    SettingKind = "email"
	KindURL      SettingKind = "url"
	KindColors   SettingKind = "colors"
	KindSecret   SettingKind = "secret"
)

The kinds. KindSecret is a string that is never sent to a browser: see SettingKey.Secret, which it implies.

type Signup added in v1.2.0

type Signup struct {
	ID          int64
	Name        string
	FirstName   string
	LastName    string
	Email       string
	State       string
	Created     int64
	ValidatedAt *int64
	DecidedAt   *int64
	DecidedBy   *int64
	LastIP      string
}

Signup is one application for an account. It is deliberately not a User, and nothing in the library that walks users will find it (FR-9.2).

func (*Signup) Decided added in v1.2.0

func (s *Signup) Decided() bool

Decided reports whether an admin (or the auto-approval) has ruled on this.

type Store

type Store struct {
	// contains filtered or unexported fields
}

Store wraps the SQLite database handle, the app's migrations, and the app's ownership model.

func Open

func Open(path string, appMig fs.FS, own OwnershipModel) (*Store, error)

Open opens the SQLite database at path, creating the parent directory, the file, and the schema if absent. It applies the library's migrations and the app's own (appMig may be nil), ordered together by version. own may be nil, in which case NopOwnership is used.

A freshly created file is locked to 0600: it holds hashed credentials. Foreign keys are enforced; WAL keeps readers and the writer from blocking; a single open connection serializes writes, which is what the store's read-and-write-in-one- transaction guarantees rest on (NFR-3).

func (*Store) AppPref added in v1.32.0

func (s *Store) AppPref(key string) (string, error)

AppPref returns one setting: the stored value, or the registered default.

func (*Store) AppliedMigrations

func (s *Store) AppliedMigrations() []string

AppliedMigrations returns the migration versions applied during Open (empty if the database was already current).

func (*Store) AuthGeneration added in v1.25.0

func (s *Store) AuthGeneration() uint64

AuthGeneration is the current value. auth reads it when it grants an approval and again on every hit; a difference means something was revoked and the approval is void.

func (*Store) BeginTOTP added in v1.22.0

func (s *Store) BeginTOTP(userID int64, secret string) (*TOTP, error)

BeginTOTP writes a pending enrollment, replacing any earlier pending one, and moves the account to enabled. A confirmed secret is ErrTOTPEnrolled: clear it first, which is a step only the account (with its password) or an admin can take.

func (*Store) Bootstrapped added in v1.30.0

func (s *Store) Bootstrapped() (bool, error)

Bootstrapped reports whether anybody has claimed this install: whether an account exists that a person set up, as opposed to one the deployment conjured. It is what closes both bootstrap paths -- the interactive /setup flow and the GOGO_ADMIN_TOKEN seeding -- and nothing else consults it.

Two kinds of row are not a claim, for the same reason. The seeded anonymous account is a credential the configuration grants (FR-1.9). A certificate-only account is one the reverse proxy conjured by letting a CN through: nobody chose to create it, it carries no password and no token, and its holder cannot act as an admin anyway -- a certificate is capped at the non-admin role, twice over (FR-1.4). Letting either close the door would leave an install with no way to ever get an admin, which is the lockout /setup exists to prevent.

The consequence is deliberate and worth knowing: with GOGO_ADMIN_TOFU on and certificate auth in front, /setup stays open however many certificate users have been through, until somebody claims it. That is what TOFU is -- an open door until the first caller through it -- and it is why GOGO_ADMIN_TOFU is off by default.

func (*Store) BumpSessionEpoch added in v1.2.0

func (s *Store) BumpSessionEpoch(id int64) (int64, error)

BumpSessionEpoch invalidates every session of an account at once -- "sign out everywhere" -- without rotating GOGO_SESSION_SECRET, which would log out every account. It returns the new epoch, which the caller's own re-issued cookie must carry if they are to stay signed in (FR-5.5).

func (*Store) ClearPref added in v1.31.0

func (s *Store) ClearPref(userID int64, key string) error

ClearPref returns one key to its default.

func (*Store) Close

func (s *Store) Close() error

Close closes the underlying database.

func (*Store) CompleteSignup added in v1.2.0

func (s *Store) CompleteSignup(id int64, passwordHash string) (*User, error)

CompleteSignup turns an approved application into an account, in one transaction: the users row is written with the address already validated (the applicant proved it to reach the queue at all), the provenance is stamped on the account, and the signups row is deleted.

The name is checked again here, because an admin may have created an account with that name while the applicant was choosing a password, and the transaction that writes the row is the last guard against it.

func (*Store) ConfirmTOTP added in v1.22.0

func (s *Store) ConfirmTOTP(userID, step int64) (*User, error)

ConfirmTOTP marks a pending enrollment proved and moves the account to enrolled. step is the one that proved it, and is spent in the same transaction, so the code somebody just typed cannot be replayed at the login form.

func (*Store) ConsumeActionToken added in v1.2.0

func (s *Store) ConsumeActionToken(purpose, tokenHash string) (*ActionToken, error)

ConsumeActionToken resolves a presented hash and spends it, in one transaction. The store keeps a single open connection, so two callers racing one link produce exactly one success and one ErrTokenUsed: the link proves its claim exactly once (FR-7.3).

func (*Store) CountRecoveryCodes added in v1.22.0

func (s *Store) CountRecoveryCodes(userID int64) (int, error)

CountRecoveryCodes is how many unspent codes an account still holds.

func (*Store) CountUsers

func (s *Store) CountUsers() (int, error)

CountUsers returns the number of real user rows. The seeded anonymous account is excluded: it is a credential the configuration grants, not somebody's account.

func (*Store) CreateActionToken added in v1.2.0

func (s *Store) CreateActionToken(purpose, subject, tokenHash string, ttl time.Duration) (*ActionToken, error)

CreateActionToken records the hash of a freshly minted secret against a purpose and a subject, to expire after ttl. The caller mints the plaintext (auth.NewToken), hashes it (auth.Hash), and puts the plaintext in the mail: the store sees only the hash, exactly as with a bearer token.

Minting invalidates every outstanding token for the same purpose and subject, so at most one live credential exists per subject and a forwarded "resend" cannot be replayed. The last link wins (FR-7.3).

func (*Store) CreatePAT added in v1.2.0

func (s *Store) CreatePAT(userID int64, label, tokenHash string, expiresAt *int64) (*PAT, error)

CreatePAT issues a token for a user. The caller mints the plaintext (auth.NewToken) and hashes it (auth.Hash); the store sees only the hash, and the plaintext is shown to its owner exactly once.

expiresAt is a Unix time, or nil for a token that never expires.

func (*Store) CreateSignup added in v1.2.0

func (s *Store) CreateSignup(name, first, last, email, ip string) (*Signup, error)

CreateSignup records an application. The two refusals are deliberately asymmetric (FR-9.3):

  • A taken name is ErrNameTaken, and the caller says so plainly.
  • A taken address is ErrEmailTaken, and the caller must say nothing: it answers the applicant as it answers everybody, and mails the existing owner instead. Saying "that address is already registered" would turn a public form into an oracle for who has an account here.

An application for an address with a live, unproved signup replaces it: the last one wins. One already validated or approved is ErrSignupExists, and the caller stays just as quiet about that as about a taken address.

func (*Store) CreateUser

func (s *Store) CreateUser(name, role, tokenHash, certCN string, info ...UserInfo) (*User, error)

CreateUser inserts a user. tokenHash may be empty for a cert-only user; certCN may be empty for a token user. The name must be unique, and so must any address. info is optional: pass zero or one UserInfo.

A tokenHash becomes the account's legacy PAT (see pats.go): the create API returns a token exactly once, and that token has to authenticate.

func (*Store) DB

func (s *Store) DB() *sql.DB

DB exposes the underlying handle so an app can query its own tables.

func (*Store) DecideSignup added in v1.2.0

func (s *Store) DecideSignup(id, adminID int64, approve bool) (*Signup, error)

DecideSignup approves or rejects a validated application. adminID may be 0, which is the auto-approval that happens when user.signup_admin_review is off.

Only a validated application can be decided, and only once: two admins reading their mail at the same moment produce one decision and one ErrSignupState.

func (*Store) DeletePAT added in v1.2.0

func (s *Store) DeletePAT(userID, id int64) error

DeletePAT revokes one token. The user id is part of the key, not a filter applied afterwards: a caller who may act on one account can never revoke another account's token by guessing an id.

func (*Store) DeletePATs added in v1.2.0

func (s *Store) DeletePATs(userID int64) (int64, error)

DeletePATs revokes every token a user holds, and returns how many. It is the "revoke all" button.

func (*Store) DeleteSignup added in v1.2.0

func (s *Store) DeleteSignup(id int64) error

DeleteSignup removes an application outright, whatever state it is in. It is what "ignore" means: no account, no mail, no record, and the applicant is told nothing. It is deliberately not a rejection -- a rejection is a decision, and an applicant is told about a decision (FR-9.6).

func (*Store) DeleteUser

func (s *Store) DeleteUser(id int64) error

DeleteUser removes a user. The last enabled admin cannot be deleted. The app's OwnershipModel.Release runs in the same transaction to detach the user's resources.

func (*Store) GetAnonymousUser

func (s *Store) GetAnonymousUser() (*User, error)

GetAnonymousUser returns the seeded anonymous account, or ErrNotFound.

func (*Store) GetPref added in v1.31.0

func (s *Store) GetPref(userID int64, key string) (string, error)

GetPref returns one preference, or its default. An unregistered key is ErrUnknownPref.

func (*Store) GetPrefs added in v1.31.0

func (s *Store) GetPrefs(userID int64) (map[string]string, error)

GetPrefs returns every registered key with this account's choice, or the default. A stored key that is no longer registered is not returned.

func (*Store) GetSignup added in v1.2.0

func (s *Store) GetSignup(id int64) (*Signup, error)

GetSignup returns one application by id.

func (*Store) GetTOTP added in v1.22.0

func (s *Store) GetTOTP(userID int64) (*TOTP, error)

GetTOTP returns an account's enrollment, pending or confirmed.

func (*Store) GetUser

func (s *Store) GetUser(id int64) (*User, error)

GetUser returns one user by id.

func (*Store) GetUserByCertCN

func (s *Store) GetUserByCertCN(cn string) (*User, error)

GetUserByCertCN resolves a verified client-certificate CN to its user.

func (*Store) GetUserByEmail added in v1.2.0

func (s *Store) GetUserByEmail(email string) (*User, error)

GetUserByEmail returns the one account holding an address, normalizing it first so a caller may pass whatever a human typed. An empty or malformed address is ErrNotFound rather than an error: every stored address is normalized, so no account can hold one.

func (*Store) GetUserByName

func (s *Store) GetUserByName(name string) (*User, error)

GetUserByName returns one user by name.

func (*Store) InvalidateUserActionTokens added in v1.2.0

func (s *Store) InvalidateUserActionTokens(purpose string, userID int64) (int64, error)

InvalidateUserActionTokens spends every live token of one purpose that acts on one account, whatever address its subject names, and returns how many died. SetPassword calls it, so that setting a password kills the outstanding reset links in the same transaction that writes the hash (FR-8.5).

The subject match covers both shapes a user subject can take: "user:42" and "user:42/pim@ipng.ch". The address is deliberately not part of the match -- a link mailed to an address the account has since left must die with the rest.

func (*Store) ListPATs added in v1.2.0

func (s *Store) ListPATs(userID int64) ([]*PAT, error)

ListPATs returns a user's tokens, oldest first. No secret material: a token's value exists only where its owner put it after it was issued once.

func (*Store) ListReviewAdmins added in v1.2.0

func (s *Store) ListReviewAdmins() ([]*User, error)

ListReviewAdmins returns the admins a review request may be mailed to: enabled, and holding an address they have proved. Mailing an approve-this-account link to an unproved address would hand the power of approval to whoever squatted it -- the same rule, and the same reason, as recovery (FR-8.5, FR-9.5).

func (*Store) ListSignups added in v1.2.0

func (s *Store) ListSignups() ([]*Signup, error)

ListSignups returns every application, newest first. It is what the admin queue is drawn from, and it is deliberately unfiltered: a rejected application and an approved one nobody finished are both things an administrator wants to see.

func (*Store) ListUsers

func (s *Store) ListUsers() ([]*User, error)

ListUsers returns all users ordered by name.

func (*Store) MergeCertUser

func (s *Store) MergeCertUser(sourceID, targetID int64) error

MergeCertUser folds a certificate-only user (source) into an existing user (target), so one identity holds both a bearer token and a client certificate. In one transaction it cascades ownership through OwnershipModel.Reassign, moves the source's cert_cn onto the target, and deletes the source.

Guards, all enforced here so an API cannot bypass them: source and target must differ; source must be certificate-only (a cert_cn and no other credential, so a merge cannot destroy a token with the source row); target must not already hold a certificate; neither may be the anonymous account.

func (*Store) NonAdminRole

func (s *Store) NonAdminRole() string

NonAdminRole returns the configured non-admin role label.

func (*Store) NoteLoginFailure added in v1.2.0

func (s *Store) NoteLoginFailure(id int64) error

NoteLoginFailure records one failed password attempt, and locks the account on the third consecutive one. The read and the write are one transaction, so a burst of parallel guesses cannot slip past the third by racing the counter.

func (*Store) NoteLoginSuccess added in v1.2.0

func (s *Store) NoteLoginSuccess(id int64) error

NoteLoginSuccess clears the failure counter and any lock: a correct password ends the episode.

func (*Store) PasswordHash added in v1.2.0

func (s *Store) PasswordHash(id int64) (string, error)

PasswordHash returns the stored argon2id hash of an account, or ErrNoPassword when it has none. Passwords are argon2id where tokens are SHA-256, deliberately: a password is low-entropy and worth guessing (FR-5.3). This is the one place the hash leaves the store, and it goes to one caller: the login handler, which compares it and drops it.

func (*Store) PeekActionToken added in v1.2.0

func (s *Store) PeekActionToken(purpose, tokenHash string) (*ActionToken, error)

PeekActionToken resolves a presented hash without consuming it: it is what the page behind a mailed link uses to say what is about to happen. A mailed link must not act on a GET -- mail scanners follow every URL in a message -- so the POST behind the button on that page is what consumes (FR-7.5).

func (*Store) PrefKeyOf added in v1.31.0

func (s *Store) PrefKeyOf(key string) (PrefKey, bool)

PrefKeyOf returns a registered key, and whether it is one.

func (*Store) RegisterPref added in v1.31.0

func (s *Store) RegisterPref(k PrefKey) error

RegisterPref adds a key to this store's registry. Call it at startup. The registry is per-store, not package-level, so two stores in one process do not share it.

func (*Store) RegisterSetting added in v1.32.0

func (s *Store) RegisterSetting(k SettingKey) error

RegisterSetting adds a key to this store's settings registry. Call it at startup, before the configuration's second phase reads the table. The registry is per-store, not package-level, so two stores in one process do not share it.

func (*Store) RegisteredPrefs added in v1.31.0

func (s *Store) RegisteredPrefs() []PrefKey

RegisteredPrefs returns every registered key, ordered by name.

func (*Store) RegisteredSettings added in v1.32.0

func (s *Store) RegisteredSettings() []SettingKey

RegisteredSettings returns every registered key, ordered for display: the library's scope first, then each app scope, and within a scope the order they were registered in. The screen renders them in this order, so the order is part of the contract and is changed by reordering the slice that declares them.

func (*Store) SchemaVersion

func (s *Store) SchemaVersion() string

SchemaVersion returns the latest migration version the schema is at.

func (*Store) SeedAnonymousUser

func (s *Store) SeedAnonymousUser(enable bool) error

SeedAnonymousUser ensures the reserved anonymous account exists: a credential-less non-admin account, seeded disabled unless enable is true on first creation (GOGO_ANONYMOUS_POST=true). An existing account is left untouched, so an admin's later enable/disable choice survives restarts.

func (*Store) SetAppPref added in v1.32.0

func (s *Store) SetAppPref(key, value string) error

SetAppPref sets one key.

func (*Store) SetAppPrefs added in v1.32.0

func (s *Store) SetAppPrefs(patch map[string]*string) error

SetAppPrefs applies a patch in one transaction: a nil value deletes the row, which returns the key to the value it seeded from or to its default. Absent keys are left alone. The whole patch is validated before anything is written, so a screen that saves eight fields at once either saves all of them or changes nothing.

func (*Store) SetNonAdminRole

func (s *Store) SetNonAdminRole(role string)

SetNonAdminRole configures the non-admin role label. It must be called before any user is created or seeded. gogo.Run calls it from App.Role.

func (*Store) SetPassword added in v1.2.0

func (s *Store) SetPassword(id int64, hash string) (*User, error)

SetPassword stores a password hash and, in the same transaction:

  • stamps password_set_at;
  • bumps session_epoch, so every session opened with the old password dies;
  • clears the lockout, because whoever just proved the current password (or the admin who reset it) is not the attacker the lock was for;
  • invalidates every outstanding password-reset link for this account, so a link still sitting in a mailbox is dead. This lives here rather than in a handler, because a guard in one handler is a guard the next handler forgets -- and it must hold for /profile, for an admin's reset, and for recovery itself (FR-8.5).

The caller hashes (auth.HashPassword); the store never sees a plaintext password, exactly as it never sees a plaintext token.

func (*Store) SetPref added in v1.31.0

func (s *Store) SetPref(userID int64, key, value string) error

SetPref sets one key.

func (*Store) SetPrefDefault added in v1.32.0

func (s *Store) SetPrefDefault(key, def string) error

SetPrefDefault moves a registered key's default. It exists for exactly one case: the default of ui.theme is a deployment setting (ui.theme_default), and a setting can change while the process runs. Without this, an administrator who changes the default would change it only for accounts created by a future restart.

It does not touch a single stored row. An account that has chosen keeps its choice; this is what an account that has not chosen reads.

func (*Store) SetPrefs added in v1.31.0

func (s *Store) SetPrefs(userID int64, patch map[string]*string) error

SetPrefs applies a patch in one transaction: a nil value deletes the key, returning it to its default. Absent keys are left alone. The whole patch is validated before anything is written. The reserved anonymous account is shared and holds no preferences.

func (*Store) SetRecoveryCodes added in v1.22.0

func (s *Store) SetRecoveryCodes(userID int64, hashes []string) error

SetRecoveryCodes replaces every code an account holds. The caller hashes; the store never sees a plaintext code, exactly as it never sees a plaintext token.

func (*Store) SetTOTPState added in v1.22.0

func (s *Store) SetTOTPState(userID int64, state string) (*User, error)

SetTOTPState moves an account between the four states, and is the whole state machine. The legal moves, and what each does:

-> off       drop the secret and every recovery code
-> enabled   drop them too, and bump session_epoch so the requirement takes
             effect now rather than whenever a cookie lapses
-> disabled  only from enrolled: keep the secret, suspend the prompt
-> enrolled  only from disabled: restore the prompt on the same secret

An admin can require, suspend, restore and remove; an admin can never enroll, because enrollment means holding the phone. ConfirmTOTP is the only other writer of the state, and it is the one the account itself drives.

func (*Store) SettingBool added in v1.32.0

func (s *Store) SettingBool(key string) bool

SettingBool returns a boolean setting.

func (*Store) SettingDuration added in v1.32.0

func (s *Store) SettingDuration(key string) time.Duration

SettingDuration returns a duration setting, accepting either a Go duration or the gogo/duration grammar.

func (*Store) SettingFloat added in v1.32.0

func (s *Store) SettingFloat(key string) float64

SettingFloat returns a floating-point setting.

func (*Store) SettingInt added in v1.32.0

func (s *Store) SettingInt(key string) int

SettingInt returns an integer setting.

func (*Store) SettingKeyOf added in v1.32.0

func (s *Store) SettingKeyOf(key string) (SettingKey, bool)

SettingKeyOf returns a registered key, and whether it is one.

func (*Store) SettingString added in v1.32.0

func (s *Store) SettingString(key string) string

SettingString returns a setting's value, or its default.

func (*Store) SignupOrigin added in v1.2.0

func (s *Store) SignupOrigin(id int64) (string, error)

SignupOrigin is the address the application was first made from -- not last_ip. What an admin reviews is where the application came from, not where its owner last happened to be.

func (*Store) SignupVisits added in v1.2.0

func (s *Store) SignupVisits(id int64) (int, error)

SignupVisits returns how many times an application has been touched. Every touch is recorded: abuse of a public, mail-sending endpoint is otherwise invisible.

func (*Store) SpendRecoveryCode added in v1.22.0

func (s *Store) SpendRecoveryCode(userID int64, hash string) error

SpendRecoveryCode marks one code used. An unknown or already-spent code is ErrNotFound: the caller must not learn which of the two it was.

func (*Store) SpendTOTPStep added in v1.22.0

func (s *Store) SpendTOTPStep(userID, step int64) error

SpendTOTPStep records that a step has been used, and refuses one already spent. The comparison is in the WHERE clause, so two requests racing one code cannot both win.

func (*Store) StoredAppPrefs added in v1.32.0

func (s *Store) StoredAppPrefs() (map[string]string, error)

StoredAppPrefs returns only the rows that exist, and nothing about the registry. It is what the configuration layers over the environment, so an unregistered leftover row is included: dropping it here would make a key that was renamed silently revert to its environment value.

func (*Store) SweepActionTokens added in v1.2.0

func (s *Store) SweepActionTokens(grace time.Duration) (int64, error)

SweepActionTokens deletes the tokens that can never be used again: those that expired, and those that were spent, more than grace ago. Nothing depends on it having run -- expiry and single use are both enforced on read -- so a missed sweep costs disk, never correctness (FR-7.3).

func (*Store) SweepPendingTOTP added in v1.22.0

func (s *Store) SweepPendingTOTP(grace time.Duration) (int, error)

SweepPendingTOTP drops enrollments nobody ever confirmed.

func (*Store) SweepSignups added in v1.2.0

func (s *Store) SweepSignups(ttl, grace time.Duration) (int64, error)

SweepSignups deletes the applications that are over: pending ones older than ttl (nobody proved the address), rejected ones older than grace, and approved ones whose applicant never came back to choose a password, which would otherwise reserve a username forever. It never touches a validated signup: that one is not stale, it is queued behind an admin (FR-9.8).

func (*Store) TouchPAT added in v1.2.0

func (s *Store) TouchPAT(id int64)

TouchPAT records that a token was just used. Failures are ignored: last_used is an audit convenience, never a correctness dependency. Because it is only that, the write is debounced by touchInterval, on a key of its own -- pat ids and user ids are separate spaces. See dueForTouch.

func (*Store) TouchSignup added in v1.2.0

func (s *Store) TouchSignup(id int64, ip string)

TouchSignup records a visit and the address it came from. Failures are ignored: this is an audit convenience, never a correctness dependency.

func (*Store) TouchUser

func (s *Store) TouchUser(id int64)

TouchUser records a successful authentication. Failures are ignored: last_used is an audit convenience, never a correctness dependency. Because it is only that, the write is debounced by touchInterval: every authenticated request passes through here, and a commit on a quiet database is not free.

func (*Store) Unlock added in v1.2.0

func (s *Store) Unlock(id int64) error

Unlock is the same clearing, done by an admin from /users for a user who does not want to wait out the lockout.

func (*Store) UpdateUser

func (s *Store) UpdateUser(id int64, up UserUpdate) (*User, error)

UpdateUser applies a partial update. Disabling or demoting the last enabled admin is refused with ErrLastAdmin. Renaming cascades through OwnershipModel.Rename in the same transaction, so it can never half-apply.

The order is the contract: the guards, then the address (whose change clears the validation), then the validation flag (so an admin may set an address and vouch for it in one call, while a user changing their own address leaves it cleared), then the ownership cascade.

func (*Store) UserByToken added in v1.2.0

func (s *Store) UserByToken(tokenHash string) (*User, *PAT, error)

UserByToken resolves a presented bearer token, already hashed, to its account and the token it authenticated with. It is the only lookup the resolver's bearer branch makes: pats is the credential index. A disabled user is returned, not refused -- the caller decides and logs that refusal. An expired token is ErrPATExpired.

func (*Store) ValidateEmail added in v1.2.0

func (s *Store) ValidateEmail(id int64, email string) (*User, error)

ValidateEmail stamps an address validated -- the one write that makes the system's claim -- but only if the account still holds exactly that address, checking and writing in one transaction. The address is an argument rather than an assumption: between the mail and the click the account may have moved, and stamping whatever it holds now would validate an address nobody proved (FR-6.2, FR-7.4). ErrEmailChanged is the refusal; the caller asks the user for a fresh link.

func (*Store) ValidateSignup added in v1.2.0

func (s *Store) ValidateSignup(id int64) (*Signup, error)

ValidateSignup marks the address proved. Only a pending application can be validated, and the transition is guarded here, so a replayed link cannot walk a decided signup backwards.

func (*Store) ValidateStored added in v1.32.0

func (s *Store) ValidateStored() []error

ValidateStored reports every stored row that its key would refuse today: a value seeded from a variable that never passed validation, or one left behind by a key whose rules have since tightened. Seeding deliberately does not validate -- a deployment that booted yesterday must boot today -- so this is where that debt is named.

It is a warning, not an error. Each such key reads as its default until somebody fixes it on the settings screen.

type TOTP added in v1.22.0

type TOTP struct {
	UserID      int64
	Secret      string
	Created     int64
	ConfirmedAt *int64
	LastStep    int64
	LastUsed    *int64
}

TOTP is one enrollment: the shared secret and what has been done with it.

func (*TOTP) Confirmed added in v1.22.0

func (t *TOTP) Confirmed() bool

Confirmed reports whether a code has ever proved this secret.

type User

type User struct {
	ID   int64
	Name string
	Role string // user | admin
	// TokenHash mirrors the account's legacy PAT and is not a credential: nothing
	// authenticates against it, and it exists only so an older binary rolled back
	// onto this database still finds the token it knows about. Ask PATs instead.
	TokenHash string
	CertCN    string // client-cert CN; empty otherwise
	Created   int64
	LastUsed  *int64
	Disabled  bool

	FirstName string // may be empty
	LastName  string // may be empty
	Email     string // may be empty; lowercased and validated when set
	// EmailValidated is when something proved the address belongs to this account,
	// nil until then. It is a claim only the system may make: a user can change
	// their address, never vouch for it, and any change of address clears this in
	// the same transaction (FR-4.3).
	EmailValidated *int64

	// HasPassword is whether a password is set. The hash itself is never carried
	// on the User: it leaves the store only through PasswordHash.
	HasPassword bool
	// PasswordSetAt is when it was last set, nil when there is none.
	PasswordSetAt *int64
	// SessionEpoch is carried in the signed session cookie, which the resolver
	// admits only while the two match. Bumping it kills every live session of this
	// account without rotating GOGO_SESSION_SECRET (FR-5.5).
	SessionEpoch int64
	// LockedUntil is when password authentication becomes possible again, nil when
	// it is not barred. Tokens and certificates ignore it -- see passwords.go.
	LockedUntil *int64
	// PATs is how many personal access tokens the account holds. This -- not
	// TokenHash -- is what "does this account hold a token" means (FR-5.1).
	PATs int

	// SignupAt is when this person applied for the account, nil for every account an
	// admin created. SignupApproved says a human said yes, as opposed to the
	// auto-approval with review off. The signups row is swept; this survives it.
	SignupAt       *int64
	SignupApproved bool
	// InvitedAt is when an admin created this account. Such an account starts with
	// no credential at all: its owner proves the address and chooses a password,
	// with no approval step (FR-6.1, FR-6.3).
	InvitedAt *int64

	// TOTPState is which of the four second-factor states this account is in. It is
	// the policy; the totp table holds the secret, and the store keeps the two
	// consistent in one transaction. See totp.go.
	TOTPState string
}

User is an account. A user authenticates by a personal access token (see pats.go), by a password, or -- for auto-provisioned client-certificate identities -- by its CertCN. A certificate never grants admin.

Every field but the first three is optional: an account carrying none of them serializes exactly as it did before those fields existed (FR-4.2).

func (*User) CertOnly added in v1.2.0

func (u *User) CertOnly() bool

CertOnly reports whether the account's only way in is its client certificate -- no token, no password. It is what a merge folds into a real identity, and it is deliberately strict: a certificate user who also holds a token is not cert-only, and merging them would delete that token with the source row.

func (*User) HasToken added in v1.2.0

func (u *User) HasToken() bool

HasToken reports whether the account holds any personal access token. It is a count of PATs, not a look at TokenHash (FR-5.1).

func (*User) Invited added in v1.3.0

func (u *User) Invited() bool

Invited reports whether an admin created this account (as opposed to somebody signing themselves up, or the bootstrap).

func (*User) IsAdmin

func (u *User) IsAdmin() bool

IsAdmin reports whether the user holds the admin role.

func (*User) Locked added in v1.2.0

func (u *User) Locked() bool

Locked reports whether password authentication is currently barred for this account. It says nothing about its tokens or its certificate, which keep working.

func (*User) TOTPMustEnroll added in v1.22.0

func (u *User) TOTPMustEnroll() bool

TOTPMustEnroll reports whether the next password login is an enrollment.

func (*User) TOTPRequired added in v1.22.0

func (u *User) TOTPRequired() bool

TOTPRequired reports whether a password login must clear a second factor.

func (*User) Unclaimed added in v1.3.0

func (u *User) Unclaimed() bool

Unclaimed reports whether an account has no way in at all: no token, no password, no certificate. An invited account is unclaimed until its owner chooses a password.

type UserInfo added in v1.2.0

type UserInfo struct {
	FirstName string
	LastName  string
	Email     string
	// EmailValidated stamps the address validated at creation. Only an admin
	// reaches this through the API, and only with an address: validating an
	// empty one is ErrNoEmail.
	EmailValidated bool
	// PasswordHash is an already-hashed password (auth.HashPassword). It is how
	// GOGO_ADMIN_PASSWORD seeds the first admin, and how /setup accepts one.
	PasswordHash string
	// Invited stamps invited_at: an admin made this account, and its owner has not
	// claimed it yet. Such an account may be created with no credential at all -- the
	// credential is the one thing an admin cannot choose for somebody else (FR-6.1).
	Invited bool
}

UserInfo is the optional information a user may be created with. CreateUser takes it variadically, so a four-argument call site keeps compiling.

type UserUpdate

type UserUpdate struct {
	Name      *string
	Disabled  *bool
	Role      *string
	TokenHash *string // set by a token regeneration

	FirstName *string // "" clears
	LastName  *string // "" clears
	Email     *string // "" clears (and so clears EmailValidated)

	// EmailValidated is the system's claim about the address, not the user's: true
	// stamps the moment, false clears it, and the web layer populates it only for an
	// admin. A nil pointer leaves it alone -- except that a change of address clears
	// it regardless, which UpdateUser enforces and no caller can bypass.
	EmailValidated *bool
}

UserUpdate carries the mutable fields of a user. A nil field is unchanged; an empty string clears the field it names.

Jump to

Keyboard shortcuts

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