Skip to content

Authorization

Verified against v0.10.0 · internal/api/authz.go, internal/api/principal.go, internal/store/spaces.go, internal/store/orgs.go, internal/poker/routes.go, internal/standup/routes.go, internal/store/sessions.go, internal/store/links.go, internal/api/custody/store.go, internal/api/secevent.go, internal/db/migrations/0023_org_custody.sql, internal/api/ics.go, internal/standup/state.go, internal/session/registry.go, web/src/components/AsyncDigest.tsx

Parley has five authorization concepts and no more: you are a member of the org, you are a member of the space, you own the space, you are the facilitator of this session, and you hold a signed link to this one room.

A space slug is unique inside an org, not across the instance, so a space’s address names both: /o/{org}/s/{slug}, and every space route on the API is mounted under /api/orgs/{org}/.

requireOrgMember resolves the {org} segment once, checks the caller belongs to it, and puts the org in the request context. Someone outside the org gets 404, not 403 — the same non-disclosure rule membership follows, so an unprivileged account elsewhere on the instance cannot use the status code to enumerate tenants. requireOrgAdmin narrows that to the org’s admins and answers 403, which discloses nothing: the caller is already known to be inside.

Standing behind the middleware is not the boundary on its own. It proves the caller is in org A and says nothing about which org a space resolved by slug belongs to, so every lookup behind it is filtered by the org id from the context as well. Without that, a member of org A could join, re-passcode, or open a session in a same-named space owned by org B.

GET /api/orgs/{org}/spaces/{slug} is the exception, and stays anonymous: it is the pre-join landing view someone reads before they have joined anything, so there is no principal to check. It resolves the org and the space in a single joined query, so a nonexistent org, a space in a different org, and a slug that exists nowhere all fail after identical work. Resolving them in sequence would cost one query for a bad org and two for a bad slug, and that difference is a cross-org existence oracle no amount of matching the response body hides.

Two routes stay outside the org prefix on purpose. /api/sessions/... does, because a link guest belongs to no org and no space: it has no org slug to put in a URL and no membership one could be derived from, so an org-prefixed session tree would 404 every signed link ever issued. POST /api/links/redeem does, because it mints the identity an org prefix would require.

GET /ics/{token} is a third outsider, and its own class: token-auth. A calendar client has no cookie and no org. The token in the path is the credential, checked on every request against current space membership. A bad or revoked token is 404. The route is a GET with no cookie, so it has no CSRF exposure and sits outside /api.

This section is the mechanism. Organizations is the same boundary written for the person who has to explain it to their team, including the three ways somebody reaches a room — one of which is neither org nor space membership.

An org admin has custody of every space in their org, private ones included, and access to none of them. They can list, rename, archive, narrow, delete and repair the ownership of a space they are not in, and they can read nothing said inside it — no roster, no presence, no votes, no standup entries, no notes.

Four things hold that line, and they hold it together:

  • The response type is the allow-list. Custody answers with a fixed struct — id, slug, name, ownerIds, visibility, memberCount, archivedAt — so “carries no session content” is enforced by the compiler rather than by a reviewer remembering the forbidden field names. A test intercepts the response as raw JSON and fails on any key outside that list, which also catches a handler that marshalled an untyped map instead.
  • The handlers cannot reach session content. They live in internal/api/custody, which does not import the session, presence, hub or store packages at all. A test asserts that with go list -deps. The types that hold what was said in a space are not linked into the package.
  • Custody may only make a space more private. orgprivate is permitted; privateorg is refused with 403 and stays the space owner’s alone. Without this the rest is theatre: an admin flips a private space to org-visible, finds it in the directory, joins as an ordinary org member, and has everything — through a different door, with every custody handler still perfectly pure.
  • Ownership is granted, never transferred. An admin may promote somebody who is already a member of the space. They may not name themself, may not name a non-member, and never demote an incumbent. A “reassign” that demoted the incumbents would be one call that removes every owner of a space the admin cannot see; naming themself would be a way in through the ordinary member routes.

There is exactly one path by which an org admin becomes a member of a space they were not in: POST /api/orgs/{org}/admin/spaces/{slug}/claim, refused while any member remains. It exists because a space with nobody left in it can otherwise never be managed again, and there is nobody to hand it to.

Every claim writes a row to org_audit_log, as do custody space deletes, ownership grants and org purges. Neither of that table’s foreign keys cascades and both slugs are stored as text, so the record outlives the space and the org it names — including an org purge, which is the action most worth having a record of. The log is write-only today: there is no read route or screen (#399); query Postgres, and do not describe it as something a team can review in the product until that lands. See Organizations.

A space also cannot be driven to abandonment first. Removing members one at a time is refused at the last owner, and no custody action other than an org-level revoke removes a space’s members at all.

DELETE /api/orgs/{org}/admin/members/{userId} removes somebody from the org and from every space in it, in one transaction, and upserts a tombstone rather than updating one — so an admin can shut the door on somebody who has never signed in, and a sign-in never clears it. POST .../restore lifts it, because without an un-revoke a mis-click would be permanent.

The per-space half matters more than it looks. The last-owner guard runs one space at a time, so a cross-space bulk delete would bypass it and strand every space where the revoked person was the sole owner. Instead the revoke promotes the most recently active remaining member where it can, and where it cannot it refuses with 409, names those spaces, and writes nothing at all.

Their open WebSockets in that org’s spaces close immediately on the replica that served the revoke and, via one parley_member_revoke notification per space, on every other replica too. Only those spaces: a revoke reaches one org, so sockets the same person holds in another org’s spaces are left alone. If a notification is lost, the backstop is the hub’s revalidation tick — at most 30 seconds.

DELETE /api/orgs/{org} is the most destructive route in the product: every space, every session and the org row itself. It cannot happen by accident — spaces.org_id is on delete restrict, so the database refuses a bare delete — and it will not run without the org’s own slug sent back as confirm. Without that confirmation it answers 400 and states the exact space and session counts it would destroy, read inside the same transaction that would have done the destroying.

The whole purge is one transaction: an interrupted one leaves the org and every space exactly as they were, rather than some spaces gone, the rest standing, and the org row undeletable behind the restrict foreign key. The audit records survive, and still name what was purged — write-only, as above; there is no product UI to open them (#399). Nothing else does — there is no restore.

Acting anywhere inside a space requires having joined it. That is checked on the server for every request, not implied by holding a URL.

A non-member asking about a session gets 404, not 403. This is deliberate: a 403 confirms the session exists, which leaks the shape of a team’s work to anyone who can guess an identifier. The same applies to the WebSocket, where membership is verified before the connection is upgraded.

A non-member looking up a space gets its name and whether it is protected, and nothing else — no roster, no session list, no passcode.

Every member carries a role on members.role: owner or member. The person who creates a space is seeded as its owner. A space can have several owners; only an owner may promote another member to owner, demote an owner back to member, or remove a member from the space.

An owner also owns the space itself: renaming it, deleting it, changing who can find it, and renaming or deleting any session in it. Every one of those routes goes through the same requireSpaceOwner middleware, which resolves the slug, reads the caller’s role, and answers 403 to a member and 404 to anyone outside the space.

The room-level split is deliberate. Closing a session ends a meeting and is the facilitator’s, because it is part of running one; the record survives and it can be reopened. Deleting a session discards it and is the owner’s, because it is housekeeping on the space. The delete route is addressed through the space (DELETE /api/orgs/{org}/spaces/{slug}/sessions/{id}) and its SQL is scoped by space_id as well as by id, so owning one space can never reach a session in another even with its id in hand.

Two guard rails are enforced in the database transaction, not in the UI:

  • The last owner cannot be demoted or removed. A space with no owner could never be managed again, so both routes answer 409 instead. This holds whether the demotion is aimed at someone else or at oneself — stepping down is allowed, but only once somebody else has been promoted first.
  • The owner count is read under a row lock, so two owners demoting each other at the same instant cannot both succeed.

A member who is not an owner gets 403 from either route. Someone outside the space gets 404 — the same non-disclosure rule the roster follows.

spaces.visibility is private or org. An org-visible space is listed to the org’s members at GET /api/orgs/{org}/spaces; a private one appears in that list only to people who are already members of it. Nothing else changes: being able to find a space grants nothing about getting into it, and handleJoinSpace compares the passcode exactly as it did before, for org members included. A space that is both listed and protected is a real and supported state — PATCH .../visibility never writes the passcode, and POST .../passcode never writes the visibility, so neither route can silently strip the other.

Two refusals hold the boundary:

  • Open mode cannot reach org visibility. An instance with no sign-in configured mints an anonymous identity for every visitor on POST /api/me and enrols it in the default org, so a listed, passcode-free space there would be joinable by anyone on the internet. Space creation forces private, and PATCH .../visibility refuses org with 403 before it reaches the database, so the route cannot be used to route around the create-time guard.
  • A signed-link guest gets neither the directory nor entry. A redeemed link is a users row in no org and no space. The directory sits behind RequireUser and then requireOrgMember, in that order, so a link guest is refused 401 at the first of them — the same answer GET /api/spaces gives it, and the same 401 it gets from the join route. That ordering is the guarantee: if the directory ever answered for a link guest, one link to one standup would become a listing of every org-visible space on the instance.

Membership is read from the database on every request, so there is no cache to expire: the removed member’s very next request sees the stranger view of the space and, if the space is protected, the passcode gate.

A WebSocket that is already open is not a request, so it is closed explicitly. The removal disconnects the member’s live sockets for that space on the process that served the removal, before it answers. On any other process holding a socket for them — a second replica behind a load balancer — the close lands at that connection’s next revalidation tick instead, so the worst-case window there is one revalidation interval — capped at 30 seconds, and 30 seconds is the default. Reconnecting fails the membership check at the handshake.

Removal is not a ban — they can knock again with a current passcode, and come back as a plain member.

The facilitator is one member per session, recorded on the session row. Only they can create, edit, reorder, select, or delete poker stories; reveal or reset a round; toggle poker auto-reveal; save an estimate; start or advance a standup; close or reopen the session; or hand the role to someone else.

Every one of those is enforced server-side. Ordinary members retain the active participation that belongs to them: a non-spectator may cast their own poker vote, a standup participant may edit their own entry, and a standup participant may set their own readiness signal. All three write only the caller’s own row, keyed by the user id on the request rather than anything in the body. They cannot mutate the story queue or control session progression — readiness is advisory, and start does not read it.

If the facilitator disappears — closes the laptop mid-round — any member can claim the role after a 60-second server-side grace period.

Three details make that safe. The grace is measured by the server, not by a client saying “they’re gone”. Eligibility is decided from the server’s own view of who is connected, never from a client-supplied user id. And the claim is a single conditional UPDATE, so two people racing for it produce one winner rather than two facilitators.

The claim is broadcast and attributed by name — taking over is visible to the room, not silent.

A facilitator can mint a signed link to one room and hand it to somebody who has no account and never joins the space. Redeeming it mints an ordinary user row flagged link-bound, so presence, the roster and CSV attribution need no second code path — but authorization treats it as a capability, not a membership.

The URL is the credential. Anyone holding the link can redeem it until it expires (24 hours) or the facilitator revokes it. Send it the way you would send a passcode.

A link guest may read the one room the link names, take part in it, read their own identity, and end their own session — and may do nothing else. Every other route refuses them, including routes that are open to anonymous callers: the identity writes (the initial redemption name is not checked, so the roster marks every guest seat regardless), the space view, the CSV export of the room they are in, the facilitator controls, and the link routes.

GET /api/me is the single exception, and it is a read. A guest’s browser caches their name and hue in sessionStorage, which dies with the tab while the HttpOnly cookie does not — so a fresh tab, a private window, a cleared site or a second device has none of that cache with the cookie still live, and such a guest used to resolve as nobody, land in the name gate, and be refused the POST that gate makes. It answers with what the guest already holds: their own id, name, avatar, the id of the room they are bound to, and when their seat runs out. It carries no space, no slug, no other member and no other session, the same line the room envelope’s redaction draws.

DELETE /api/me is the second exception, and the only write on identity a guest may make: it spends the credential rather than reshaping it. A guest link is aimed at somebody outside the team, often on a borrowed or shared machine, and the HttpOnly cookie carries no Max-Age: it lasts the browsing session, not the rest of the link’s 24 hours. That is shorter than it used to be, and still long enough for the next person to inherit the seat — the browsing session is the browser, not the tab, so closing the room tab leaves the cookie sitting there for whoever opens the room URL next, and a browser set to restore its last session carries it across a quit as well. The guest banner therefore carries a Leave room control, and it lands them on a dead-link screen rather than a seat. What it buys over waiting for the browsing session to end is that it happens server-side and immediately: like sign-out for an account, it deletes the caller’s own session_tokens row, so the cookie is dead the moment they leave and stays dead through a restored session. That row is all it deletes — the user row, the link and everything the guest contributed stay exactly where they are.

Claiming an abandoned facilitator seat is refused twice — at the middleware and again in the UPDATE itself.

The room’s own envelope is redacted for them too, which is the part no route guard could do: the guest’s copy carries no spaceSlug and no space member who is not taking part in that meeting. Otherwise one link would hand out the space’s join slug and its whole membership list from the one room the guest is entitled to read. What the guest does see is everybody taking part, other guests included, and always their own seat — a guest holds a seat on the roster like anybody else in the room, never a spectator’s. Their own seat is in the very first copy they are served, before their socket has connected and put them in the room’s presence: being told you are not in the room you are in is never the right answer.

An async standup’s update is still attributed after its author has left the room: each entry carries its author’s display name, and only that, so a guest reads “Priya Raman” rather than “Someone” beside an update it can already read. It discloses nothing the entry did not already imply, and it does not seat the author on the guest’s roster. An entry written by another link guest carries no name, because a guest’s name is whatever it typed and, without the roster’s guest flag, it could pass for a member’s. The guest’s copy of the state carries neither the away list nor the list of who the standup is waiting on: both are drawn from the space’s membership, so its Not yet is limited to the people it can already see.

A guest chooses their own display name, and renaming is the only thing refused afterwards, so nothing prevents one redeeming as a name a member already carries. Every roster entry therefore carries a guest flag set from how the seat was obtained — a space membership, or a signed link — and the clients draw it beside the name. The flag survives the guest’s own redaction, so a guest cannot read its seat as a member’s either. This matters most with sign-in turned on, where a member’s display name comes from the identity provider and is not otherwise choosable.

Taking part is uniform across both kinds. Standup entries and poker votes are keyed to the user, not to a membership, and the round each one belongs to is built from the space’s non-spectating members plus the guests redeemed into that room: a guest is placed in the standup’s running order and holds the turn when it reaches them, and poker’s auto-reveal — opt-in, off by default — waits on their vote when it is on. The facilitator-only actions are unchanged by that — start, next and skip in a standup, and stories, reveal, reset and config in poker, are refused for a guest because a guest is never the facilitator.

A link that expires or is revoked while its guest holds the turn does not strand the round. The socket is severed on the next revalidation tick, but nothing is swept: the guest keeps its slot and the update it wrote, and the facilitator moves the round on with next or skip, the same key they use for anybody who has gone quiet.

Expiry lives on the session token the redemption mints, so a lapsed link severs the WebSocket mid-meeting rather than waiting for a sweep. Revoking deletes those tokens and closes the sockets immediately. Neither deletes the guest: their votes and updates stay in the meeting they took part in, and so does their name. A revoked or expired link takes away the seat, not the attribution — a CSV exported afterwards still carries the guest by name.

A member can mark themselves a spectator. Spectators do not get a seat, cannot vote, and are excluded from the auto-reveal denominator. It is a self-service toggle, not a permission an administrator grants.

CapabilityStatusWhat to do instead
Roles beyond owner and memberNot builtOwnership covers membership management and the space itself — renaming and deleting the space and its sessions. It does not cover the door: the server still lets any member, owner or not, rotate the passcode or open the space, though the controls that do so are offered only to owners, on the space settings page.
Per-user or per-team space accessNot builtA passcode is the only gate. Signing in with OIDC proves who you are but grants nothing by itself.
Removing a session participantNot builtA facilitator can hand the role to another member, but there is no eject-from-this-session-only control. Remove them from the space instead, or rotate the passcode.
Undo, or an audit trail, for an owner deletionNot builtAn owner can delete a space or a session, and it is gone — no soft delete, no archive. A space delete emits a stdout security event line; a session delete does not, and neither writes org_audit_log (that table covers org-admin custody actions only, and is write-only — #399). Restore from a backup if you need one back.
Spectating as a link guestNot builtSpectator mode is a flag on a space membership, and a link guest has none, so the toggle is refused rather than silently accepted.
Upgrading a link identity into an accountNot builtA link guest cannot rename itself or sign in. When the link expires the identity stops working; what they contributed stays.