Skip to content
View raw

Certified Group Service (CGS)

In standard AT Protocol, each repository is controlled by a single identity (DID) — there's no built-in way for multiple users to collaboratively manage the same repo with different permission levels.

The Certified Group Service (CGS) fills that gap. It's an AT Protocol service that sits between clients and a group's backing PDS, enforcing role-based access control, tracking record authorship, and keeping a full audit log. From the client's perspective, a group looks like any other AT Protocol repository — it just happens to be co-governed.

Certified operates a hosted CGS instance, but CGS is also designed to be self-hostable per operator — anyone can run their own instance against any AT Protocol PDS (including, but not limited to, the Certified-operated PDSs).

A single CGS instance can host groups across multiple PDSs. Each group records its own backing PDS, resolved from the group's DID document and stored per-group. When a group is imported (app.certified.group.import), CGS uses whatever PDS already hosts that account, so imported groups can live on different PDSs within one instance. The GROUP_PDS_URL environment variable is narrower than it sounds: it's only the PDS on which app.certified.group.register creates brand-new group accounts. Letting register target a specific PDS per call is a plausible future extension but is not implemented today.

Certified operates production, staging, and test CGS instances. See Certified Group Services for the current hostnames, version endpoints, and guidance on which to use in which scenario.

System overview

text
Any AT Protocol client

  │  atproto-proxy: did:plc:GROUP#certified_group

User's PDS

  │  Authorization: Bearer <service-auth-jwt>
  │  (signed with user's key, iss=user, aud=group)

┌──────────────────────────────────┐
│  Certified Group Service         │
│                                  │
│  1. AuthVerifier  (JWT → DID)    │
│  2. RbacChecker   (DID → role)   │
│  3. PDS proxy     (forward)      │
│  4. AuditLogger   (record all)   │
└──────────────────────────────────┘


Group's backing PDS

Clients never call CGS directly. Instead they send a normal XRPC request to their own PDS with an atproto-proxy header pointing at the group DID and service fragment (did:plc:GROUP#certified_group). The user's PDS resolves the group's DID document, finds the #certified_group service endpoint, mints a service auth JWT signed with the user's key, and forwards the request to CGS. This is the same proxying mechanism used by Ozone labeling services.

Integrating from an app

Apps don't call CGS directly. Your backend uses an authenticated AtpAgent for the user and sends XRPC requests to the user's PDS with an atproto-proxy header set to did:plc:<groupDid>#certified_group. The PDS handles service auth and forwards the request to CGS on your behalf. See the upstream integration guide for a worked example.

One important gotcha: CGS uses custom NSIDs for record operations — app.certified.group.repo.createRecord, putRecord, deleteRecord, uploadBlob — instead of the standard com.atproto.repo.*. This is deliberate: if your app called com.atproto.repo.createRecord, the user's PDS would handle it itself and write to the user's own repo, not proxy it to CGS. The custom NSIDs are unrecognized by the PDS, so it looks them up in the group's DID document and routes them to CGS. Use the custom NSIDs in your proxied calls.

Authentication

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 — 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 lifetimeexp - 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.

For a group-scoped request, the handler then receives { callerDid, groupDid } and proceeds to authorization. Cross-group requests receive just { callerDid }.

Authorization (RBAC)

Roles are strictly hierarchical and compared numerically. A higher level grants every permission of the lower levels.

text
member (0)  <  admin (1)  <  owner (2)

Permission matrix

OperationMinimum role
Create records, upload blobs, list membersmember
Edit / delete records you authoredmember
Edit / delete any member's recordadmin
Edit the group's profileadmin
Add / remove membersadmin
Query the audit logadmin
Change a member's roleowner

Special rules

  • Cannot modify equal or higher roles. An admin cannot remove another admin; only an owner can.
  • 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

Once a request is authorized, CGS forwards it to the group's backing PDS using stored credentials:

  • Credential storage. The group's PDS app password (and, where applicable, the recovery keypair used for PLC operations) is stored encrypted with AES-256-GCM, using a 32-byte master key from the service's ENCRYPTION_KEY environment variable.
  • Agent pool. An authenticated AtpAgent per group is cached in memory; stale sessions are refreshed automatically on AuthenticationRequired / ExpiredToken errors.
  • Blob uploads. uploadBlob requests are streamed to the PDS with an enforced MAX_BLOB_SIZE limit (checked both upfront via Content-Length and incrementally during the stream).

Audit logging

Every meaningful action — permitted or denied — is written to the per-group group_audit_log table. Each entry captures:

  • Who — the caller's DID (actor_did)
  • What — the operation name (e.g. createRecord, member.add)
  • Where — collection and rkey, for record-level operations
  • Resultpermitted or denied (plus a reason for denials)
  • Tracing — the JWT's jti, for correlation with auth logs
  • When — ISO timestamp

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

A group enters the service in one of two ways: register (create a brand-new account) or import (adopt an account that already exists).

Registerapp.certified.group.register requires a service auth JWT proving the caller controls the prospective owner DID. During registration, CGS:

  1. Creates a new PDS account on the instance's configured backing PDS (GROUP_PDS_URL) and receives a new group DID.
  2. Generates a recovery keypair and registers a #certified_group service entry in the group's DID document via a PLC operation.
  3. Stores the encrypted app password and recovery key in its own database.
  4. Seeds the caller as the group's first owner.

Importapp.certified.group.import adopts a pre-existing PDS account instead of creating one. The caller supplies an app password, and the JWT must be signed by the account being imported (proving control of the group DID beyond merely holding its app password). CGS resolves the account's PDS from its own DID document — which may be any PDS, not just GROUP_PDS_URL — authenticates there with the app password, stores the credentials, and seeds the named owner. It does not generate a recovery key (an app password cannot grant key control) or modify the account's DID document.

Both are service-level operations (aud = the service's own DID), since the group does not yet exist in the service when they run. From then on, the group's DID is co-governed through CGS: owners promote admins, admins manage members, and members interact with the repository subject to the permission matrix above.

Storage

CGS uses SQLite for all persistence:

  • 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.

Future directions

The current RBAC model — three fixed roles (member, admin, owner) with a hard-coded permission matrix — is intentionally simple. It covers the common case of a small group co-managing a repository, but it is a starting point rather than an endpoint. Directions being explored as groups' governance needs mature:

  • Customizable roles. Let each group define its own roles and permissions instead of relying on a fixed three-tier hierarchy.
  • Finer-grained permissions. Scope permissions per collection or record type — for example, a role that can only create records in one lexicon, or only edit the group profile.
  • Group-level governance. Move beyond unilateral admin/owner actions toward proposals, voting, or quorum-based decisions for sensitive operations.
  • Time-bound and delegated roles. Temporary elevations — e.g. an admin grants another member admin for 24 hours, after which the role automatically reverts.
  • Credential-based membership. Derive membership and roles from external signals (verifiable credentials, badges, tokens) rather than only manual member.add calls.
  • Per-call PDS targeting for register. A single instance can already host groups across multiple PDSs via import (which adopts an account on whatever PDS already hosts it). What's missing is letting register create new group accounts on a caller-chosen PDS rather than only the instance's configured GROUP_PDS_URL.

None of the above are committed features; they're possibilities being shaped by user and developer needs and feedback.

Further reading