Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 15 additions & 9 deletions pages/architecture/certified-group-service.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,12 @@ One important gotcha: CGS uses **custom NSIDs** for record operations — `app.c
Every request to CGS arrives with `Authorization: Bearer <JWT>`. The `AuthVerifier` runs the following checks:

1. **Signature** — verified against the issuer's DID document via `@atproto/xrpc-server`'s `verifyJwt()`.
2. **Audience** — the JWT's `aud` must match a group DID registered with this CGS instance.
2. **Audience** — for group-scoped operations the JWT's `aud` must match a group DID registered with this CGS instance. Service-level (cross-group) operations such as `app.certified.groups.membership.list` instead require `aud` to be the service's own DID.
3. **Lexicon method** — the JWT's `lxm` must match the requested XRPC method (from an allowlist of record and group-management operations).
4. **Token lifetime** — `exp - iat` must not exceed the nonce TTL (120 seconds), so that tokens can't outlive the replay-prevention window.
5. **Nonce (replay prevention)** — the JWT's `jti` is checked against a short-lived nonce cache. If it's been seen before, the request is rejected.

If all checks pass, the handler receives `{ iss: callerDid, aud: groupDid }` and proceeds to authorization.
For a group-scoped request, the handler then receives `{ callerDid, groupDid }` and proceeds to authorization. Cross-group requests receive just `{ callerDid }`.

## Authorization (RBAC)

Expand All @@ -70,9 +70,9 @@ member (0) < admin (1) < owner (2)

| Operation | Minimum role |
|---|---|
| Create records, upload blobs, edit any record, list members | member |
| Delete records you authored | member |
| Delete any member's record | admin |
| Create records, upload blobs, list members | member |
| Edit / delete records you authored | member |
| Edit / delete any member's record | admin |
| Edit the group's profile | admin |
| Add / remove members | admin |
| Query the audit log | admin |
Expand All @@ -81,9 +81,9 @@ member (0) < admin (1) < owner (2)
### Special rules

- **Cannot modify equal or higher roles.** An admin cannot remove another admin; only an owner can.
- **`member.add` cannot assign `owner`.** New owners must be promoted by an existing owner via `role.set`.
- **Self-removal always succeeds.** Any member can remove themselves, regardless of role.
- **Last-owner protection.** The system atomically prevents demoting or removing the only remaining owner.
- **`member.add` cannot assign `owner`.** The owner role is immutable — it is fixed at registration and cannot be assigned or changed via `role.set` (ownership transfer is a separate operation, not yet implemented).
- **Owners cannot be removed or demoted.** `role.set` and `member.remove` both reject the owner role — this takes precedence over self-removal, so even an owner cannot remove themselves.
- **Self-removal succeeds for non-owners.** Any member or admin can remove themselves regardless of the equal-or-higher-role rule; only the owner is excepted (see above).
- **Authorship is tracked per record.** CGS maintains a `group_record_authors` table so `deleteOwnRecord` (member) can be distinguished from `deleteAnyRecord` (admin).

## PDS proxying and credentials
Expand All @@ -107,6 +107,12 @@ Every meaningful action — permitted or denied — is written to the per-group

Admins can query the audit log via `app.certified.group.audit.query`.

## Cross-group membership

Most CGS operations are scoped to a single group. One endpoint is service-level rather than group-level: `app.certified.groups.membership.list` lets the authenticated user list **every group they belong to on this group service**, along with their role and join date in each. Because it spans groups, its service auth JWT is addressed to the service's own DID (`aud` = service DID) rather than to any one group.

CGS answers this query from a `member_index` table in the global database — a reverse index from member DID to the groups they're in — since the per-group databases have no way to look up membership across groups.

## Group lifecycle

Groups are created via `app.certified.group.register`, which requires a service auth JWT proving the caller controls the prospective owner DID. During registration, CGS:
Expand All @@ -122,7 +128,7 @@ From then on, the group's DID is co-governed through CGS: owners promote admins,

CGS uses SQLite for all persistence:

- A **global database** holds the group registry (`groups` table) and the nonce cache.
- A **global database** holds the group registry (`groups` table), the nonce cache, and a `member_index` table (the reverse member-to-group index that backs cross-group membership listing).
- Each group gets its **own per-group database**, named by the SHA-256 hash of the group DID. This isolates group data and keeps audit logs per-group.
- All databases use WAL mode for concurrent read performance.

Expand Down