Organizations
Verified against v0.21.0 · internal/api/custody/custody.go, internal/api/pluginaudit.go, internal/api/custody/store.go, internal/api/org_membership.go, internal/api/router.go, internal/auth/oidc.go, internal/db/migrations/0021_orgs.sql, internal/db/migrations/0023_org_custody.sql, internal/db/migrations/0060_org_audit_retention.sql, web/src/pages/OrgAdminPage.tsx
An organization is a tenancy boundary. Spaces belong to one, slugs are unique
inside one rather than across the instance, and every space route on the API is
mounted under /api/orgs/{org}/. Who belongs to an org is decided by a claim in
the id_token — see
Claim mapping for the setup.
This page is about the part an operator has to be able to explain to their own team: what the person administering an org can see.
Custody is not access
Section titled “Custody is not access”An org admin has custody of every space in their org and access to none of them.
Custody is management. They can list every space including the private ones, rename one, archive one, delete one, make one less discoverable, and repair the ownership of a space they are not in. What the custody API answers with is a fixed structure — the space’s id, slug and name, the user ids of its owners, its visibility, how many members it has, and whether it is archived. That is the whole list, and it is a Go type rather than a convention: the handlers live in a package that does not link the session, presence or hub code at all, so there is nothing there to leak.
So an admin can see that a private space called Reorg planning exists, that it has four members, and who owns it. They cannot read a vote, a standup entry, a note, a story title or a roster, they do not appear in the room, and nobody in it is told they looked.
Two rules keep that from being decoration:
- Custody may only make a space more private on visibility.
org→privateis permitted;private→orgis refused with 403 and stays the space owner’s alone. Without that asymmetry an admin could make a private space org-visible, find it in the directory, join it as an ordinary member, and read everything — with every custody handler still perfectly clean. Custody may also archive or un-archive a space: archiving takes it out of the org directory; un-archiving restores an org-visible space to the directory. - Ownership is granted, never transferred. An admin can promote someone who is already a member of the space. They cannot name themself, cannot name a non-member, and never remove an incumbent.
There is exactly one exception, below.
Claiming an abandoned space
Section titled “Claiming an abandoned space”A space everybody has left cannot be managed by anyone, and there is nobody to hand it to. So an admin may claim a space with zero members, which makes them its owner and therefore a member of it. It is refused while a single member remains.
This is the only route in the product by which custody becomes access, and it
writes a row to org_audit_log naming the org, the space, the actor and the
time. 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.
An org admin reads the log on the Audit log tab of the org admin hub, or
from GET /api/orgs/{org}/admin/audit. It answers newest first, 50 records at a
time (limit up to 100). To page back, pass the last entry’s createdAt as
before and its id as beforeId. It reads only this
org’s records, filtered by the org id, so another org’s admin gets nothing, and
a plain member is refused. Each record carries the time, the action code, the
stored space slug, the detail, and the actor’s name. The name is looked up
through this org’s membership. It is empty when the account is gone, was never
in this org, or there was no actor, and the screen then says “Unknown or removed
account”. A deleted space still shows by its stored slug.
Each org keeps its records forever unless an org admin chooses otherwise.
Keep records for at the top of the Audit log tab, or
PATCH /api/orgs/{org}/admin/audit/settings with {"retentionDays": …}, takes
null (forever, the default), 365, 90 or 30; anything else is a 400, and
GET on the same path reads it. Shortening the window asks for confirmation
first, because the deleted records cannot be recovered. Every change writes an
audit.retention_changed record naming the old and new values, and since every
window is at least 30 days, the prune can never delete the record of the change
that shortened it. Each replica runs the prune hourly, in batches of 1,000.
Each batch takes its own advisory try-lock, so a replica skips a batch another
replica is holding rather than waiting, and the deletes are idempotent. It deletes only
rows that still carry an org id whose window has passed. Records a purge
orphaned (their org id is null) are never pruned and stay in Postgres until an
operator removes them.
Every custody mutation writes one record in the same transaction as the
change, so a refused or failed action writes nothing. A rename is
space.rename with the old and new names (<old> to <new>), a visibility
change is space.visibility with the new value, archiving is space.archive
or space.unarchive, a role change is member.role with the member’s user id
and new role (<user id> to <role>), and revoking or restoring a member is
member.revoke or member.restore with their user id. A revoke refused
because it would strand a space writes nothing. Details carry names, slugs,
roles and user ids only, never session content.
A plugin panel action is recorded as the plugin, the method and the action name, never the session it ran in. Records written before that change carried the session’s path, and the read route cuts it out, so an admin never reads a session id from the log.
Records orphaned by a purge no longer carry an org id, so no admin can read them through the product; they stay in Postgres. If that record matters to you, back the table up like any other.
Private means private from your colleagues
Section titled “Private means private from your colleagues”It does not mean private from whoever runs the instance. A Parley admin has custody, a database administrator has everything, and a backup is a copy of everything. That is true of every self-hosted tool and it is worth saying out loud, because “private” in the space settings screen is about discovery by peers: it decides whether a space is listed in the org directory, and nothing else. The room code is what decides who gets in — including for members of the org, who are not thereby members of any space.
The three ways into a room
Section titled “The three ways into a room”The custody screens, at /o/<org>/admin (also reached from the account menu’s
Org admin group), list spaces and their member counts, and the members screen
lists the org. Opening a space row shows a panel with the custody actions and
nothing else: rename, make private (only from org-visible, never the other way),
add an owner who is already a member, claim a space nobody is left in, and, set
apart, archive or delete. Deleting asks for the space’s name typed exactly. The
panel never fetches anything from inside the space and never links into it.
Opening a member row offers make admin or make member, revoke, which asks first
and removes them from every space in the org and closes their live sessions,
and, for a revoked member only, restore. An admin is offered none of these on
their own row. The server still refuses a change that would leave the org with
no admin, or a revoke that would leave a space with no owner, and the panel
shows that refusal in its own words.
Read together they look like a census of everyone who can reach a room’s
contents. They are not, and this is the row that gets missed:
| Path | How they got there | What an org admin sees | Can an admin revoke it? |
|---|---|---|---|
| Org membership | A claim in their id_token matched the org’s registered value at their last sign-in — or, in open mode, they simply signed in |
Their name and role on the org members screen | Yes. Revoking removes them from the org and from every space in it, and closes their WebSockets in that org’s spaces |
| Space membership | They joined a space with its room code, or with an invite handle from someone who held the room code | That the space has n members, and the user ids of its owners. Not the roster | Only through an org revoke, which is org-wide. There is no per-space eviction in the custody API — the space’s owner manages its members |
| A redeemed signed link | A facilitator sent them a link to one room, and they opened it | Nothing. These identities belong to no org and no space. They are not on the members screen, they are not in the space’s member count, and there is no route that lists them | No. Org member revocation cannot reach them, because they have no org membership to revoke |
A redeemed link mints an identity that can take part in exactly one session and nothing else on the instance. It is a capability on one room rather than an account, which is why it is deliberately outside org membership — and why an org admin cannot see or revoke one.
What governs a link guest instead:
- The link’s own expiry. A link lives 24 hours, and the guest’s session token expires with it.
- Whoever holds the room. The session’s facilitator can revoke an individual link, which is per-session and belongs to them rather than to the org.
So when the question is “who can read what is in this room”, the answer is the space’s members plus anyone holding a live link to a session in it. The org members screen answers a different question.
Membership is only as fresh as the last sign-in
Section titled “Membership is only as fresh as the last sign-in”Org membership derived from a claim is evaluated when someone signs in, and the session cookie is long-lived and refreshed on write and on WebSocket connect. A session can therefore be arbitrarily stale.
This lags in both directions, and both are worth planning for:
- Removals do not propagate. Taking somebody out of a group at the identity provider changes nothing in Parley until their next sign-in, which may never come while they keep the tab open. Deprovisioning somebody means revoking them in Parley as well — an explicit admin action, and the only one that takes effect immediately.
- Additions lag identically. Somebody added to an IdP group gains nothing until they sign in again. This is the usual “why can this person not see the org directory” support ticket, and the answer is almost always that they have not signed in since.
A revocation is a tombstone, so it beats any claim that arrives later: the group membership re-appearing at the provider does not undo it, and neither does the bootstrap-admin setting. An admin has to restore them explicitly.
A broad claim value is a broad org
Section titled “A broad claim value is a broad org”An org matches one exact, case-sensitive claim value. Point one at a group
everybody is in — Domain Users, Everyone, an all-staff group — and the whole
company becomes a member of that org.
The blast radius is bounded: they get the org’s directory of org-visible spaces, and a room code still stands between them and any space they have not joined. But the org members list is a disclosure in itself — a roster of the company, its names, and who administers it — and the directory shows every space an owner chose to list. Point orgs at the groups you would be comfortable naming to each other.
Deleting an org
Section titled “Deleting an org”An org holding spaces cannot be deleted. spaces.org_id is on delete restrict,
so the database itself refuses, and a stray delete from orgs fails rather than
orphaning anything.
The deliberate route is DELETE /api/orgs/{org}, which requires the org’s own
slug sent back as confirmation. Without it the request is refused and answers
with the exact number of spaces and sessions it would have destroyed. With it,
every space and every session in the org is destroyed, in one transaction, along
with the org row.
A purge is not recoverable without a backup. There is no undo, no soft delete and no retention window. The audit records survive and still name what was purged, though only in Postgres: the purge removes the org whose admins could read them in the product. Nothing else survives. The default org cannot be purged at all.