All docs

Docs · Reference

Users and credentials

the people and things your product knows

txco://user/ and txco://credential/ are where a stack keeps who: the people who use your product, the other things that act in it (a pony, a service), and the revocable passwords each of them signs in with. One person is one user across the tenant, with as many credentials as they have devices — each scoped to the doors it may open, each revocable on its own.

Upgrading (printing). The print head signs in with these credentials too. Its IPP_PASSWORD secret is gone: register each printer with txco://ipp/printer and issue its principal a credential with an ipp scope (IPP).

Upgrading. Accounts no longer hold passwords. txco://imap/account, calendar/account, contacts/account and drive/account take a principal instead and refuse password and rotate; every mail, calendar, contacts and drive client signs in with a credential issued here. A password set before this release has no credential id and stops working: re-run each account op with its principal, issue a credential, and give the new password to the client (Signing in).

These are your product’s users. They are not the accounts that administer a tenant (txco login, the admin UI): those live on a different plane, and nothing links the two.

Principals

A principal is whoever a credential signs in as — the thing a later access decision is asked about. It is written <kind>:<name>:

principalwhat it is
user:usr_7HqZ3kYb…a person. The chassis mints it (txco://user/create); it has a user record with a name and a status.
pony:paris, service:billing-sync, …anything else your product names. No user record — it exists as soon as it has a credential or a binding.

kind is lowercase letters, digits, _ and -. name is letters, digits and . _ @ + -. Ids are case-sensitive. You cannot name a user: principal into existence; only user/create makes one.

A principal is found by an identifier bound to it. user/create binds the user’s email; a login’s username resolves the same way.

The ops

All six are tenant-scoped, and answer at into (default _user or _credential).

# a claim flow: the magic link was clicked, so the address is proven
WHEN ._claim.ok == true
  EXEC "txco://user/create"
    WITH email        = ._claim.email,
         display_name = ._claim.name,
         verified     = true
# → _user = {id, principal, display_name, status, email, email_verified,
#            created_by, created_at, updated_at, created}
opWITHresult
user/createemail, display_name?, verified?the user, its principal, and created
user/getid | emailthe user (email, email_verified when looked up by email)
user/disableid, disabled? (default true; false re-enables)the user
credential/createprincipal, scopes (a list, or one scope as a string — build a computed one with &concat), label?, password_style? (token | words), password_words? (4–12)the credential and its password, once
credential/listprincipal, include_revoked?{principal, count, items[]}, newest first — never a secret
credential/revokeid (with principal to pin whose it must be) — or principal with except = an id, or all = true{id, principal, revoked} — or {principal, revoked_count}

user/create is safe to retry. It is idempotent on the email: creating a user whose address your stack already holds returns that user with created: false, so a redelivered message or a resumed task does not fail and does not split one person in two. verified = true upgrades an unverified address; nothing downgrades it. The display name is set on create only.

Disabling revokes nothing. A disabled user cannot be issued new credentials and will not be able to sign in, but their credentials are untouched, so disabled = false restores exactly what they had.

Credentials

# only for a user this run created — a retried claim finds the user again
# (`created: false`) and must not mint a second password
WHEN ._user.created == true
  EXEC "txco://credential/create"
    WITH principal      = ._user.principal,
         scopes         = ["imap:*:*", "calendar:*:*"],
         label          = "Alice's laptop",
         password_style = "words",
         redact         = "_credential.password"
# → _credential = {id, short_id, principal, kind, scopes, label,
#                  created_by, created_at, password}

The password is in the result once. Only its argon2id hash is stored; it cannot be read back, by you or by the chassis. Show it to the person and drop it. The chassis keeps it out of traces on its own; a redact as above is harmless.

Every password is generated. There is no password = "…" param. An issued password carries the id of its credential:

words   k7m2-river-galaxy-bamboo-orbit-velvet     for a person to type
token   txc_k7m2_p3vw8n…                          for a machine

That leading k7m2 is the credential’s short_id. A principal can hold many credentials, and the id is how a login finds the right one in a single lookup instead of trying each. It is not a secret — it is also how someone recognises “the one that starts k7m2” in a list when they want to revoke it.

Scopes

A credential opens only the doors its scopes name. Each is domain:instance:action, where instance and action may be *:

scopeopens
imap:*:*mail, any mailbox the principal has
drive:dc_7HqZ3k…:*one drive collection
ipp:front-desk:printprinting, to one printer

Scopes only narrow. What a credential can do is what its principal may do and what its scopes cover, so the password typed into the office printer cannot read mail.

The domain is never *: a credential names the heads it opens. A head that ships later is closed to every password already issued, until you issue one for it. At least one scope is required, at most 32. A principal may hold 64 live credentials.

Rotating

Issue the new credential, then revoke the rest:

# 3400: issue
WHEN ._rotate.principal != ""
  EXEC "txco://credential/create"
    WITH principal = ._rotate.principal, scopes = ._rotate.scopes,
         into = "_new", redact = "_new.password"

# 3410: revoke every other credential
WHEN ._new.id != ""
  EXEC "txco://credential/revoke"
    WITH principal = ._rotate.principal, except = ._new.id
# → _credential = {principal, revoked_count}

except must name a live credential of that principal, and a bare principal is refused. That is deliberate: if the issue step failed, its id is missing — and the op must not answer a missing id by revoking the one password that still works. To revoke everything, say so: all = true.

Revocation is permanent. The row stays (include_revoked = true lists it), and its short_id is never reused.

Revoking one device

# "remove this device": the id came from the person's own request
WHEN ._req.ok == true
  EXEC "txco://credential/revoke"
    WITH id = ._req.credential_id, principal = ._req.principal

Pass principal with an id that came from a request. The creator-stack rule is not a wall between one stack’s own principals: a product’s stack manages every one of its users, so a bare id would let any of them revoke another’s credential by guessing or learning its id. With principal, the id is revoked only if it is that principal’s; anyone else’s answers not_found, the same as an id that does not exist. Combining id with except or all is refused.

Signing in

The IMAP, CalDAV, CardDAV, WebDAV and IPP heads sign people in with these credentials. An account op (txco://imap/account and its siblings) binds the account’s username to a principal; the client then presents the username and a password credential/create issued to that principal:

username ──binding──▶ principal ──id in the password──▶ credential ──▶ verified
                                                             │
                                          its scopes must open this head
headthe scope a login needs
IMAPimap:<username>:login
CalDAVcalendar:<username>:login
CardDAVcontacts:<username>:login
WebDAVdrive:<collection-id>:login — the account’s own collection
IPPipp:<printer>:print — and the printer must be granted to the principal

So imap:*:* opens every mailbox the principal has, and nothing else; one credential with ["imap:*:*", "calendar:*:*", "contacts:*:*"] is one password for Mail, Calendar and Contacts. A drive account’s collection is what its principal may reach; the credential’s drive scope can only narrow it.

Printing has no account op. A printer is not a login: it is a row (txco://ipp/printer) that names the one principal who may print to it. That principal signs in with a username it already has — a user’s email, or one an account op bound — so a pony with a mailbox prints with its mail address. ipp:*:* opens every printer the principal is granted, and nobody else’s: the scope narrows, the grant decides.

Revocation is immediate. Each head remembers a verified password for a few minutes so a client’s stream of requests costs one hash, but every request reads the credential, so a revoked one is refused on its next use. An IMAP session is the one thing that signs in once and stays: it re-checks its credential about once a minute and is closed (BYE) within that minute of a revocation (IMAP). A disabled user cannot sign in until re-enabled, and loses open sessions the same way.

One budget for guesses. Password checks are limited per client IP and per principal across all five heads together (--login-rate, 30 a minute): guessing over CalDAV spends the same budget as guessing over IMAP. Behind a reverse proxy, name it in --web-trusted-proxies (and --imap-proxy-protocol), or “per client IP” means the proxy and every client shares one budget (serve.md).

Who is acting. A run a head starts on behalf of a signed-in client — an IMAP answer lane, a CalDAV observe, a print job — carries @principal.id, @principal.kind and @principal.credential. It is a read-only copy of what the chassis pinned; no request, rule or op can set it, and a run nobody signed in to has none. Usage lines and traces record the principal too.

The password stays out of traces. The chassis removes a password credential/create issued from every trace record of that request — as itself, and inside a response body that renders it — without a redact.

Which stack may manage a principal

Only the stack that created a principal may change it. Every record carries the stack that wrote it (created_by). The first stack to write anything for a principal — create the user, issue its first credential — owns it, and from then on another stack’s user/disable, credential/create, credential/list or credential/revoke for it answers not_owner, naming the owner. Granting it a printer (txco://ipp/printer) follows the same rule, and needs the principal to exist already.

  • A canary slot counts as its stack: web/canary manages what web created, and so does a channel such as web/_mail.
  • user/get is open to every stack in the tenant, and its created_by says whom to ask.
  • To act on another stack’s principal, jump into that stack (@goto = "web/3400") and let its rules make the call.
  • The stack is taken from the deployed rule that is running, never from the document, so it cannot be claimed by writing a field.

This keeps a stack that has no business with users from resetting a password by mistake. It is not a wall between authors: whoever can deploy one stack in a tenant can deploy them all.

Errors

Failures land as <into>.error.{code, message} and the run continues:

code (txco_user_… / txco_credential_…)meaning
invalid_arga bad or missing param; the message says which
not_foundno such user, credential or except id in this tenant
not_owneranother stack manages this principal; the message names it
identifier_boundthe email already belongs to a different principal
user_disableda credential was requested for a disabled user
too_manythe principal already holds 64 live credentials
no_tenant · no_stackthe request has no tenant, or the op was not dispatched by a rule
disabledthis node could not open the identity database at boot
storethe database failed; the message has the detail

Where it lives

In the auth database (--db-auth-dsn): a local SQLite file by default, the shared Postgres on a fleet, where every node sees the same users. Every node opens it, not only those running the admin personality. A node that cannot open it keeps serving everything else, and these ops answer disabled there until it restarts.

Edit this page · View as markdown