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 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 ValidateEmail(email string) error
- func WithClientIP(ctx context.Context, addr netip.Addr) context.Context
- type APIKeyAuditor
- type APIKeyConfig
- type APIKeyInfo
- 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 MembershipAuthority
- type Params
- type RegisterInput
- type RotateAPIKeyInput
- type RotatedAPIKey
- type RotatedPredecessor
- 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) 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 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.
Variables ¶
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 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 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 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.
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
}
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"`
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 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
}
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.
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
}
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
// 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 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 (*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) 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 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
}
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 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) 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 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).
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.