|
| 1 | +# Design: Permission Sets for Hypercerts & Certified data |
| 2 | + |
| 3 | +Status: **Proposed** |
| 4 | + |
| 5 | +This document designs **permission sets** for the `org.hypercerts.*` and |
| 6 | +`app.certified.*` record collections defined in this repository — published, |
| 7 | +reusable bundles of AT Protocol OAuth scopes that any application can request |
| 8 | +with a single `include:<nsid>` scope, instead of enumerating every underlying |
| 9 | +`repo:` scope by hand. |
| 10 | + |
| 11 | +## Background: what a permission set is |
| 12 | + |
| 13 | +AT Protocol clients act on a user's data with **OAuth scopes**. Writing records |
| 14 | +of a given collection requires a `repo:<collection>?action=…` scope; calling a |
| 15 | +service method requires an `rpc:<lxm>?aud=…` scope. (See the |
| 16 | +[AT Protocol permission spec](https://atproto.com/specs/permission).) |
| 17 | + |
| 18 | +A **permission set** ([spec](https://atproto.com/specs/permission#permission-sets)) |
| 19 | +is a named, published Lexicon schema that bundles several such scopes under one |
| 20 | +NSID. A client requests the whole bundle with one scope string — |
| 21 | +`include:<nsid>` — and the user's Authorization Server (their PDS) resolves the |
| 22 | +set and expands it into the underlying scopes when granting access. Per the |
| 23 | +spec, permission sets are _"published publicly and can be used by any client |
| 24 | +developer"_, and _"Authorization Servers resolve, authenticate, and process |
| 25 | +permission-sets dynamically."_ |
| 26 | + |
| 27 | +The motivation is plain: an application that does CRUD over a family of |
| 28 | +collections — say, all the records that make up a hypercert — would otherwise |
| 29 | +have to list every `repo:org.hypercerts.…?action=…` scope itself, keep that list |
| 30 | +correct, and re-derive it independently from every other integrator. A published |
| 31 | +set replaces that with one curated, versioned name. |
| 32 | + |
| 33 | +## Who uses these sets |
| 34 | + |
| 35 | +These are general AT Protocol artifacts, not specific to any one service. The |
| 36 | +expected consumers, in rough order of generality: |
| 37 | + |
| 38 | +1. **Any atproto app working with Hypercerts / Certified data directly.** An |
| 39 | + OAuth client requests `include:org.hypercerts.permissions.crud` (and/or the |
| 40 | + `app.certified.*` set) in its authorization request; the user's PDS expands it |
| 41 | + and the app reads/writes those collections in the user's own repo. No |
| 42 | + intermediary is involved. This is the broadest case. |
| 43 | +2. **The Certified group service (CGS), via OAuth.** CGS is used as a standard |
| 44 | + OAuth resource (reached through AT Protocol service proxying). A client |
| 45 | + calling CGS on a user's behalf requests the same sets; the user's PDS expands |
| 46 | + them the same way. CGS is a likely-popular consumer, but it consumes the sets |
| 47 | + like any other client — it does not own them. |
| 48 | +3. **CGS API keys.** CGS also issues long-lived API keys whose scopes can be |
| 49 | + specified as `include:<nsid>`; CGS resolves the **same** published set and |
| 50 | + expands it to concrete scopes on the key. (Detailed in CGS's own design doc; |
| 51 | + summarised under _Consumption by CGS_ below.) |
| 52 | + |
| 53 | +The common thread: the **published Lexicon record is the single source of |
| 54 | +truth.** Whatever resolves a set — a PDS during an OAuth grant, or CGS at |
| 55 | +key-creation — reads the same published definition. |
| 56 | + |
| 57 | +## The sets |
| 58 | + |
| 59 | +We define two **record-collection CRUD** sets — one per data namespace in this |
| 60 | +repo. Each grants create/update/delete over all of that namespace's record |
| 61 | +collections. |
| 62 | + |
| 63 | +| Set NSID | Grants CRUD on | |
| 64 | +| --------------------------------- | ----------------------------------------- | |
| 65 | +| `org.hypercerts.permissions.crud` | all `org.hypercerts.*` record collections | |
| 66 | +| `app.certified.permissions.crud` | all `app.certified.*` record collections | |
| 67 | + |
| 68 | +Both are authored and published from **this repository**, which is the namespace |
| 69 | +authority for both — see _Namespace authority_ below. |
| 70 | + |
| 71 | +> **Naming.** The spec prescribes no naming convention: a permission set is |
| 72 | +> identified only by `type: "permission-set"` in its `main` def, and its sole |
| 73 | +> examples are illustrative (`com.example.authFull`). The `permissions.crud` |
| 74 | +> leaf is a house choice for this repo; rename freely. |
| 75 | +
|
| 76 | +### `org.hypercerts.permissions.crud` |
| 77 | + |
| 78 | +Enumerates every `type: "record"` collection under `org.hypercerts.*`: |
| 79 | + |
| 80 | +```jsonc |
| 81 | +{ |
| 82 | + "lexicon": 1, |
| 83 | + "id": "org.hypercerts.permissions.crud", |
| 84 | + "defs": { |
| 85 | + "main": { |
| 86 | + "type": "permission-set", |
| 87 | + "title": "Full CRUD on Hypercerts record collections", |
| 88 | + "permissions": [ |
| 89 | + { |
| 90 | + "type": "permission", |
| 91 | + "resource": "repo", |
| 92 | + "collection": [ |
| 93 | + "org.hypercerts.claim.activity", |
| 94 | + "org.hypercerts.claim.contribution", |
| 95 | + "org.hypercerts.claim.contributorInformation", |
| 96 | + "org.hypercerts.claim.rights", |
| 97 | + "org.hypercerts.collection", |
| 98 | + "org.hypercerts.context.acknowledgement", |
| 99 | + "org.hypercerts.context.attachment", |
| 100 | + "org.hypercerts.context.evaluation", |
| 101 | + "org.hypercerts.context.measurement", |
| 102 | + "org.hypercerts.funding.receipt", |
| 103 | + "org.hypercerts.workscope.tag", |
| 104 | + ], |
| 105 | + "action": ["create", "update", "delete"], |
| 106 | + }, |
| 107 | + ], |
| 108 | + }, |
| 109 | + }, |
| 110 | +} |
| 111 | +``` |
| 112 | + |
| 113 | +### `app.certified.permissions.crud` |
| 114 | + |
| 115 | +Enumerates every `type: "record"` collection under `app.certified.*`: |
| 116 | + |
| 117 | +```jsonc |
| 118 | +{ |
| 119 | + "lexicon": 1, |
| 120 | + "id": "app.certified.permissions.crud", |
| 121 | + "defs": { |
| 122 | + "main": { |
| 123 | + "type": "permission-set", |
| 124 | + "title": "Full CRUD on Certified record collections", |
| 125 | + "permissions": [ |
| 126 | + { |
| 127 | + "type": "permission", |
| 128 | + "resource": "repo", |
| 129 | + "collection": [ |
| 130 | + "app.certified.actor.organization", |
| 131 | + "app.certified.actor.profile", |
| 132 | + "app.certified.badge.award", |
| 133 | + "app.certified.badge.definition", |
| 134 | + "app.certified.badge.response", |
| 135 | + "app.certified.graph.follow", |
| 136 | + "app.certified.link.evm", |
| 137 | + "app.certified.location", |
| 138 | + "app.certified.signature.proof", |
| 139 | + ], |
| 140 | + "action": ["create", "update", "delete"], |
| 141 | + }, |
| 142 | + ], |
| 143 | + }, |
| 144 | + }, |
| 145 | +} |
| 146 | +``` |
| 147 | + |
| 148 | +> The collection lists are derived from this repo's `lexicons/org/hypercerts/` |
| 149 | +> and `lexicons/app/certified/` (`type: "record"` defs at time of writing). They |
| 150 | +> must be **kept in sync** as record types are added — see _Maintenance_. |
| 151 | +
|
| 152 | +## Design rules these sets follow |
| 153 | + |
| 154 | +### Namespace authority |
| 155 | + |
| 156 | +A permission set may only contain permissions under its **own** namespace |
| 157 | +authority. Spec, verbatim: _"Permission sets are limited to expressing |
| 158 | +permissions that reference resources under the same NSID namespace as the set |
| 159 | +itself"_ — it may address its own NSID group and children, never siblings or |
| 160 | +parents. The rule applies uniformly to `repo:` permissions (by collection NSID) |
| 161 | +and `rpc:` permissions (by lxm NSID). |
| 162 | + |
| 163 | +Consequences here: |
| 164 | + |
| 165 | +- A set under `org.hypercerts.*` may grant `repo:org.hypercerts.*` collections, |
| 166 | + and **only** those — it cannot reference `app.certified.*` collections, or any |
| 167 | + third party's, or any service's `rpc:` methods. Likewise the `app.certified.*` |
| 168 | + set is confined to `app.certified.*`. |
| 169 | +- This is why there are **two** sets rather than one combined set: the two |
| 170 | + namespaces are distinct authorities and cannot legally be mixed in a single |
| 171 | + set. An app that needs both requests both `include:` scopes. |
| 172 | +- The authority confinement is also what makes a published set **safe for any |
| 173 | + client to resolve**: an `include:` can only ever widen access to the set's own |
| 174 | + namespace, never smuggle in foreign permissions. |
| 175 | + |
| 176 | +### Collections are enumerated, never wildcarded |
| 177 | + |
| 178 | +Inside a permission set, a `repo:` permission names collections by **exact |
| 179 | +NSID**. Spec, verbatim: _"Wildcards are not supported in permissions within a |
| 180 | +permission set."_ So a CRUD set cannot say "all `org.hypercerts.*` collections" — |
| 181 | +it lists each one. The enumerated list **is** the grant, and is the security |
| 182 | +boundary: a newly-added collection is not covered until it is added to the set |
| 183 | +and the set is re-published. (Contrast a standalone scope outside a set, where |
| 184 | +`repo:*` is permitted — but that is "all collections", not "a namespace", and is |
| 185 | +not what these sets are for.) |
| 186 | + |
| 187 | +### `repo:` permissions carry no `aud` |
| 188 | + |
| 189 | +A `repo:` scope targets the **user's own repo**, so it has no audience. Both sets |
| 190 | +here are `repo:`-only, so an `include:` for them is requested **without** any |
| 191 | +`?aud=` parameter, and expands to plain |
| 192 | +`repo:<collection>?action=create&action=update&action=delete` scopes. |
| 193 | + |
| 194 | +(For completeness: an `rpc:` permission _does_ take an `aud` naming the target |
| 195 | +service, and a set can let the `include:`'s `?aud=` flow into `rpc:` permissions |
| 196 | +marked `inheritAud: true`. Neither CRUD set uses `rpc:`, so this does not apply — |
| 197 | +it is noted only because a future Hypercerts/Certified _service-method_ set |
| 198 | +would.) |
| 199 | + |
| 200 | +## Publication and resolution |
| 201 | + |
| 202 | +Both sets are published like any other Lexicon schema in this repo (see |
| 203 | +[`PUBLISHING.md`](../PUBLISHING.md)) and resolved at runtime via the standard |
| 204 | +**Lexicon resolution system**, to which the permission spec defers: |
| 205 | +_"Permission sets are Lexicon schemas and are published and fetched using the |
| 206 | +Lexicon resolution system, which includes cryptographic authentication."_ |
| 207 | + |
| 208 | +The resolution chain a consumer follows (per |
| 209 | +[`/specs/lexicon`](https://atproto.com/specs/lexicon), core protocol): |
| 210 | + |
| 211 | +1. **NSID → authority DID via DNS.** Reverse the NSID's authority portion (all |
| 212 | + but the final name), prepend `_lexicon.`, query that domain for a `TXT` record |
| 213 | + `did=<DID>`. |
| 214 | +2. **DID → PDS endpoint** via standard DID resolution. |
| 215 | +3. **Fetch the schema record**: `com.atproto.repo.getRecord` with |
| 216 | + `repo=<authority-DID>`, `collection=com.atproto.lexicon.schema`, |
| 217 | + `rkey=<full-NSID>` (the rkey **is** the NSID). |
| 218 | +4. Validate the record's `main` def is `type: "permission-set"`, then expand. |
| 219 | + |
| 220 | +For a direct OAuth client this is done by the **user's PDS** during the grant. |
| 221 | +The publication of the `_lexicon` DNS records and schema records for these |
| 222 | +namespaces is a maintenance responsibility of this repo's operators. |
| 223 | + |
| 224 | +## Consumption by CGS |
| 225 | + |
| 226 | +CGS is expected to be a prominent consumer, in two ways. Neither changes the |
| 227 | +sets — they are recorded here so the sets are designed with these uses in mind; |
| 228 | +full detail lives in the CGS design doc |
| 229 | +([`api-key-permission-sets.md`](https://github.com/hypercerts-org/certified-group-service/blob/main/docs/design/api-key-permission-sets.md)). |
| 230 | + |
| 231 | +- **Via OAuth + service proxying.** A client calls CGS on a user's behalf; the |
| 232 | + user's PDS has already expanded the requested `include:` set into the grant. |
| 233 | + CGS sees ordinary `repo:` scopes. Identical to any other OAuth resource. |
| 234 | +- **Via CGS API keys.** CGS lets an owner specify an API key's scopes as |
| 235 | + `include:<nsid>`. CGS resolves the published set (same chain as above) and |
| 236 | + expands it to concrete scopes stored on the key, **once at key-creation time** |
| 237 | + (a snapshot — a later change to the set does not retroactively alter issued |
| 238 | + keys until they are re-issued). CGS then gates its own write methods |
| 239 | + (`app.certified.group.repo.*`, which proxy to the group's PDS) by matching the |
| 240 | + request's collection against those `repo:` scopes. |
| 241 | + |
| 242 | +The point for _this_ repo: the sets must be **self-contained and |
| 243 | +deployment-agnostic** — no CGS-specific assumptions baked into the definitions. |
| 244 | +The two CRUD sets satisfy this trivially (pure `repo:` collection grants). |
| 245 | + |
| 246 | +## Maintenance |
| 247 | + |
| 248 | +The enumerated `collection` lists are the one ongoing obligation. Whenever a |
| 249 | +`type: "record"` lexicon is added to or removed from `org.hypercerts.*` or |
| 250 | +`app.certified.*`, the corresponding set must be updated and re-published, or the |
| 251 | +new collection will be silently uncovered by the set. Worth a check in the |
| 252 | +release process (and ideally an automated test asserting each set lists exactly |
| 253 | +the `type: "record"` defs in its namespace). |
| 254 | + |
| 255 | +## References |
| 256 | + |
| 257 | +- AT Protocol permission spec — permission sets: |
| 258 | + https://atproto.com/specs/permission#permission-sets |
| 259 | +- AT Protocol lexicon resolution: https://atproto.com/specs/lexicon |
| 260 | +- CGS consumption design: |
| 261 | + https://github.com/hypercerts-org/certified-group-service/blob/main/docs/design/api-key-permission-sets.md |
0 commit comments