atproto made simple: granular permissions

you're working on an atproto app that needs to write some records to your own collection like app.example.post or app.example.like.

you're already using OAuth instead of app passwords. however you're still requesting transition:generic scope, which means your app is asking for way too much power. that's not good!

you've tried reading the official Permission Sets documentation and felt that this is too difficult. i know -- i couldn't understand them either. turns out, actually using permission sets is easy. it's only reading the documentation that's difficult. so let's skip that part.

here's how you can fix your permissions to be granular and good.

writing to collections

currently you have something like this in your codebase:

scope: "atproto transition:generic",

maybe in several places. replace transition:generic with the specific things you need. to write to some collections, add repo:<collection name> permissions per collection -- for example:

scope: "atproto repo:app.example.post repo:app.example.like",

one repo for each collection you want to be writing to. yes, you do have to list each of the collections separately unless you want a scary global "write anything" permission (i assume you don't).

i repeat: there is no app.example.* wildcard. it's either repo:* ("i want to write any app's records") or granular repo:foo repo:bla.

optional: granular actions

you can make actions granular per collection. maybe you only want to ask to "create" and "update" but not delete -- this would work:

scope: "atproto repo:app.example.post?action=create&action=update repo:app.example.like",

omitting ?action asks for full permissions (create, update, delete), i.e. the same as ?action=create&action=update&action=delete.

for your own app's collections, full permissions usually make sense.

in case you need blobs

if you need to upload images/video, also throw in blob:*/* in there:

scope: "atproto blob:*/* repo:app.example.post repo:app.example.like",

but wait... this kinda sucks though?

this will work but the popup the user will see will still be unpleasant and confusing because there's no human-readable description. it will just say "Repository" and pressing "?" will show something like this:

if you want a nicer popup, you need to publish a "permission set".

let's do that now!

making it look nice with a permission set

this is what you had before:

scope: "atproto repo:app.example.post repo:app.example.like",

for a nicer permission dialog, we need to extract those repo: permissions into a "permission set". replace them with include:

scope: "atproto include:app.example.fullPermissions",

what's app.example.fullPermissions here?

that's a "permission set" which lets you take the permissions above and give them a human-readable description.

your app.example.fullPermissions could look like this:

// at://did/com.atproto.lexicon.schema/app.example.fullPermissions
{
"lexicon": 1,
"id": "app.example.fullPermissions",
"defs": {
"main": {
"type": "permission-set",
"title": "Example App Permissions",
"detail": "Manage Example App posts and likes.",
"permissions": [
{
"type": "permission",
"resource": "repo",
"collection": [
// Collections you want to write to
"app.example.post",
"app.example.like"
]
}
]
}
}
}

as you can see, this is sort of an expanded form of the inline string you had to write earlier. this doc page shows short vs long forms. you'll also find the JSON granular actions syntax and blobs there. again, you have to enumerate all collections you want to write to.

this JSON looks like a lexicon, right? yes! permission sets are just lexicons. you'd publish it the same way you'd publish any lexicon.

after you publish it as a lexicon (create record + update DNS), the include:app.example.fullPermissions syntax in scope will work.

then instead of "Repository" it will say what you wrote, for example something like this. (note clicking "?" would still bring up a table.)

permission set naming

there's nothing special about permission set name. you could've called it app.example.postingAndLikingPermissions. the important bit is that the reverse namespace of your set (here, app.example.) must be "above" the collections that you want that set to write to.

effectively this means that your app's permission sets can only ask for permissions to write to your app's (app.example.*) collections.

if you want to request writing to another app's collections, you'll have to include: that app's permission sets (to display them nicely) or, if the app hasn't published any, you'd have to manually ask for repo:<collection name> permissions for each collection.

evolving permission sets

if you add new collections to your app, you probably will want to broaden permissions of your permission set -- since your currently logged in users won't be able to write to those collections yet.

usually you'd add new collections to your existing set. then you can add some application logic so that your app can handle "doesn't have permissions to write this" gracefully and requests re-authentication.

however, there's also an easier way -- if you add more collections to a permission set and wait 30 minutes after updating it, the new permissions will work for users who have approved that set before. then you can deploy the code that needs to write those collections.

that said, gracefully asking for more permissions as needed is a useful pattern in general. my rule of thumb is to request permissions upfront for writing my own app's records (by bundling them into my set) but to request permissions as needed for other apps' records.

hope this helps!

bonus: how to avoid ugly url in the popup

if you're seeing an ugly .json url in the permission request dialog message header, it's because your OAuth client metadata file is in the wrong place. make sure your client_id is set to something like ${PUBLIC_URL}/oauth-client-metadata.json -- that exact name.

then it'll just show just your app's domain instead of that json path.

of course, for that to work, you'll need to actually serve that file.