Documentation
¶
Overview ¶
Package auth handles passwords, sessions, API keys and permission checks.
Index ¶
- Constants
- Variables
- func APIKeyHash(pepper []byte, prefix, secret string) []byte
- func AnonymizeIP(addr netip.Addr) string
- func ClientIPFrom(ctx context.Context) netip.Addr
- func CookieName(secure bool) string
- func HashOpaqueToken(token string) []byte
- func HashSessionToken(token string) []byte
- func IsSessionInvalid(err error) bool
- func NewOpaqueToken(n int) (token string, hash []byte, err error)
- func NewSessionToken() (token string, hash []byte, err error)
- func NewTOTPSecret() (string, error)
- func NormalizeEmail(email string) string
- func ParseAPIKey(token string) (prefix, secret string, err error)
- func ProvisionOrganization(ctx context.Context, q *dbgen.Queries, userID uuid.UUID, name string, ...) (dbgen.Organization, dbgen.Workspace, error)
- func Slugify(s string) string
- func TOTPCode(secret string, step int64) (string, error)
- func TOTPStep(t time.Time) int64
- func TOTPURI(issuer, account, secret string) string
- func TOTPVerify(secret, code string, now time.Time) (step int64, ok bool)
- func ValidateEmail(email string) error
- func WithClientIP(ctx context.Context, addr netip.Addr) context.Context
- func WritePassword(ctx context.Context, q *dbgen.Queries, h *Hasher, userID uuid.UUID, ...) error
- type APIKeyAuditor
- type APIKeyConfig
- type APIKeyInfo
- type APIKeyOrgRevocation
- type APIKeyRevocation
- type APIKeyRotation
- type APIKeyService
- func (s *APIKeyService) Authenticate(ctx context.Context, token string) (*Identity, error)
- func (s *APIKeyService) Close(ctx context.Context) error
- func (s *APIKeyService) Create(ctx context.Context, actor *Identity, in CreateAPIKeyInput) (*CreatedAPIKey, error)
- func (s *APIKeyService) FlushUsage(ctx context.Context) error
- func (s *APIKeyService) List(ctx context.Context, actor *Identity) ([]APIKeyInfo, error)
- func (s *APIKeyService) MayCreateOrgWide(ctx context.Context, actor *Identity) (bool, error)
- func (s *APIKeyService) Revoke(ctx context.Context, actor *Identity, id uuid.UUID) error
- func (s *APIKeyService) Rotate(ctx context.Context, actor *Identity, in RotateAPIKeyInput) (*RotatedAPIKey, error)
- func (s *APIKeyService) Start()
- type Authority
- type CreateAPIKeyInput
- type CreatedAPIKey
- type Hasher
- type Identity
- type LockoutPolicy
- type LoginInput
- type LoginResult
- type MFAAuditor
- type MFAChange
- type MFAChangeKind
- type MFACipher
- type MFAConfig
- type MFAEnrolled
- type MFAEnrolment
- type MFANotifier
- type MFAService
- func (m *MFAService) Available() bool
- func (m *MFAService) BeginEnrolment(ctx context.Context, actor *Identity) (*MFAEnrolment, error)
- func (m *MFAService) CompleteSecondFactor(ctx context.Context, token, code string, ip netip.Addr, userAgent string) (*LoginResult, error)
- func (m *MFAService) ConfirmEnrolment(ctx context.Context, actor *Identity, secret, code string) (*MFAEnrolled, error)
- func (m *MFAService) Disable(ctx context.Context, actor *Identity, password, code string) error
- func (m *MFAService) PurgePendingLogins(ctx context.Context, batch int32) (int64, error)
- func (m *MFAService) RegenerateRecoveryCodes(ctx context.Context, actor *Identity) ([]string, error)
- func (m *MFAService) Status(ctx context.Context, actor *Identity) (MFAStatus, error)
- type MFAStatus
- type MembershipAuthority
- type Params
- type PendingSecondFactor
- type RegisterInput
- type RotateAPIKeyInput
- type RotatedAPIKey
- type RotatedPredecessor
- type RotationReach
- type Service
- func (s *Service) Authenticate(ctx context.Context, token string) (*Identity, error)
- func (s *Service) ChangePassword(ctx context.Context, userID, keepSession uuid.UUID, current, next string) error
- func (s *Service) Hasher() *Hasher
- func (s *Service) IdentityForEmail(ctx context.Context, email string) (*Identity, error)
- func (s *Service) Login(ctx context.Context, in LoginInput) (*LoginResult, error)
- func (s *Service) Logout(ctx context.Context, sessionID uuid.UUID) error
- func (s *Service) NeedsSetup(ctx context.Context) (bool, error)
- func (s *Service) Register(ctx context.Context, in RegisterInput) (*Identity, error)
- func (s *Service) SetDefaultWorkspace(ctx context.Context, actor *Identity, workspaceID *uuid.UUID) error
- func (s *Service) SwitchWorkspace(ctx context.Context, actor *Identity, workspaceID uuid.UUID) error
- func (s *Service) VerifyPassword(ctx context.Context, userID uuid.UUID, password string) error
- func (s *Service) Workspaces(ctx context.Context, actor *Identity) ([]Workspace, error)
- type ServiceConfig
- type Session
- type SessionTTL
- type Workspace
Constants ¶
const ( PermAPIKeysRead = "apikeys.read" PermAPIKeysWrite = "apikeys.write" )
Permissions API key management itself requires.
const ( // DefaultRotationGrace is how long both secrets verify when the caller does // not say. An hour is long enough for a deploy to reach every consumer of // the credential and short enough that a rotation nobody finished is not a // second live key for the rest of the week. DefaultRotationGrace = time.Hour // MinRotationGrace is the floor, and it exists because of `last_used_at`. // // The obvious way to check a rotation landed is to watch whether anything // still uses the old key — and `last_used_at` is buffered and flushed on a // 30s cadence, so a predecessor that reads as idle may have been used up to // 30 seconds ago. A grace window measured in seconds would close before that // answer was even available. Five minutes is an order of magnitude above the // flush interval, which is what makes the reading mean something. MinRotationGrace = 5 * time.Minute // MaxRotationGrace is the ceiling, and it is the thing that keeps D9's // accepted trade finite. A leaked key persisting across rotations is // tolerable because each predecessor stops verifying; an unbounded window // would make "stops verifying" a promise about the heat death of the // universe. MaxRotationGrace = 24 * time.Hour )
Rotation, per decision D9.
The tension this resolves is recorded rather than dodged. `apikeys.*` is non-delegable precisely so a credential can never mint another credential — otherwise revoking a leaked key means nothing, because whoever leaked it issued a second one first. Rotation is the one thing a key must nevertheless be able to do without a human, because the alternative is a credential that can only be replaced by somebody signing in, which is not a thing an unattended deployment can arrange at 3am.
So rotation is not "a key minting a key". It is a key replacing **itself**:
- only its own row, addressed by the token that authenticated the request; the endpoint takes no id, because taking one would imply otherwise
- into scopes that are a subset of its own
- with the same workspace binding, copied verbatim
- once — a key that already has a successor refuses, and a unique index holds that in the database as well, so the lineage is a chain
Nothing there widens anything, which is what makes it safe to leave in a credential's hands. `apikeys.write` is still not a scope any key may hold, and `TestNonDelegableScopesCoverKeyManagement` is what says so.
The accepted trade, stated rather than buried: **a leaked key can persist across rotations.** Whoever holds the secret can rotate it, so revoking the key the owner knows about does not necessarily end the intruder's access — they hold a successor the owner never saw. It is finite rather than unbounded because every generation appears in the owner's key list and the chain is visible there, but it is real, and it is the price of unattended rotation. The alternative considered was session-only rotation, which is what the product already had: mint a new key by hand. That leaves the limitation unsolved.
const ( // PermInstanceAdmin is the principal itself: holding it confers // instance-level review on another account, and confers nothing else. // // It is not in InstanceGrantable below, and that omission is D98's // delegation bound made structural — "the principal may grant instance-level // review, and a holder of it may not". A principal cannot mint a second // principal, so the set of people who may delegate cannot grow, which is the // property the constraint exists to protect. Without it the first delegatee // appoints the next and the bound is gone in two hops. PermInstanceAdmin = "instance.admin" // PermDestinationsReview is the reading half of the dispute permission: list // the queue and inspect what is in it. PermDestinationsReview = "destinations.review" // PermDestinationsDecide is the deciding half: allow or uphold, which lifts // an entry from the instance-wide blocklist. PermDestinationsDecide = "destinations.decide" // PermAuditReadInstance reads the audit records of acts that belong to the // instance rather than to any tenant. PermAuditReadInstance = "audit.read.instance" // PermDomainsWriteInstance administers the instance default domain: its root // redirect and its bot policy. // // `domains.write` is a role permission and stays one, because a workspace // administering its own registered hostname is M39's whole point. The // instance default is not any tenant's — it is the hostname every // workspace's links are served on until it registers one — and the guard // answered `true` for it on the bare role permission, so on a // multi-organization instance every owner and admin could repoint it. Under // `SIGNUP_MODE=open` that is one registration away (F70, D100). // // Named to sort beside `domains.write` for the reason `audit.read.instance` // is named beside `audit.read`: the reader comparing the two is the reader // this permission is for. PermDomainsWriteInstance = "domains.write.instance" )
The instance-level permissions (D98), named here for the reason NonDelegableScopes names slugs that belong to other packages: this is the package that resolves an identity, so it is the package that has to know which permissions arrive from somewhere other than a membership. The canonical constants stay where the feature lives — dispute.PermReview, dispute.PermDecide, audit.PermReadInstance — and those packages import this one, so the dependency cannot run the other way.
const ( SessionCookieName = "__Host-linkctrl_session" SessionCookieNameInsecure = "linkctrl_session" )
SessionCookieName uses the __Host- prefix, which browsers only accept when the cookie is Secure, has Path=/, and carries no Domain attribute. That makes it impossible for a subdomain — including one an attacker controls via a stale DNS record or a shared hosting neighbour — to set or overwrite the session cookie.
The prefix requires HTTPS, so local HTTP development uses the unprefixed name. Config refuses SECURE_COOKIES=false in production, so the weaker form cannot reach a real deployment.
const ( // APIKeyPrefixLength is the length of the public, storable part. APIKeyPrefixLength = len(apiKeyTag) + apiKeyIDChars )
Token layout: "lk_live_" + 8-character public id + "_" + 43-character secret.
The public id is stored and indexed, so verification is a single-row lookup rather than a scan comparing every hash. The tag is fixed-length and the id is fixed-length, which means the parts are taken by offset — splitting on "_" would break the moment a base64url secret contained one.
"live" is there so a future test-mode key is distinguishable by eye rather than by asking the database. The whole token is one word with no spaces or punctuation beyond underscores, so it survives being pasted into a shell, a YAML file and a CI secret box unquoted.
const MFAKeyMinBytes = 32
MFAKeyMinBytes is the shortest configured value accepted.
Thirty-two, matching `API_KEY_PEPPER`'s floor, and for the operator's sake rather than the cipher's: the value is hashed to a 256-bit key whatever its length, so a short one is not weaker than its own entropy — it is weaker than it looks. Refusing below the floor is what stops `MFA_SECRET_KEY=changeme` from producing a working instance.
const MFAPendingTTL = 5 * time.Minute
MFAPendingTTL is how long the step between a right password and a session stays usable.
**Five minutes, and m53.md asks for it to be a number a test asserts.** The window is a person reading six digits off a phone that is already in their hand, plus the time it takes to find the phone. Longer is a credential lying about for no reason; much shorter fails somebody whose phone locked itself while they were typing their password.
const MFARecoveryCodeCount = 10
MFARecoveryCodeCount is how many codes an enrolment issues. Ten, which m53.md names, and which is the number every product that does this settled on: enough that spending one is not an event, few enough to write on one line of paper.
const MaxPasswordLength = 4096
MaxPasswordLength caps input before hashing.
Argon2 has no practical input limit, so this is not about the algorithm: it is a denial-of-service guard. Hashing is deliberately expensive, and an unbounded body means an attacker can make the server do unbounded work.
const MinPasswordLength = 12
MinPasswordLength is the floor for new passwords. Length is the only requirement — no composition rules, which push people toward predictable substitutions without adding real entropy (NIST SP 800-63B).
const MinPepperLength = 32
MinPepperLength mirrors the config validation floor, so a service built directly in a test cannot be weaker than a deployed one.
const NoRoleRank = math.MaxInt32
NoRoleRank is the rank of an identity whose role could not be resolved.
math.MaxInt32 and not zero, and that choice is the whole safety property: rank counts *downward* in authority, so a zero would read as outranking the owner role. Anything comparing ranks fails closed against this value.
const TOTPDigits = 6
TOTPDigits is the length of a code. Six, for the same reason.
const TOTPPeriod = 30 * time.Second
TOTPPeriod is the length of one step. Thirty seconds is RFC 6238's default and is what every authenticator app assumes; it is not configurable for that reason, because a value the phone cannot be told about is a value that produces codes nobody can match.
const TOTPSecretBytes = 20
TOTPSecretBytes is the entropy in a generated secret.
Twenty bytes — 160 bits — which is what RFC 4226 §4 R6 requires as a minimum and what HMAC-SHA-1's block handling makes the natural size: a longer secret is hashed down to twenty bytes before use, so the extra entropy never reaches the computation. It encodes to thirty-two base32 characters with no padding, which is the shape every authenticator app expects to be handed.
const TOTPSkew = 1
TOTPSkew is how many steps either side of the current one are accepted.
**One, which is a tolerance of ninety seconds in total** — the current step plus the one before and the one after. m53.md asks for clock skew to be answered by accepting the adjacent windows and documenting the tolerance as a number, *and no more*, so this is the number: 30 seconds of drift in either direction, plus the up-to-30 seconds a person spends typing.
Wider is the tempting mistake. Every extra step multiplies the guessing surface by three-in-a-million per attempt and extends how long an observed code stays usable, and it treats a broken clock as something to absorb rather than something to fix. NTP exists.
Variables ¶
var ( // not configured MFA_SECRET_KEY. Wraps ErrMFAKeyMissing so a caller may test // for either. ErrMFAUnavailable = fmt.Errorf("auth: the second factor is not available on this instance: %w", ErrMFAKeyMissing) // ErrMFAAlreadyEnabled is enrolling an account that already has a factor. // Disabling and enrolling again is the route to a new secret, deliberately: // replacing one in place would mean an account briefly having two, and the // recovery codes belonging to neither. ErrMFAAlreadyEnabled = errors.New("auth: this account already has a second factor") // ErrMFANotEnabled is disabling or regenerating on an account with no factor. ErrMFANotEnabled = errors.New("auth: this account has no second factor") // ErrMFACodeInvalid is every rejected second factor: a wrong code, a code // from a step already spent, a recovery code that does not match or has been // used, and a secret this instance's key cannot read. // // **One error for all of them, and the last one is why it is worth saying.** // A secret that will not decrypt is an operator's mistake and not the // person's, and it is still answered here rather than surfaced — telling // whoever is at the prompt that the *server* cannot read the secret hands a // stranger a way to probe which accounts are enrolled and what state the // instance's configuration is in. The operator's copy of that fact is the log // line, which names it plainly. ErrMFACodeInvalid = errors.New("auth: that code is not valid") // ErrMFAChallengeInvalid is a pending login that cannot be completed: no such // token, lapsed, already spent, or an account whose state changed while the // prompt was open. // // Collapsed for the reason recovery.ErrNotResettable is, and answered by // sending the person back to the sign-in form. A caller enumerating cannot // tell the four apart. ErrMFAChallengeInvalid = errors.New("auth: this sign-in can no longer be completed") )
Errors this package returns for the second factor, and that a caller distinguishes.
var ( ErrMismatch = errors.New("auth: password does not match") ErrInvalidHash = errors.New("auth: hash is not in a recognised format") ErrUnsupportedID = errors.New("auth: unsupported password hash algorithm") )
var ( ErrEmailTaken = errors.New("auth: email already registered") ErrInvalidEmail = errors.New("auth: invalid email address") ErrInvalidCredentials = errors.New("auth: invalid email or password") ErrAccountLocked = errors.New("auth: account temporarily locked") ErrAccountInactive = errors.New("auth: account is not active") ErrSignupClosed = errors.New("auth: registration is closed") )
var ( ErrSessionNotFound = errors.New("auth: session not found") ErrSessionExpired = errors.New("auth: session expired") ErrSessionRevoked = errors.New("auth: session revoked") )
var DefaultLockout = LockoutPolicy{Threshold: 5, Window: 15 * time.Minute}
var DefaultParams = Params{
MemoryKiB: 64 * 1024,
Iterations: 3,
Parallelism: 2,
SaltLength: 16,
KeyLength: 32,
}
DefaultParams follows the RFC 9106 second recommendation: 64 MiB, t=3, p=2. config.Validate refuses anything below the 19 MiB floor.
var ErrAPIKeyAlreadyRotated = errors.New("auth: this api key has already been rotated")
ErrAPIKeyAlreadyRotated is the refusal a second rotation of one key gets.
Distinct from ErrAPIKeyInvalid, and deliberately so: the caller holding this key is its legitimate owner — it just authenticated — and telling it "invalid" would send an automated rotation into a retry loop against a key that is working perfectly well. The successor exists; whoever asked has lost it, and that is a different problem from a bad credential.
var ErrAPIKeyInvalid = errors.New("auth: api key is not valid")
ErrAPIKeyInvalid covers every reason a presented key does not authenticate: malformed, unknown, wrong secret, revoked, expired, or belonging to an account that is no longer active.
One error rather than several. The distinction is of no use to a legitimate caller — the key list shows revocation and expiry, so the owner can already see which of theirs is which — and separate responses would tell whoever found a leaked key whether it is still worth trying elsewhere.
var ErrMFAKeyMissing = errors.New("auth: this instance has no MFA_SECRET_KEY configured")
ErrMFAKeyMissing is a second-factor operation attempted on an instance with no `MFA_SECRET_KEY`.
Distinct from a decryption failure, because the two are different operator problems with different answers: this one is *set the variable*, and a decryption failure is *you set the wrong one, or you set it after accounts had enrolled under another*. Both refuse the same way to whoever is at the form.
var ErrMFASecretUnreadable = errors.New("auth: the stored second-factor secret cannot be read")
ErrMFASecretUnreadable is a stored secret that will not decrypt under the configured key.
Authenticated encryption means this is the only failure shape there is: a wrong key, a truncated value and a tampered one are one error, because GCM's tag check does not distinguish them and neither should a caller.
var ErrNoWorkspace = errors.New("auth: account belongs to no organization")
ErrNoWorkspace reports that an account belongs to no organization, and so resolves into no workspace.
It is a state, not a fault, and that is the whole of D36. Until organization deletion existed this could not be reached — registration provisions a membership in the same transaction as the user — so resolveWorkspace called it a broken instance and every caller propagated the error. Deleting the last organization somebody belongs to now produces it deliberately, on an account that is otherwise entirely intact, and an availability path reached by every authenticated request must not treat that as a failure.
Callers turn it into an identity that holds nothing rather than into an error: see identityWithoutOrganization. It stays an error value so that a caller which has *not* been taught about it fails loudly instead of silently acting with a zero workspace id.
var InstanceGrantable = map[string]struct{}{ PermDestinationsReview: {}, PermDestinationsDecide: {}, }
InstanceGrantable is what the principal may confer on somebody else.
The dispute queue, both halves. A reviewer who could read but not decide would be watching a queue they cannot work, and F15's problem was never that owners could decide — it was that every owner on the instance could.
PermAuditReadInstance is deliberately absent as well as PermInstanceAdmin. The instance audit surface ties an ip_prefix to a named actor, which is the disclosure limb of D18, and D98 gives it to the principal rather than to "instance-level review". Widening it is a decision, and this list is where somebody would have to make it.
PermDomainsWriteInstance is absent for the same reason (D100). The principal administers the instance default domain; conferring *that* is not what D98 decided the principal may delegate, which was instance-level review of disputes and nothing beside it.
var InstancePrincipalScopes = []string{ PermInstanceAdmin, PermDestinationsReview, PermDestinationsDecide, PermAuditReadInstance, PermDomainsWriteInstance, }
InstancePrincipalScopes is everything the principal holds, enumerated.
Enumerated and not implied, which is D98's own wording and the load-bearing part of it: this is not a general instance-administration role. Its reach is the three findings that needed it — the dispute queue, the blocklist entries those decisions lift, and the instance-wide audit surface — and nothing inherits from holding it. A permission added to this list later is a decision somebody made, visible in a diff, rather than a consequence of the principal existing.
var KeyIssuableRoles = map[string]struct{}{
"editor": {},
"viewer": {},
}
KeyIssuableRoles are the roles an API key may put somebody into (D43). Absolute, not relative to whoever created the key.
The second of the two mechanisms that may branch on credential type, and it sits beside the first so that a reader meets both at once. NonDelegableScopes above governs what a key may **hold**. This governs what a key may **make** with one it legitimately holds, and members.write is the permission that needs both: a key holding it does not itself gain anything, but the interactive principal it produces is not a credential — nothing revokes that principal when the key is revoked, and requireSessionActor cannot tell it from an account somebody registered.
Named rather than ranked, deliberately. A relative ceiling — one rank below the issuer — is the fix this looks like and it closes nothing: admin holds every permission except org.delete (00700_seed.sql), so a key an owner created could still produce an admin holding apikeys.write, audit.read and members.write. The boundary is between admin and editor because of what those two roles *hold*, which is not a property of where a rank sorts, so a role added later is refused here until somebody decides otherwise rather than admitted by arithmetic.
**Every way a key can put somebody at a role passes through this**, which is what D43 originally missed: it bounded the invitation and left role assignment on an existing membership — team.ChangeRole and team.Grant — reaching admin with the same key and the same permission. Reaching admin by promotion rather than by admission is one axis over, not a different defect.
var NonDelegableScopes = map[string]struct{}{ PermAPIKeysRead: {}, PermAPIKeysWrite: {}, "org.delete": {}, "audit.read": {}, "webhooks.write": {}, "automation.write": {}, PermInstanceAdmin: {}, PermDestinationsDecide: {}, PermAuditReadInstance: {}, }
NonDelegableScopes are permissions an API key may never hold, whatever its creator's role.
Key management is the important one: a key that can mint keys makes revocation meaningless, because whoever holds a leaked key simply issues another before the original is cut off. So minting stays behind an interactive session, and org.delete follows the same rule — an irreversible action should require a human sign-in rather than a token in a CI variable.
audit.read is here for a different reason, and the difference matters to whoever adds the next entry. It escalates nothing and reverses nothing; it is listed because of what it discloses. The audit log is the one place a network prefix is tied to a named person, so the rule this map encodes is now "escalating, irreversible, or disclosing" rather than only the first two.
**D18 now says that too.** Until 2026-08-05 the decision named only the escalating and disclosing limbs and closed with "everything else is delegable" — which, read literally by whoever adds the next irreversible permission, makes org.delete delegable. This comment was right and the decision was not, for eight months of milestones. F12 corrected the text rather than the map, and the near miss is worth leaving on the record here: the next milestone to add an irreversible permission is the one that would have applied the two limbs, found neither matched, and shipped it delegable.
This map is the only thing that makes audit.read session-only. There is no second check in the handler or the service — the endpoint authorizes on the permission like every other endpoint — so if machine export ever outweighs the disclosure, deleting this one line is the whole change. See decisions.md.
destinations.decide is the escalating limb again, and more directly than key management is. Allowing a disputed destination deletes a row from the instance-wide low-confidence blocklist, after which every destination under that host becomes creatable — by the key that removed it, among others. A key that can decide what it is allowed to point at has widened its own reach by an action it took itself (M31, applying D18).
**destinations.review is deliberately no longer here** (M45, D98). It used to be, because one permission guarded both reading the queue and deciding what is in it, and the deciding half is what the paragraph above convicts. D98 split them, and the split is how "API access is read-only for disputes; a change requires a person" is built: a key may list and inspect disputes, and is refused by this map when it tries to act on one. That refusal comes from the map rather than from a check on what kind of credential is calling — the inherited Permissions rule says anything branching on credential type outside this map and D43 is a defect, and F104 already convicts seven places for it, so adding an eighth deliberately would have been the wrong direction. Reading the queue matches neither limb of D18: it discloses who filed a dispute and a defanged host, never an address or a network prefix, and it escalates nothing.
instance.admin is the second limb in its hardest form (M45, D98). Holding it confers destinations.decide on a person, so a key holding it would be a key that widens its own reach by manufacturing somebody else's — the shape D9 keeps apikeys.* out of the map for, one step further removed. It is also the only permission in this product whose whole content is granting another one, which is exactly the thing a credential must not be able to do unattended.
audit.read.instance is the *disclosing* limb, for the reason audit.read is: the instance audit surface is the same table, carrying the same ip_prefix tied to the same named actors, differing only in that its rows belong to no tenant. A permission that leaks what its sibling is listed here to protect would make the sibling's entry decorative.
webhooks.write is the *durability* of a reach, which is the shape none of the entries above quite has (M42, applying D18's second limb). A webhook is a standing instruction to send every link change in a workspace to an address its creator chose, and it keeps sending after the credential that created it is revoked: revoking the key does not revoke the channel. That is a reach the key retains once it is gone, which is what makes it escalation rather than ordinary use of a permission the holder already has.
webhooks.read is deliberately **not** here. Reading the list discloses where a workspace's events go and what the recent deliveries did, which is exactly what an integrator's tooling needs and escalates nothing. The pair therefore splits the way apikeys.* does not, and the split is the point: a key can watch its own integration, and a human has to create one.
automation.write is the durability limb again, and one turn further round than webhooks.write (M43, applying D18). A webhook is a standing instruction to *report*; an automation rule is a standing instruction to *act* — it archives links on the scheduler, unattended, and it can make the server emit an event on top of that. Both outlive the credential that created them, so revoking the key does not revoke the instruction, and that is what makes it escalation rather than ordinary use of a permission the holder already has. An editor can archive a link today; nobody should be able to leave behind a token that keeps archiving links after it has been revoked.
automation.read is deliberately **not** here, for the reason webhooks.read is not: reading the list says what a workspace has told the scheduler to do and when each rule last fired, which is exactly what an integrator's tooling needs and escalates nothing.
Functions ¶
func APIKeyHash ¶
APIKeyHash is the value stored in api_keys.key_hash.
HMAC-SHA256 with a pepper from configuration, so a database dump on its own does not permit offline verification. Deliberately not argon2: the secret is full-entropy random, so stretching buys nothing, and 64 MiB of work per request would not fit a 150ms API budget.
The prefix is part of the message, which binds a hash to the row that holds it: a hash copied to another key's row no longer verifies.
func AnonymizeIP ¶
AnonymizeIP reduces an address to the prefix kept for session and audit records: /24 for IPv4, /48 for IPv6.
The same reasoning as analytics — enough to recognise "this session moved to a different network", not enough to identify a person. Analytics keeps no address at all; sessions keep a prefix because "where was this session used" is a question a user legitimately asks of their own account.
func ClientIPFrom ¶ added in v0.2.0
ClientIPFrom returns the resolved client address, or the zero Addr when there is none — a CLI invocation, a background job, or a test that did not set one. AnonymizeIP maps that to an empty string, so an event written off a request records no network rather than a misleading one.
func CookieName ¶
CookieName returns the correct cookie name for the deployment.
func HashOpaqueToken ¶ added in v0.2.0
HashOpaqueToken returns the storage hash for a token minted by NewOpaqueToken.
func HashSessionToken ¶
HashSessionToken returns the storage hash for a session token.
func IsSessionInvalid ¶
IsSessionInvalid reports whether an Authenticate failure means the credential itself is finished, as opposed to the lookup having failed.
The distinction decides whether a caller may destroy the cookie. Authenticate returns wrapped pgx errors for a dead pool, a cancelled context or a missing workspace row, and treating those as "this session is over" turns a ten-second database blip into a forced sign-out for every signed-in user at once — sessions that were, and remain, perfectly valid.
func NewOpaqueToken ¶ added in v0.2.0
NewOpaqueToken returns a random bearer-shaped secret and its storage hash.
Only the hash is ever persisted. A database leak therefore does not hand over live credentials, which is the same reasoning as never storing a raw password. SHA-256 rather than argon2 is correct here: the token is full-entropy random, so key-stretching adds nothing, and these are verified on paths where 64 MiB of work would be untenable.
Generalized out of NewSessionToken when invitations needed the same construction (M27). One implementation rather than two, so "hashed like a session token" is a fact about the code and not a claim in a comment.
func NewSessionToken ¶
NewSessionToken returns a random session token and its storage hash.
func NewTOTPSecret ¶ added in v0.3.0
NewTOTPSecret returns a fresh secret, base32-encoded as an authenticator app expects to receive it.
The encoded form is what travels: it is what goes in the URI, what is shown beside the QR code for somebody enrolling on the device they are reading, and what is encrypted at rest. Keeping one representation means there is no place for an encode and a decode to disagree.
func NormalizeEmail ¶
NormalizeEmail trims and lowercases. The database also stores a generated lowercase column, so comparison never depends on the caller remembering.
func ParseAPIKey ¶
ParseAPIKey splits a token into its public prefix and its secret.
Everything about the shape is checked here so that a malformed token costs no database round trip, which is what stops a flood of junk Authorization headers turning into a flood of queries.
func ProvisionOrganization ¶ added in v0.2.0
func ProvisionOrganization( ctx context.Context, q *dbgen.Queries, userID uuid.UUID, name string, isPersonal bool, ) (dbgen.Organization, dbgen.Workspace, error)
ProvisionOrganization creates an organization, its first workspace and an owner membership for one user, inside the caller's transaction.
Exported and taking a *dbgen.Queries rather than being a method, because two packages provision tenancy and there must not be two implementations of it. Registration calls it for the personal organization every account starts with (is_personal true); internal/team calls it for an organization somebody deliberately creates (is_personal false). The tenancy invariants — an organization always has a workspace, and always has an owner, both written in the same transaction as the row that needs them — are stated once, here.
The caller owns the transaction and the commit. That is what lets registration create the user in the same one, and what keeps this function unable to leave a half-provisioned organization behind.
func Slugify ¶ added in v0.2.0
Slugify reduces a name to the URL-safe form the tenancy tables store beside it. Exported because workspace renaming derives a slug the same way, and a second implementation would be a second answer to "what is this called".
func TOTPCode ¶ added in v0.3.0
TOTPCode computes the code for one step.
RFC 4226 §5.3's dynamic truncation, verbatim: HMAC the counter, take the low four bits of the last byte as an offset, read four bytes from there, mask the sign bit, and reduce modulo ten to the power of the digit count. The mask is what stops the result depending on the platform's integer signedness, which is the one place a hand-written HOTP usually goes wrong.
func TOTPStep ¶ added in v0.3.0
TOTPStep is the counter RFC 6238 derives from a moment in time.
Exported because it is the replay guard's unit: `users.mfa_last_step` holds one of these, and the refusal is an integer comparison rather than a set of spent codes. Unix seconds divided by the period, which is the specification's T with T0 = 0.
func TOTPURI ¶ added in v0.3.0
TOTPURI builds the `otpauth://` URI an authenticator app scans.
The shape is Google's de-facto Key URI Format, which every app implements:
otpauth://totp/<issuer>:<account>?secret=…&issuer=…&algorithm=SHA1&digits=6&period=30
The issuer appears twice — as a label prefix and as a query parameter — because older apps read one and newer ones read the other, and an app that reads neither files the entry under a bare address with no clue which service it belongs to.
**Everything is escaped, and the label is escaped as a path segment.** The account name is an email address and the issuer is the instance's own hostname, neither of which is attacker-controlled here; escaping them anyway is what stops that from being a fact this function depends on. `url.URL.String` would encode the path for us, but it also re-encodes `:` inconsistently across the label separator, so the path is built with `url.PathEscape` and assigned to `Opaque` — which is what the RFC 3986 shape of these URIs actually is.
func TOTPVerify ¶ added in v0.3.0
TOTPVerify checks a presented code against a secret, over the accepted window, and reports which step matched.
**The step is returned rather than a bare boolean**, and that is the whole interface to the replay guard: the caller writes the matched step to `users.mfa_last_step` through `AcceptMFAStep`, which refuses anything not strictly greater. A verifier that answered yes-or-no would leave the caller guessing which of the three accepted steps to record, and recording the wrong one either lets the code work twice or refuses the next two windows.
The comparison is constant-time. Six digits is a small space and the code is compared against three candidates on a route an attacker can drive, so a byte-wise early exit is a timing oracle on the first digits — cheap to remove and awkward to reason about if it is left in.
Steps are tried nearest-first, so an ordinary in-window code costs one HMAC and the skew allowance costs two more only when it is needed.
func ValidateEmail ¶
ValidateEmail is the gate on every path that writes an address: creating the first account, issuing an invitation, and starting a registration. It is not on the login path, where the address is compared and never sent to.
The regex above is permissive on purpose, and the second check is what stops permissive becoming unsendable. `net/mail.ParseAddress` is the parser the mailer itself uses, so an address that passes the pattern and fails the parser is one this product will accept, store, and then fail to send to — which is what F53 was: nine forms including `a<b@c.de`, `a,b@c.de` and `user@exa(mple.com` matched the pattern, committed a `pending_registrations` row, and then answered 500 from the enqueue, a status the API does not declare. Checking here rather than in signup closes it for invitations too, which reach the same enqueue through a different door.
Strictly a narrowing: every address the parser accepts and the pattern does not — `Barry Gibbs <bg@example.com>` is the shape — is still refused, because the pattern runs first and because a display-name form is not the address somebody typed.
func WithClientIP ¶ added in v0.2.0
WithClientIP carries the resolved client address down to the service layer.
It lives here, beside AnonymizeIP and Identity, rather than in the HTTP layer where it is set. Services take an *Identity and no request, and an audit event has to record the network a change came from — so without a carrier, every service method that will ever write an audit event grows an address parameter, and every caller of those methods grows one too. Five later milestones write audit events; that is the retrofit M21 exists to avoid.
A context value rather than a field on Identity because it is a property of the request, not of who is making it: the same identity acts from different networks, and Identity is also built outside a request entirely, by the CLI.
func WritePassword ¶ added in v0.3.0
func WritePassword( ctx context.Context, q *dbgen.Queries, h *Hasher, userID uuid.UUID, password string, ) error
WritePassword hashes a password and stores it against an account.
**The product's one password-writing path**, and it is exported for that reason rather than for reuse. Two of them existed the moment M51 needed to write a password without a session to verify against: this function is what POST /account/password reaches through ChangePassword below, and what a completed recovery reaches through internal/recovery. One statement, one hasher, one place where `failed_login_count` and `locked_until` are cleared — which matters more than it looks, because an account recovered while locked out by the guessing that made its owner reset it would otherwise still refuse the new password.
It takes the Queries rather than reading the service's own, so a caller inside a transaction passes the transactional handle and the write joins whatever else that transaction is doing. Recovery needs exactly that: spending the token and setting the password must not be separable.
Types ¶
type APIKeyAuditor ¶ added in v0.2.0
type APIKeyAuditor interface {
RecordAPIKeyRotation(ctx context.Context, actor *Identity, ev APIKeyRotation) error
RecordAPIKeyRevocation(ctx context.Context, actor *Identity, ev APIKeyRevocation) error
// RecordAPIKeyReachRevocation is an administrator cutting their organization
// out of an account-wide key (M54). A separate method rather than a flag on
// the one above, because the two are different acts and the record is the
// only place the difference is visible: one destroyed a credential, the other
// narrowed somebody else's.
RecordAPIKeyReachRevocation(ctx context.Context, actor *Identity, ev APIKeyRevocation) error
}
APIKeyAuditor records key-lifecycle events.
Declared here as an interface rather than taken as an *audit.Service, because internal/audit imports internal/auth — the writer resolves an actor into the label it stores — so the dependency runs one way and this is the seam. *audit.Service satisfies it.
type APIKeyConfig ¶
type APIKeyConfig struct {
// Pepper keys the HMAC. Required; a short one is refused rather than
// silently accepted, because a weak pepper is invisible in behaviour.
Pepper []byte
// UsageFlushInterval is how often buffered last_used_at values are
// written. Coarse on purpose: the value answers "is this key still in
// use", which does not need second resolution.
//
// It is also the tolerance on that answer, and rotation depends on the
// number: a predecessor that reads as idle may have been used up to this
// long ago, which is why MinRotationGrace sits an order of magnitude above
// it.
UsageFlushInterval time.Duration
// Auditor records rotations, and one administrator revoking somebody else's
// key. Optional — a nil one means the operation still happens and is logged
// as unrecorded, which is the same trade every other service makes with its
// audit writer.
Auditor APIKeyAuditor
Logger *slog.Logger
}
APIKeyConfig configures the key service.
type APIKeyInfo ¶
type APIKeyInfo struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Prefix string `json:"prefix"`
Scopes []string `json:"scopes"`
// OrgWide is the workspace choice made when the key was created: false for
// a key bound to one workspace, true for one not pinned to any (a NULL
// workspace_id). Reported rather than left implicit, because the two are
// otherwise indistinguishable in a list and they are not the same credential.
//
// Not pinned is not *all at once*: there is no per-request workspace
// selector, so a request made with such a key resolves exactly one workspace
// the way a sign-in does, bounded to the organization the key was issued in
// (D90). The qualifier is here because leaving it out cost two readers a
// high-severity misfiling — F122, and this field is one of the sites F139
// found still saying it short.
OrgWide bool `json:"org_wide"`
// OrganizationID is the key's **reach** (M54): the organization it is pinned
// to, or null for a key that reaches every organization its owner holds an
// organization-wide membership in.
//
// Reported rather than derived, because a null and a set value are two
// different credentials and nothing else in this struct distinguishes them.
// OrgWide above answers a different question one tier down — whether the key
// is pinned to a *workspace* — and the two are not redundant: a
// workspace-bound key is pinned to an organization by construction, so
// OrgWide false implies this is set, while OrgWide true says nothing about
// it either way.
OrganizationID *uuid.UUID `json:"organization_id"`
// RevokedOrganizations are the organizations an administrator has cut this
// key out of, and it closes F178 — the seeing half of M54's reach
// revocation, whose acting half shipped without it.
//
// A credential that has silently stopped resolving into one tenant read on
// this page exactly as it did the day before, and the audit record that says
// why is written in the administrator's organization, which the owner may
// hold no audit.read in. The support question that produced the row is *why
// does my key work in Acme and not in Beta*, and until this field there was
// no answer anywhere the owner could reach.
//
// Always an array, never null, and empty for every pinned key: an
// organization is a pinned key's whole reach, so cutting it is an outright
// revoke and RevokedAt is where that shows.
RevokedOrganizations []APIKeyOrgRevocation `json:"revoked_organizations"`
LastUsedAt *time.Time `json:"last_used_at"`
ExpiresAt *time.Time `json:"expires_at"`
RevokedAt *time.Time `json:"revoked_at"`
// RotatedAt, GraceExpiresAt and SuccessorID describe a key that has been
// replaced. All three are set together, on the predecessor, and all three
// are nil on a key that has not been rotated. GraceExpiresAt is the moment
// it stops authenticating anything.
RotatedAt *time.Time `json:"rotated_at"`
GraceExpiresAt *time.Time `json:"grace_expires_at"`
SuccessorID *uuid.UUID `json:"successor_id"`
CreatedAt time.Time `json:"created_at"`
}
APIKeyInfo is a key as its owner sees it. The secret is absent by construction: it is never stored, so it cannot be listed.
type APIKeyOrgRevocation ¶ added in v0.3.0
type APIKeyOrgRevocation struct {
OrganizationID uuid.UUID `json:"organization_id"`
OrganizationName string `json:"organization_name"`
RevokedAt time.Time `json:"revoked_at"`
}
APIKeyOrgRevocation is one organization cut out of an account-wide key's reach, as the key's owner sees it.
The name is carried and not only the id, because the question this answers is asked in words — *why does my key work in Acme and not in Beta* — and a uuid answers it in none of them. It discloses nothing the owner did not already have: a bar can only exist on a key whose owner held an organization-wide membership there when it was written.
Who did it is deliberately absent. api_key_org_revocations carries revoked_by, and naming an administrator of another organization to a credential's holder is a disclosure this field was not asked for; the audit record in that organization is where the actor is recorded, for the people who may read it.
type APIKeyRevocation ¶ added in v0.2.0
APIKeyRevocation is one administrator stopping somebody else's key.
Only the prefix, never the token: the prefix is the public half by construction — stored, indexed, and printed in the key list — and the secret is not in the row this is built from.
type APIKeyRotation ¶ added in v0.2.0
type APIKeyRotation struct {
PredecessorID uuid.UUID
PredecessorPrefix string
SuccessorID uuid.UUID
SuccessorPrefix string
GraceExpiresAt time.Time
Scopes []string
// ScopesNarrowed says the successor holds fewer scopes than the key it
// replaced. Recorded because the interesting rotation to find afterwards is
// the one that changed what the credential could do.
ScopesNarrowed bool
OrgWide bool
// ReachNarrowed says the successor is pinned to one organization where the
// key it replaced was account-wide. Recorded beside ScopesNarrowed and for
// the same reason: the rotation worth finding afterwards is the one that
// changed what the credential could reach, and reach is now two axes.
ReachNarrowed bool
}
APIKeyRotation is one rotation, as the audit log records it.
type APIKeyService ¶
type APIKeyService struct {
// contains filtered or unexported fields
}
APIKeyService issues, lists, revokes and authenticates API keys.
It sits alongside Service rather than inside it because the two answer different questions with different inputs — a password and a cookie versus a bearer token and a pepper — and only this one needs a secret from configuration. Both resolve to the same Identity, so nothing downstream can tell which credential a request arrived with unless it asks.
func NewAPIKeyService ¶
func NewAPIKeyService(pool *pgxpool.Pool, authSvc *Service, cfg APIKeyConfig) (*APIKeyService, error)
func (*APIKeyService) Authenticate ¶
Authenticate resolves a bearer token to an identity.
The identity's permissions are the intersection of the owner's current role and the key's scopes, recomputed on every request. So demoting a user weakens their keys at once, and a scope the role no longer grants stops working without the key having to be reissued.
func (*APIKeyService) Close ¶
func (s *APIKeyService) Close(ctx context.Context) error
Close flushes buffered usage timestamps and stops the writer.
func (*APIKeyService) Create ¶
func (s *APIKeyService) Create(ctx context.Context, actor *Identity, in CreateAPIKeyInput) (*CreatedAPIKey, error)
Create issues a key and returns the only copy of its token.
The token is not recoverable afterwards by design: only the HMAC is stored, which is the same reasoning as never storing a password. A caller who loses it revokes the key and issues another.
func (*APIKeyService) FlushUsage ¶
func (s *APIKeyService) FlushUsage(ctx context.Context) error
FlushUsage writes buffered last_used_at values immediately. Called by Close, and by tests that would otherwise have to sleep.
func (*APIKeyService) List ¶
func (s *APIKeyService) List(ctx context.Context, actor *Identity) ([]APIKeyInfo, error)
List returns the actor's own keys.
Own, not the workspace's: a key is a personal credential acting as its owner, and showing one user another's credentials serves no purpose that listing memberships does not serve better.
**Own, and not the organization's either** (M54, closing F75). The list used to be scoped by owner *and* organization while Revoke was scoped by owner alone, so a key issued elsewhere was invisible here and revocable there — a 204-versus-404 oracle over guessed ids. The fix is not a filter on the revoke: that would make a key unrevokable from the organization somebody is signed into, which F75's own severity called worse than the defect. It is that both statements now ask the one question a personal credential admits, and an account-wide key has no organization to be filtered by in the first place.
The cost, stated because it is a real change in what this page shows: somebody who belongs to two organizations sees every key they own from either, rather than the current organization's. That is what a personal access token list looks like everywhere else, and the reach column is what tells them apart.
func (*APIKeyService) MayCreateOrgWide ¶ added in v0.2.0
MayCreateOrgWide reports whether this actor may issue a key that reaches every workspace in the organization.
The check is **not** `actor.Can(PermAPIKeysWrite)`, and the difference is the whole point. `Can` answers from the union of every membership matching the workspace being acted in (D31), so an actor holding `apikeys.write` through a membership scoped to one workspace answers yes to it — and issuing an organization-wide key on the strength of a workspace-scoped role is precisely the shape F27 had. D44's rule is that a write is authorized against the membership whose scope covers its target, and an organization-wide key's target is the organization: `In(nil)` is that question, and only an organization-wide membership reaches it.
No new permission was minted for this, deliberately. A permission is held per *role*, and roles are granted per membership, so an `apikeys.org_scope` would have been held by a workspace-scoped admin exactly as `apikeys.write` already is — the new slug would have looked like a gate and enforced nothing the wrong check was already failing to enforce.
Also gated on being a session, because Create is: a key cannot mint a key at all, so it certainly cannot mint a wider one.
func (*APIKeyService) Revoke ¶
Revoke disables a key immediately.
Immediately in the literal sense: nothing about a key is cached, so the next request presenting it fails. That is the reason revocation is checked in the verification query rather than kept in a cache alongside the hash.
Two revokes behind one id, tried in that order. Own key first, which is the ordinary path and needs no authority beyond apikeys.write. Somebody else's second, and only for an actor holding apikeys.write from an organization-wide membership — a key belongs to the organization it was issued into, so reaching one is an organization-wide act and a workspace-scoped admin does not reach it (D44). It exists because there was otherwise no answer at all to a key that had to be stopped and whose owner would not stop it.
func (*APIKeyService) Rotate ¶ added in v0.2.0
func (s *APIKeyService) Rotate(ctx context.Context, actor *Identity, in RotateAPIKeyInput) (*RotatedAPIKey, error)
Rotate issues the successor to the key the request authenticated with.
Returns the only copy of the successor's token that will ever exist, exactly as Create does, and the deadline the predecessor now carries.
func (*APIKeyService) Start ¶
func (s *APIKeyService) Start()
Start launches the background writer for last_used_at.
type Authority ¶ added in v0.2.0
type Authority struct {
// Granted is whether any membership reaching the scope grants the
// permission. False is the whole refusal — no rank comparison follows.
Granted bool
// Rank is the lowest rank among the memberships that both reach the scope
// **and** grant the permission: the authority actually being carried, which
// is what a rank bound must be evaluated against. NoRoleRank when none does,
// so an ungranted Authority outranks nothing.
Rank int32
// Role is the slug behind Rank, for refusals that name the rule rather than
// the person. Empty when nothing was granted.
Role string
}
Authority is what one actor may exercise over one object: whether they hold a permission in that object's scope at all, and the rank of the membership that carried it there.
It is the companion to Identity.Can, and the two answer deliberately different questions. Can answers *what may this person do in the workspace they are acting in*, from the union of every membership matching it and the lowest rank among them (D31). That is the right answer for an object that lives in a workspace — a link, a tag, a key — and the wrong one for an object that spans the organization, because the union silently lends the reach of one membership to the authority of another.
M28's reopening is the reason this type exists. An actor holding an organization-wide `viewer` row and a workspace-scoped `admin` row resolves, inside that workspace, as an admin at rank 20 — and every member write then scoped by `actor.OrgID` alone, so `mayManage` compared that borrowed rank against their **own organization-wide membership** and answered yes. One dropdown on /members made them an organization-wide admin (F27).
The rule this restores is the one `LockOrganizationOwners` already states in SQL: a workspace-scoped membership grants authority over its own workspace, not over the organization.
type CreateAPIKeyInput ¶
type CreateAPIKeyInput struct {
Name string
Scopes []string
ExpiresAt *time.Time
// OrgWide asks for a key that is not pinned to the workspace its creator was
// acting in. Each request still resolves exactly one, the way a sign-in
// does, within the organization the key is issued in — see APIKeyInfo.OrgWide
// and D90.
//
// Opt-in, and false is the behaviour every key had before M44. Being able to
// act in any of an organization's workspaces is not something to grant
// because somebody left a field blank, and the check behind it is not the
// ordinary permission check — see MayCreateOrgWide.
OrgWide bool
// OrganizationID pins the key to one organization instead of leaving it
// account-wide (M54). Nil is account-wide, and it is the default for an
// unpinned key: a key is minted by an *account* and reaches the organizations
// that account belongs to, the way a personal access token does.
//
// The only value accepted is the organization the caller is acting in.
// Minting into another one would need authority there that nothing has
// checked, and the caller can switch organization and mint again — which is
// the same act with the authorization visible.
//
// Ignored, not refused, when OrgWide is false: a workspace-bound key is
// pinned to the workspace's organization by construction, and the check
// constraint added in 04200 refuses any other combination outright. Naming
// the caller's own organization there is therefore a no-op rather than a
// contradiction, and naming a different one is still a validation error.
OrganizationID *uuid.UUID
}
CreateAPIKeyInput describes a new key.
type CreatedAPIKey ¶
type CreatedAPIKey struct {
APIKeyInfo
Key string `json:"key"`
}
CreatedAPIKey is the response to creating a key: the record, plus the only copy of the token that will ever exist.
type Hasher ¶
type Hasher struct {
// contains filtered or unexported fields
}
Hasher hashes and verifies passwords.
The semaphore is the reason this is a struct rather than free functions. Each hash allocates 64 MiB, so N concurrent logins allocate N x 64 MiB; a credential-stuffing burst would otherwise OOM the process. Limiting concurrent hashing bounds that at a fixed cost, and the login rate limiter keeps the queue behind it short.
func (*Hasher) DummyVerify ¶
DummyVerify performs a hash with the same cost as a real verification and discards the result.
Called when the account does not exist, so that login timing does not reveal whether an email is registered. Without it, "no such user" returns in microseconds while a real user costs ~50ms, which is a trivially measurable account-enumeration oracle.
func (*Hasher) NeedsRehash ¶
NeedsRehash reports whether a stored hash was made with weaker parameters than the current policy. Callers rehash on the next successful login, which is the only moment the plaintext is available.
type Identity ¶
type Identity struct {
UserID uuid.UUID
Email string
Name string
WorkspaceID uuid.UUID
OrgID uuid.UUID
SessionID uuid.UUID
Role string
// RoleRank orders roles against each other: lower binds tighter, so owner
// (10) outranks admin (20) outranks editor (30) outranks viewer (40).
//
// Carried on the identity rather than looked up where it is needed because
// it is a property of who the actor is, exactly like Role, and the first
// consumer — the invitation role ceiling (D28) — must not be able to reach
// the wrong membership by asking a second time. It fails closed: an identity
// whose role could not be resolved gets NoRoleRank, which outranks nothing.
RoleRank int32
// APIKeyID is set when the request authenticated with an API key instead
// of a session cookie. Services consult it for the few operations that
// must require an interactive sign-in; everything else is deliberately
// blind to which credential was used.
APIKeyID *uuid.UUID
// APIKeyOrgID is the organization an API key is **pinned** to, and nil for
// every other case: a session, and an account-wide key (M54).
//
// Two nils meaning different things is worth the warning, and IsAPIKey is
// what tells them apart. A session has no key and no pin; an account-wide
// key has a key and no pin, because it reaches every organization its owner
// holds an organization-wide membership in rather than one named at mint
// time. OrgID above is where *this request* landed and is set for all three.
//
// It is carried because one rule still turns on the distinction. F103 bounded
// a key's reads to the organization it was issued for, and that reasoning
// survives for a pinned key and dies for an account-wide one — the premise
// was that a key is issued for one organization, which is no longer true of
// every key. Service.Workspaces is the site.
APIKeyOrgID *uuid.UUID
// APIKeyBarredOrgIDs are the organizations an administrator has cut this
// **account-wide** key out of (M54's reach revocation), and it is empty for
// every other case: a session, and a pinned key, which never has a
// revocation row because its organization is its whole reach.
//
// Carried rather than fetched because F183 is a read bound and a read bound
// that costs a query per listing is one somebody will later be tempted to
// drop. ResolveOrganizationForAPIKey has to consult these rows anyway to
// decide where the request lands, so the whole barred set rides back with
// the one organization it chose, on the round trip that was already
// happening.
//
// Nil for a pinned key is not the same nil as APIKeyOrgID's: there is
// nothing to bar, not an unknown. keyReaches is where the two are read
// together.
APIKeyBarredOrgIDs []uuid.UUID
// contains filtered or unexported fields
}
Identity is an authenticated user together with the workspace they are acting in. Both the REST handlers and the dashboard handlers resolve to this same type, so authorization cannot diverge between the two surfaces.
func (*Identity) Can ¶
Can reports whether the identity holds a permission.
This is the RBAC evaluator, and it is deliberately called from the service layer rather than from middleware. Middleware only knows the route; the service knows which workspace the object being touched belongs to, which is the question that actually matters.
func (*Identity) HasOrganization ¶ added in v0.2.0
HasOrganization reports whether this identity belongs to an organization.
False is a real, reachable state since D36 — an account whose only organization was deleted keeps its account and loses its tenancy — and it is what the dashboard reads to send somebody to the page that offers them one. It is an affordance, never the enforcement: what such an identity may do is decided by its empty permission set, like everybody else's.
func (*Identity) Permissions ¶
Permissions returns the identity's permissions, for API-key scope intersection and for rendering the UI.
type LockoutPolicy ¶
LockoutPolicy throttles repeated failed logins for one account.
Per-account, complementing the per-IP rate limit. Neither alone is enough: per-IP misses a distributed attack on one account, and per-account lets an attacker lock a victim out by failing on purpose — which is why this uses a short expiring window rather than a lock an administrator must clear.
func (LockoutPolicy) LockedUntil ¶
LockedUntil returns when a lockout expires, or the zero time if the account is not locked.
func (LockoutPolicy) ThresholdParam ¶
func (p LockoutPolicy) ThresholdParam() int32
ThresholdParam and WindowSecondsParam narrow the policy for the SQL that applies it.
Clamped, not converted. A configured value large enough to wrap would arrive in the query as a negative threshold, and `failed_login_count + 1 >= -3` is true on the first attempt — a nonsense setting would lock every account out on one typo instead of being ignored.
func (LockoutPolicy) WindowSecondsParam ¶
func (p LockoutPolicy) WindowSecondsParam() int32
type LoginInput ¶
LoginInput is a sign-in attempt.
type LoginResult ¶
type LoginResult struct {
Identity *Identity
Token string
Expires time.Time
// Pending is set instead of the three fields above when the account has a
// second factor and it has not been presented yet (M53).
//
// **Set means nothing else is.** Identity is nil, Token is empty and Expires
// is the zero time, so a surface that forgets to check this hands the browser
// an empty cookie rather than a working one — a failure that is visible on
// the first request instead of being an authentication bypass. Callers use
// SecondFactorRequired rather than testing the field, so the invariant has one
// name.
Pending *PendingSecondFactor
}
func (*LoginResult) SecondFactorRequired ¶ added in v0.3.0
func (r *LoginResult) SecondFactorRequired() bool
SecondFactorRequired reports whether this result is a challenge rather than a session.
type MFAAuditor ¶ added in v0.3.0
type MFAAuditor interface {
RecordMFAChange(ctx context.Context, actor *Identity, ev MFAChange) error
}
MFAAuditor records second-factor changes.
The seam onto internal/audit, in the shape APIKeyAuditor already established: internal/audit imports this package to resolve an actor into the label it stores, so this package cannot import that one. Nil records nothing.
type MFAChange ¶ added in v0.3.0
type MFAChange struct {
Kind MFAChangeKind
UserID uuid.UUID
Email string
// RecoveryCodesRemaining is the unspent count after the change. Meaningful
// for every kind: ten after an enrolment or a regeneration, zero after a
// disable, and the number that makes "you have two left" worth sending after
// a recovery code is spent.
RecoveryCodesRemaining int64
}
MFAChange is one such event, as the audit and notification seams see it.
It carries no secret and no code — not the TOTP secret, not a recovery code, not a hash of one. What a reader of either surface gets is *what changed* and *how many codes are left*, which is the whole of what either is read for.
type MFAChangeKind ¶ added in v0.3.0
type MFAChangeKind string
MFAChangeKind is what happened to an account's second factor.
const ( MFAEnabled MFAChangeKind = "enabled" MFADisabled MFAChangeKind = "disabled" MFARecoveryCodeUsed MFAChangeKind = "recovery_code_used" MFARecoveryCodesRegenerated MFAChangeKind = "recovery_codes_regenerated" )
type MFACipher ¶ added in v0.3.0
type MFACipher struct {
// contains filtered or unexported fields
}
MFACipher encrypts and decrypts TOTP secrets.
AES-256-GCM, from `crypto/aes` and `crypto/cipher`. Authenticated, so a tampered ciphertext is a decryption failure rather than a secret that decodes to something an attacker chose; nonce-per-message, so the same secret written twice produces different bytes and the column tells nobody which accounts share a configuration mistake.
func NewMFACipher ¶ added in v0.3.0
NewMFACipher derives the key and prepares the cipher.
**The configured value is hashed to the key rather than used as one.** SHA-256 of the raw bytes, which accepts whatever an operator generated — `openssl rand -base64 48` produces 64 characters, and a 64-byte string is not an AES key. The alternative is demanding an exactly-32-byte base64 blob, which is a documentation problem that produces a support problem; hashing costs one invocation at boot and makes every value that clears the length floor work.
func (*MFACipher) Open ¶ added in v0.3.0
Open decrypts a stored secret.
Every malformed input answers ErrMFASecretUnreadable — an unknown scheme, bad base64, a value shorter than a nonce, a failed tag check. One error for all of them because they are one operator problem, and because a caller that could tell them apart would be tempted to treat some of them as recoverable.
func (*MFACipher) Seal ¶ added in v0.3.0
Seal encrypts a base32 TOTP secret for storage.
Output is `1.<base64url(nonce||ciphertext||tag)>`, ASCII, which is what goes in `users.mfa_secret` — a `text` column since `00200_identity.sql`, so the encoding is not an aesthetic choice.
No additional authenticated data, and the omission is deliberate rather than an oversight. Binding the ciphertext to the account id would stop a row being moved between accounts by somebody with write access to the database — who can also simply write their own secret, having the key or not. It would also make the column unreadable after a restore that renumbered anything. The threat this encryption is for is a leaked dump, and AAD does nothing about that one.
type MFAConfig ¶ added in v0.3.0
type MFAConfig struct {
// Auth verifies the account's own password and mints the session a completed
// second factor earns. Required: there is no second factor without a first
// one, and no second place a session is created.
Auth *Service
// Cipher reads and writes the secret at rest. **Nil is an instance with no
// MFA_SECRET_KEY**, and it is the one dependency here whose absence is a
// refusal rather than a degradation — an enrolled account signs in with a
// recovery code, which needs no key, and everything else refuses.
Cipher *MFACipher
// Issuer is what an authenticator app files the entry under. The instance's
// own host, so somebody with three of these on their phone can tell them
// apart.
Issuer string
// Audit records the change. Nil records nothing.
Audit MFAAuditor
// Notify tells the account holder. Nil tells nobody.
Notify MFANotifier
Log *slog.Logger
}
MFAConfig is what an MFAService needs.
type MFAEnrolled ¶ added in v0.3.0
type MFAEnrolled struct {
// RecoveryCodes are shown on this response and never again. Nothing stores
// them in a readable form, so a person who does not write them down has the
// regenerate button and nothing else.
RecoveryCodes []string
}
MFAEnrolled is what a completed enrolment hands back, once.
type MFAEnrolment ¶ added in v0.3.0
type MFAEnrolment struct {
// Secret is base32, as an authenticator app expects it and as it is shown in
// text beside the QR code — because a person enrolling from the same device
// cannot scan their own screen.
Secret string
// URI is the `otpauth://` string the QR code encodes. Rendered through
// internal/qr by the surface; this package does not know what a QR code is.
URI string
}
MFAEnrolment is an offer: a fresh secret and the URI that carries it.
**Nothing is stored yet**, which is m53.md's *half-enrolled is not a state this product has* expressed as an absence. The secret exists in this value and in the form the person is looking at, and it reaches `users.mfa_secret` only in the statement that also sets `mfa_enabled_at`, and only after a code computed from it has verified. An enrolment that is started and abandoned leaves the account byte-for-byte as it was, and TestAnAbandonedEnrolmentLeavesTheAccountAlone is what holds that.
The cost of not storing it is that the offer travels back through the form, so the confirm step is trusting the browser to return the secret it was given. Origin-checked CSRF is what stops a third party posting a secret of their own — the same protection every other state-changing form in this product rests on — and the alternative, parking a candidate secret on the account row, is precisely the half-enrolled state the milestone forbids.
type MFANotifier ¶ added in v0.3.0
MFANotifier tells the account holder.
A separate seam from the auditor because the audiences are different: an audit record is read by whoever administers the instance, and this reaches the person whose credential changed. m53.md asks for it by name on the path that matters most — *a recovery code being spent is the signal that either the phone is gone or somebody else has it* — and the others are here because a second factor appearing on, or vanishing from, your own account is the same kind of news.
The recipient is the subject rather than a parameter: every one of these events is about one account and is told to that account.
type MFAService ¶ added in v0.3.0
type MFAService struct {
// contains filtered or unexported fields
}
MFAService owns the second factor.
func NewMFAService ¶ added in v0.3.0
func NewMFAService(pool *pgxpool.Pool, cfg MFAConfig) (*MFAService, error)
func (*MFAService) Available ¶ added in v0.3.0
func (m *MFAService) Available() bool
Available reports whether this instance can enrol anybody.
Read by both surfaces before they draw the enrolment offer, so nobody starts something the instance was never going to finish — the shape ForgotPage uses for a mail-free instance. Every operation below refuses again on its own, because a surface remembering to ask is not the invariant.
func (*MFAService) BeginEnrolment ¶ added in v0.3.0
func (m *MFAService) BeginEnrolment(ctx context.Context, actor *Identity) (*MFAEnrolment, error)
BeginEnrolment offers a secret.
Session actors only. A key is not a person and has no second factor to enrol, and D87's limb — the session is the authority for operations whose subject is the person — covers this one exactly.
func (*MFAService) CompleteSecondFactor ¶ added in v0.3.0
func (m *MFAService) CompleteSecondFactor( ctx context.Context, token, code string, ip netip.Addr, userAgent string, ) (*LoginResult, error)
CompleteSecondFactor finishes a sign-in that stopped at the prompt.
The only place a pending login becomes a session, and the order below is the state machine m53.md wants adversarially tested:
- The pending row is located and locked. Anything wrong with it — unknown, lapsed, spent, an account that stopped being active — is one refusal.
- The factor is consumed: a TOTP code against the decrypted secret with its step recorded, or a recovery code spent. A failure here increments the account's own `failed_login_count` through the same policy a wrong password does, so the two share a budget rather than the second factor handing out a fresh one.
- The pending row is spent, in the same transaction. Single use is the statement's predicate, so two browsers presenting the same token race into it and exactly one wins.
- Only then is `RecordSuccessfulLogin` called and a session minted.
func (*MFAService) ConfirmEnrolment ¶ added in v0.3.0
func (m *MFAService) ConfirmEnrolment( ctx context.Context, actor *Identity, secret, code string, ) (*MFAEnrolled, error)
ConfirmEnrolment turns an offered secret into the account's second factor.
The order is the whole of it: verify a code computed from the offered secret, then write the secret and the timestamp in one statement, then issue the recovery codes — all inside one transaction, so an account never has a factor without codes or codes without a factor.
func (*MFAService) Disable ¶ added in v0.3.0
Disable takes the second factor away.
**The password and a current code, or the password and a recovery code.** Both halves, because either alone is a downgrade somebody else can perform: a stolen session would otherwise remove the factor it was supposed to be stopped by, and a phone found unlocked on a train would do the same. m53.md asks for exactly this pairing.
Session actors only, and this is the D87 limb m53.md names by test: an API key is not a person, and disabling somebody's second factor is an operation whose subject is the person.
func (*MFAService) PurgePendingLogins ¶ added in v0.3.0
PurgePendingLogins removes lapsed and spent rows, reporting how many went.
Called by the hourly maintenance pass beside the signup and recovery sweeps, for the reason those exist: a waiting room with no sweep is a table that grows forever with nothing watching it.
func (*MFAService) RegenerateRecoveryCodes ¶ added in v0.3.0
func (m *MFAService) RegenerateRecoveryCodes( ctx context.Context, actor *Identity, ) ([]string, error)
RegenerateRecoveryCodes voids the previous set and issues a new one.
Every code, spent ones included, because the previous set is void in full and a count of leftovers from a void set would be a lie. The account keeps the same TOTP secret: this is the answer to *I have lost the paper*, not to *I have lost the phone*.
type MFAStatus ¶ added in v0.3.0
type MFAStatus struct {
// Available is the instance's answer: is there a key to encrypt a secret
// with. False draws an explanation instead of an offer.
Available bool
Enabled bool
EnabledAt *time.Time
// RecoveryCodesRemaining is how many unspent codes are left. Rendered as a
// number because it is one somebody acts on: three left is a prompt to
// regenerate, and zero with a lost phone is a conversation with the operator.
RecoveryCodesRemaining int64
}
MFAStatus is the second factor as the account page describes it.
type MembershipAuthority ¶ added in v0.2.0
type MembershipAuthority struct {
// contains filtered or unexported fields
}
MembershipAuthority answers Authority per scope, from one load of an actor's memberships in one organization.
Loaded once and folded per scope rather than queried per object, because the member list asks the same question for every row it draws a control on and a query per row is a query per row. An organization's memberships are a handful by construction — the same reason ListMembers is not paginated.
func LoadMembershipAuthority ¶ added in v0.2.0
func LoadMembershipAuthority( ctx context.Context, q *dbgen.Queries, userID, orgID uuid.UUID, permission string, ) (*MembershipAuthority, error)
LoadMembershipAuthority reads an actor's memberships in one organization, with the rank and the permission grant each carries.
The queries handle is a parameter so a caller inside a transaction passes its own: the authority a write is authorized by must be read under the same lock the write takes, or it is a check-then-act.
func LoadMemberships ¶ added in v0.3.0
func LoadMemberships( ctx context.Context, q *dbgen.Queries, userID, orgID uuid.UUID, ) (*MembershipAuthority, error)
LoadMemberships is the same load with the permission question left out: which scopes does this actor hold a membership in at all, never mind what it grants.
It exists for M54. An account-wide API key's authority has to be established in an organization the caller is *not* acting in — an administrator cutting their tenant out of somebody else's key has to know whether the key reaches it — and the question there is membership, not permission. Passing a permission slug would be noise a later reader would try to interpret; the empty string matches no row in `permissions`, so every GrantsPermission comes back false and Reaches below is the only honest thing to ask of the result.
func (*MembershipAuthority) In ¶ added in v0.2.0
func (m *MembershipAuthority) In(workspaceID *uuid.UUID) Authority
In answers for one scope.
A nil workspaceID is the **organization-wide** scope, which only an organization-wide membership reaches — that asymmetry is the entire point, and it is why this is not simply GetUserPermissions with a different signature. A set one is that workspace, which an organization-wide membership reaches as well, because such a membership covers every workspace in the organization.
A nil receiver answers ungranted, so a caller that skipped the load because the actor holds nothing cannot accidentally read authority out of it.
func (*MembershipAuthority) Permission ¶ added in v0.2.0
func (m *MembershipAuthority) Permission() string
Permission is the permission this was loaded for, so a refusal can name it without the caller carrying the slug alongside.
func (*MembershipAuthority) Reaches ¶ added in v0.3.0
func (m *MembershipAuthority) Reaches(workspaceID *uuid.UUID) bool
Reaches reports whether any membership covers the scope, ignoring what it grants. In's question minus the permission.
A nil workspaceID is the organization-wide scope, and only an organization-wide membership reaches it — the same asymmetry In relies on, and the reason this is the right test for an unpinned key. Such a key has always required an organization-wide membership (GetAPIKeyByPrefix's predicate refuses one covered by a workspace-scoped row), so asking whether it reaches a second organization is asking exactly this.
A nil receiver reaches nothing, for the reason In grants nothing.
func (*MembershipAuthority) Scopes ¶ added in v0.2.0
func (m *MembershipAuthority) Scopes() (orgWide bool, workspaceIDs []uuid.UUID)
Scopes is the same answer In gives, turned inside out: instead of "may this actor exercise the permission over that object", it is "which scopes may they exercise it over at all".
orgWide true means an organization-wide membership grants it, which reaches every workspace in the organization — the workspace list is then redundant and the caller should ignore it. Otherwise the list is exactly the workspaces whose own membership grants it, and it may be empty.
It exists because a *read* has no single object to ask In about. F31 is that gap: ListAuditLogs was scoped by the actor's organization alone, so a workspace-scoped admin read the rows of workspaces they hold no membership in. Answering that per row would be a query per row; answering it as a predicate needs the set, and this is the set.
A nil receiver answers "nothing, nowhere", so a caller that skipped the load cannot read authority out of it.
type Params ¶
type Params struct {
MemoryKiB uint32
Iterations uint32
Parallelism uint8
SaltLength uint32
KeyLength uint32
}
Params are the argon2 cost parameters.
Stored in the hash string itself (PHC format), so changing these does not invalidate existing passwords: an old hash still verifies against its own recorded parameters, and NeedsRehash reports that it should be upgraded on the next successful login.
type PendingSecondFactor ¶ added in v0.3.0
PendingSecondFactor is the challenge a caller hands the browser instead of a session.
The token is bearer-shaped and is the only thing that identifies the sign-in in flight; the account is deliberately not named in it, so a caller cannot render "signing in as …" from a value somebody else's browser could be holding.
type RegisterInput ¶
type RegisterInput struct {
Email string
Name string
Password string
// IsFirstUser marks the setup flow, which is permitted even when signup is
// closed — otherwise a fresh closed instance could never create its first
// account.
IsFirstUser bool
}
RegisterInput describes a new account.
type RotateAPIKeyInput ¶ added in v0.2.0
type RotateAPIKeyInput struct {
// Scopes narrows the successor. Nil means "identical to the predecessor's".
// A scope the predecessor does not hold is refused rather than trimmed,
// because silently dropping it would let a caller believe it was granted.
Scopes []string
// Grace is how long the predecessor keeps verifying. Zero means
// DefaultRotationGrace; anything outside [MinRotationGrace, MaxRotationGrace]
// is refused.
Grace time.Duration
// Reach narrows the successor's tenancy, which is D87's second axis (M54).
//
// Three states, and they are not two: Unset copies the predecessor verbatim,
// which is what every rotation did before and what an unattended one still
// does. ReachOrganization pins an account-wide predecessor to the
// organization the request resolved into. ReachAccount asks for account-wide
// and is refused for a pinned predecessor, because a successor may not reach
// more organizations than the key it replaces.
//
// The refusal is why the third state exists at all. Leaving it out would make
// pinned-to-account-wide unaskable rather than refused, and "you cannot do
// this" is a thing an API should be able to say rather than a shape it
// declines to have a word for.
Reach RotationReach
}
RotateAPIKeyInput describes a rotation. Every field is optional, and the zero value is the common case: same scopes, default grace.
type RotatedAPIKey ¶ added in v0.2.0
type RotatedAPIKey struct {
CreatedAPIKey
Predecessor RotatedPredecessor `json:"predecessor"`
}
RotatedAPIKey is the successor, plus the fate of the key it replaced.
type RotatedPredecessor ¶ added in v0.2.0
type RotatedPredecessor struct {
ID uuid.UUID `json:"id"`
Prefix string `json:"prefix"`
// StopsWorkingAt is the far edge of the grace window. After it the
// predecessor authenticates nothing, whether or not housekeeping has got
// round to writing its revocation.
StopsWorkingAt time.Time `json:"stops_working_at"`
}
RotatedPredecessor is what the caller needs to know about the key it just replaced: which one it was, and the deadline it now has.
type RotationReach ¶ added in v0.3.0
type RotationReach int
RotationReach is what a rotation asks of the successor's tenancy.
const ( // ReachUnchanged copies the predecessor's organization verbatim, NULL // included. The zero value, because it is the rotation an unattended // deployment makes. ReachUnchanged RotationReach = iota // ReachOrganization pins the successor to the organization this request // resolved into. Narrowing, and available to an account-wide predecessor. ReachOrganization // ReachAccount asks for a successor that reaches every organization its // owner belongs to. A no-op for a predecessor that is already account-wide, // and refused for one that is pinned. ReachAccount )
type Service ¶
type Service struct {
// contains filtered or unexported fields
}
Service owns registration, login and session lifecycle.
func NewService ¶
func NewService(pool *pgxpool.Pool, cfg ServiceConfig) *Service
func (*Service) Authenticate ¶
Authenticate resolves a session token to an identity.
func (*Service) ChangePassword ¶
func (s *Service) ChangePassword(ctx context.Context, userID, keepSession uuid.UUID, current, next string) error
ChangePassword updates a password and logs out every other session.
func (*Service) Hasher ¶
Hasher exposes the configured hasher for the CLI, which creates users outside a request.
func (*Service) IdentityForEmail ¶
IdentityForEmail resolves a user to an identity without a session.
For the CLI, which acts as a named user rather than as root: `lctl apikey create` goes through the same service call and the same permission checks a request would, so the CLI cannot mint a key the user could not.
func (*Service) Login ¶
func (s *Service) Login(ctx context.Context, in LoginInput) (*LoginResult, error)
Login authenticates and starts a session.
**Every failure is answered identically, and every failure costs the same.** Unknown address, wrong password, no local password set, suspended account, and an account already locked out by repeated failures are one answer to whoever asked, and each spends one argon2 verification on the way. Distinguishing any of them — by problem type, by status, by prose, or by how long the refusal takes — tells a stranger which addresses are registered.
The errors below stay distinct because the process wants them: a lockout is a different operational event from a typo, and a test can assert it. What must not differ is what a caller sees, so the two boundaries that answer a person collapse them — internal/httpx/problem.go for the API, internal/httpx/web.go for the sign-in form. That split is the one ErrAccountInactive has always had.
Finding F92 is why both halves are spelled out here. ErrAccountLocked used to reach the API as its own problem type and a 429, so the fifth wrong password against a registered address answered differently from the fifth against an unregistered one — unauthenticated, on the shipped `closed` default, where the registration oracle is refused before any lookup, and inside LOGIN_RATE_PER_MIN so the per-address limiter never masked it. It also returned before any verification, which made it *fast* where every other refusal pays a hash; a fix that equalised the status and not the work would have left the question answerable with a stopwatch.
func (*Service) NeedsSetup ¶
NeedsSetup reports whether the instance has no users yet.
func (*Service) Register ¶
Register creates a user with their personal organization, workspace and owner membership, in one transaction.
Provisioning all four together is what lets Phase 1 behave as a single-user product while every row already carries the tenancy columns Phase 2 needs. A user without a workspace would be a state no other code path expects, so it must not be possible to observe one.
func (*Service) SetDefaultWorkspace ¶ added in v0.2.0
func (s *Service) SetDefaultWorkspace(ctx context.Context, actor *Identity, workspaceID *uuid.UUID) error
SetDefaultWorkspace pins where new sessions start, or clears the pin.
nil means "follow last-used", which is what the control offers as its first option and what every account is on until somebody chooses otherwise (D22). The derived behaviour stays the default; this exists for the person it annoys.
Session-only for the same reason as SwitchWorkspace: it is an account preference, and a leaked key must not be able to decide where its owner's browser lands.
func (*Service) SwitchWorkspace ¶ added in v0.2.0
func (s *Service) SwitchWorkspace(ctx context.Context, actor *Identity, workspaceID uuid.UUID) error
SwitchWorkspace moves the caller's session, and remembers the choice.
Two writes in one transaction, because they mean different things and both have to happen: the session moves so the next request is already in the new workspace, and the user's last-used is updated so the next *session* starts there too. Half of that would be a switcher that either forgets on sign-in or does not take effect until one.
Requires a session, like changing a password does, and for two reasons rather than one. Half of what it does needs a session id: SetSessionWorkspace moves the caller's own session, and a key has none to move. The other half writes users.last_workspace_id, which is a property of the person — a key doing that would repoint where its owner's next sign-in lands, a side effect on somebody else's browser from a credential that cannot see it.
What is *not* a reason is that a key would leave its own requests alone. A workspace-scoped key acts where its row says, but an organization-wide one (M44) names no workspace and comes through resolveWorkspace above like a login, so last_workspace_id decides for it too whenever its owner has pinned no default.
func (*Service) VerifyPassword ¶ added in v0.3.0
VerifyPassword confirms an account's own password, and answers nothing else.
**The product's one re-verification path**, exported for the reason WritePassword above is: there is now more than one operation that asks somebody to prove they are still the person holding the account, and two copies of this would be two places deciding what a missing hash means and two places mapping a mismatch onto a refusal. ChangePassword is the first caller and account deletion (M52) is the second — irreversible operations gated on the credential rather than on the session alone.
A NULL `password_hash` is ErrInvalidCredentials rather than an error of its own. The column is nullable for an SSO-only account — **unbuilt, and nothing schedules it**; this read "(Phase 3)" until M58, and Phase 3 discharged only the MFA limb of that scope row (D109) — and for one this milestone's erasure pass has scrubbed, and neither of those can confirm a password by typing one — which is precisely what "invalid credentials" says.
No lockout counting, deliberately, and the same choice ChangePassword has always made: the caller already holds a live session for this account, so there is no credential-stuffing budget to spend and locking somebody out of their own settings page for mistyping is a denial of service against them. The rate limit on the routes that reach here is what bounds the guessing.
func (*Service) Workspaces ¶ added in v0.2.0
Workspaces lists what the actor may switch to, newest information first: the current one is flagged, and so is the pinned default if there is one.
Readable with any credential, including an API key. There is no permission for it because it exposes nothing but the caller's own memberships, which is the same reason the notification inbox has none.
A **pinned** key is bounded to the organization it was issued for, and a session is not. That is the difference between a person and a credential rather than a difference in trust: the switcher's whole job is to cross organizations, so a browser has to see all of them, while M44 spent an organization_id parameter specifically so a key could not *act* in a tenant it was never issued for. A key reading the list of every tenant its owner belongs to is the same bound missing from the read — the names and slugs of organizations whose data the key cannot touch, disclosed to whoever holds it. The filter is here and not in ListWorkspacesForUser because that query serves the switcher too, and adding the predicate there would break the one caller that must cross (F103).
**An account-wide key is not bounded, and the premise is what changed** (M54). F103's reasoning was that a key is issued for one organization, so reading about the others discloses tenants it cannot touch. That is still exactly true of a pinned key and false of an account-wide one, which is issued by an account and acts in the organizations that account belongs to — the tenants it would be reading about are the tenants it works in. Bounding it to the organization the current request happened to resolve into would also hide the only surface that says where else the credential reaches. F103's row is amended rather than closed: the finding it names still stands for the credential it was found on.
**Except where a revocation has removed that premise** (F183). *The tenants it works in* is what an account-wide key reads about, and an administrator cutting their organization out of its reach is precisely the act that makes an organization one the key no longer works in. The bound applied here was pinned-or-not, and a reach revocation applies to exactly the keys that are not pinned — so the barred organization's name, slug and workspace ids went on being listed to the credential it had been taken away from. That the holder is usually a legitimate member who can see all of it in a browser is why this is minor; the case the revocation exists for is the credential that is **not** in legitimate hands, which is the case where the administrator has been told the key is cut out and half of it was not. keyReaches is the one predicate now, and it costs no query: the barred set rode back on the resolution that had already happened.
type ServiceConfig ¶
type ServiceConfig struct {
Params Params
TTL SessionTTL
Lockout LockoutPolicy
}
type Session ¶
type Session struct {
ID uuid.UUID
UserID uuid.UUID
CreatedAt time.Time
LastSeenAt time.Time
ExpiresAt time.Time
}
Session is a live login.
type SessionTTL ¶
type SessionTTL struct {
// Absolute is the hard deadline from creation. A session dies at this
// point regardless of activity, which bounds how long a stolen token stays
// useful.
Absolute time.Duration
// Idle is the maximum gap between requests. Enforced against last_seen_at
// at read time rather than by rewriting expires_at, so changing the policy
// takes effect immediately and needs no data migration.
Idle time.Duration
}
SessionTTL bundles the two expiry rules.
type Workspace ¶ added in v0.2.0
type Workspace struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
// Organization is carried because a workspace name is only unique inside
// one. Two organizations both calling a workspace "Marketing" is normal, and
// a switcher that showed the workspace name alone would be unreadable.
OrganizationID uuid.UUID `json:"organization_id"`
OrganizationName string `json:"organization_name"`
IsPersonal bool `json:"is_personal"`
// Current is where this request is acting. Computed against the identity
// rather than stored, because "current" is a property of the request.
Current bool `json:"current"`
// Default marks the pinned workspace: where a new session starts. No entry
// carries it when the user is on last-used, which is the default state.
Default bool `json:"default"`
}
Workspace is one entry in the switcher.
Deliberately not the database row: the switcher needs a label and two flags, and handing the whole workspace out would put analytics retention and soft deletion on a JSON surface nobody asked for.