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.
Org membership
Section titled “Org membership”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.
Org custody: management without access
Section titled “Org custody: management without access”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 withgo 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.
org→privateis permitted;private→orgis 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.
Claiming an abandoned space
Section titled “Claiming an abandoned space”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.
Revoking an org member
Section titled “Revoking an org member”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.
Purging an org
Section titled “Purging an org”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.
Membership
Section titled “Membership”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.
Space owner
Section titled “Space owner”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
409instead. 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.
Visibility governs discovery, not entry
Section titled “Visibility governs discovery, not entry”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/meand enrols it in the default org, so a listed, passcode-free space there would be joinable by anyone on the internet. Space creation forcesprivate, andPATCH .../visibilityrefusesorgwith403before 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
usersrow in no org and no space. The directory sits behindRequireUserand thenrequireOrgMember, in that order, so a link guest is refused401at the first of them — the same answerGET /api/spacesgives it, and the same401it 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.
Removal takes effect immediately
Section titled “Removal takes effect immediately”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.
Facilitator
Section titled “Facilitator”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.
Claiming an abandoned session
Section titled “Claiming an abandoned session”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.
Signed-link guests
Section titled “Signed-link guests”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.
Spectators
Section titled “Spectators”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.
What there is not
Section titled “What there is not”| Capability | Status | What to do instead |
|---|---|---|
| Roles beyond owner and member | Not built | Ownership 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 access | Not built | A passcode is the only gate. Signing in with OIDC proves who you are but grants nothing by itself. |
| Removing a session participant | Not built | A 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 deletion | Not built | An 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 guest | Not built | Spectator 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 account | Not built | A link guest cannot rename itself or sign in. When the link expires the identity stops working; what they contributed stays. |