This post will be pretty raw and rambling. I wrote it to get my thoughts in order while planning the next steps for the arbiter, but I figured I might as well share things as I go.

Background

Right now the arbiter has a concept of "sub policies" that can be stored on the community's PDS and routed to from the "root" policy. This was meant to help apps compose policies in the community, allowing each app to control it's own policy while being able to somehow "install" it into the root policy, without being able to change other apps' policies.

The actual way to do that though was never thought out, it was just a proof of concept. It also did not consider the difficulty in obtaining reasonable OAuth scopes from the PDS login that could be used to manage the app policy, without also granting power to modify the root policy. This kind of defeated the whole point of sub-policies in the first place.

This post will outline a new plan that will better ( actually ) accomplish the goal of allowing apps to manage their own policies, while also working around the limitations of ATProto OAuth scopes for granting permissions to community accounts.

The OAuth Scope Issue

There is a fundamental challenge raised by the very concept of the arbiter: it presents an API for acting on behalf of another account, and the PDS doesn't have scopes that can represent the access for the remote account.

For example, when you login to an Atmosphere app that wants to create events, it will request a repo:community.lexicon.calendar.event scope to create, update, or delete records in that collection on your PDS. This is a nicely granular permission. It doesn't allow the app to publish to my Leaflets or post to Bluesky, it can just manage events.

When it comes to the arbiter's API, we are unable to represent this kind of granular access with scopes. The only scope kind we can use is the rpc: scope which allows us to limit based on the XRPC call's NSID. So to use the arbiter an app has to ask for an rpc:town.muni.arbiter.proxy scope, but this allows access to do any action for any accounts that the arbiter manages, that you are the admin of.

When logging into an app we are stuck asking for all-or-nothing access to the accounts that you manage on the arbiter, with no ability to restrict it like you can with personal accounts. This is an unfortunate security consequence.

Note: One workaround for this is described in the opensocial proposal that is referenced in this thread. The reason this is not considered for our use-case is because it requires you to login to each individual remote account that you want to manage, instead of being able to use your one ATProto login session once to authenticate to all of the communities that you manage.

Proposed Solution

Emulating Custom OAuth Scopes with RPC Endpoints

While we are not able to make custom types of scopes, because they are enforced on the PDS, we can do something that is somewhat similar by abusing permission sets and rpc: scopes.

Consider the town.muni.arbiter.proxy XRPC endpoint. It allows you to request that the arbiter send an XRPC request to another server on behalf of an account managed by the arbiter. As already discussed, the issue with allowing this endpoint is that it is all-or-nothing because of the rpc:town.muni.arbiter.proxy scope.

But what if we support an arbitrary *.arbiter.proxy NSID to be used, where they are all expected to conform to the same town.muni.arbiter.proxy lexicon. This allows us to encode more information into the plain rpc: scope.

For example, the scope rpc:community.lexicon.authCalendar.arbiter.proxy, allows us to scope down that broad arbiter.proxy access to something more specific, in this case to lexicon community calendar.

Now this raises a couple questions:

  1. 1.

    How does the arbiter know what should be allowed, just based on the community.lexicon.authCalendar prefix? For example, does it apply only to records that start with that prefix? Is it read, update, or delete access?

  2. 2.

    How do we present the permission that is granted by the scope legibly to the user?

We will tackle question 2 first, by using permission sets.

Using Permission Sets to Surface Custom Scopes to Users

Permission sets allow us to resolve NSIDs like community.lexicon.authCalendar to a lexicon containing a title and description that can be shown on the PDS's OAuth consent screen. That completes the user-facing side of the "custom OAuth scope" by allowing us to describe what the scope means when authorizing a login.

The permission set only needs to contain a single actual scope: rpc:community.lexicon.authCalendar.arbiter.proxy. That will let the app make requests to the arbiter.

Now that we've told the user what that scope means, though, we need a way to tell the arbiter what it means.

Using Permission Sets to Embed Arbiter Policies

The arbiter already uses Rego policies in order to control what users are allowed to do. The main idea behind this new strategy is to include another Rego policy as extra data inside the permission set lexicon.1

In this way the permission set simultaneously explains to the user what the access they are granting, and tells the arbiter how to grant it. When a user makes a request to the arbiter using the community.lexicon.authCalendar.arbiter.proxy endpoint, the arbiter will:

  1. 1.

    Strip the arbiter.proxy suffix from the NSID in order to get the community.lexicon.authCalendar prefix.

  2. 2.

    Check whether or not community.lexicon.authCalendar has been added as an approved policy. This is important because it means the community decides what kind of scopes are even allowed to be given away.

  3. 3.

    Resolve the Rego policy by looking up the permission set for community.lexicon.authCalendar.

  4. 4.

    Make sure that any requests made through that XRPC endpoint satisfy the associated policy.

Note: It's also worth recognizing that this moves the enforcement of the scope from the PDS ( which is the normal way ) to the XRPC server. This is a necessity because we are trying to avoid changing the PDS standard.
The distinction means that an incorrectly implemented arbiter could allow all requests to go through regardless of the Rego policy or another kind of failure. It's up to the arbiter to enforce the rules and you need a trustworthy arbiter for your community anyway.

Interlude: The Purpose of a Scope

I think now is a good time to talk about the purpose of a scope because this plan is addressing a few different kinds of protections.

Normal PDS Scopes

Normally, an OAuth scope is meant to precisely "unlock" some particular kind of access to your own PDS account. It protects your data from the app.

The Atmosphere makes its apps very differently than most apps like GitHub or Twitter. It puts all your data in one place, on one server, regardless of the app. That is great and gives you lots of control, but it also means that with just a single login screen, you can give any Atmosphere app quite a lot of control of your personal "everything database". That's a lot of responsibility, and the lack of boundaries between apps that we pride ourselves on in the Atmosphere does come with extra security concerns because of it.

OAuth scopes and the OAuth consent screen help you keep most of your "everything database" to yourself instead of just willy-nilly letting any app get access to the whole thing. It gives users visibility into what they are granting different apps and gives them a chance to refuse access that they don't trust the app with.

Arbiter Scopes

These "arbiter scopes" that we're playing with in this design plan have a similar goal: they're meant to precisely unlock only some access to your managed community accounts, instead of giving out a key for the whole thing. But there is yet another responsibility that the arbiter has to deal with, too.

When you are writing to your own PDS, it's your database. You own it, you always implicitly have permission to it. OAuth scopes don't restrict your access to your database, they restrict the app's access to your database. But you always had full access to your own database.

That's not always the case with the arbiter's community-managed accounts. Yes you will probably have "admin" or "owner" account that will be able to do anything on the community's account, but many users might only have partial access to the community account. Defining this partial access is the goal of arbiter policies as they exist today: they protect the community from the user.

Different Kinds of Protection

So we have a few different kinds of access protections we are interested in:

  1. 1.

    On a user's PDS, the OAuth scopes help protect the user's data from apps they log into.

  2. 2.

    On a community's account, arbiter policies protect the community's data from its users.

  3. 3.

    When a user logs into a community management app, we still need to protect the community from the app, just like user OAuth scopes, but we can't do that directly without support on the PDS.2 So we add the concept of arbiter scopes.

This plan focuses on a way to solve problem 3, without specific PDS support, and it wants to simultaneously become more precise about a good way to handle problem 2.

Configuring a Community Account

So on the arbiter, we've now defined two layers of protection.

  1. 1.

    When a request first comes into the arbiter it will come under a particular *.arbiter.proxy endpoint. The arbiter makes sure it's an allowed scope, resolves the scope policy, and makes sure the request is allowed by the scope.

  2. 2.

    Once a request is approved for a particular scope, then is still passed through the community's user policies. This protects the community from the user, preventing them from doing things in the community that they are not allowed to do, checking their access against their roles, etc.

The hope is that long term we will not have to have layer 1 in the arbiter, because it will be in the PDS.

Nuances Around Arbiter Scopes

It's worth noting that an arbiter scope like community.lexicon.authCalendar shouldn't normally be able to approve you to create records such as app.bsky.feed.post. We want it to be limited, by default, to only allowing you to do things under the community.lexicon namespace. That way you can't have some random malicious.domain.authInnocent scope with a policy granting access to just do anything, despite a description on the OAuth consent screen stating otherwise.

The issue we have with this sensible restriction is that we will run into the fact that we will want to create arbiter scopes for apps that have not decided to create an arbiter scope policy themselves. To account for this we do need to have some concept of "trusted scopes" in the community's arbiter config.

If a scope is not trusted by a community, it should only be allowed to approve records and XRPC calls in its own namespace. If it is explicitly trusted by the community, though, then it could grant access to endpoints or records that are under different namespaces.

This escape hatch lets us release arbiter scopes for other apps without having to make them all go along with our convention and create arbiter-compatible permission sets.

Details Around Arbiter Policies

Immediately after deploying the arbiter a few weeks ago I ran into the need for app-specific policies that could be more easily updated, without having to push a configuration change to every single community.

For example, Roomy has over 4k community accounts hosted on it: whenever we change the Roomy app to include different functionality, and therefore a different policy, it currently would require pushing 4k record updates, one for each community account, which isn't ideal.

I think that a better solution would be to make a couple changes:

  1. 1.

    Structure policies more like HTTP middleware or a firewall, with a list of policies that run in order and are each given the opportunity to reject, handle, or pass through the request to a lower layer.

  2. 2.

    Allow policies to be installed referencing a remote policy record, allowing all the Roomy spaces to reference one roomy policy that can be updated automatically across all spaces that use it.

I think for change 2 we can just use an at:// URI that points to a policy. That policy can be hosted by a remote account like @roomy.space where it can be updated by the Roomy developers, or it can be hosted on the community repo where it must be manually updated by the community. It's up to the community / use-case.

I think change 1 makes it feasible to combine multiple different policies within the same community. "Installing" a policy into a community would be a very sensitive operation requiring a specific RPC endpoint on the arbiter server, so that it wouldn't normally be granted when you login to an app.

Installing a policy is very powerful, though, because, hypothetically ( this might be a stretch and hasn't been validated ), it could allow our same arbiter server to act as a space host that is compatible with the simplespace proposal, the opensocial proposal, and the Habitat groups proposal, just by installing policies that implement them.

Policies, similar to arbiter scopes might need some differentiation between "trusted" or "privileged" policies that are able to do anything, or ones that are only allowed to work within their namespace.

I think for a first pass I'll just make them all privileged for simplicity. 🤔 That probably goes for arbiter scopes, too. We can start off just requiring all scopes to be whitelisted by the community config.

Closing Thoughts

I think for Roomy's purposes this gives us a powerful, convenient, and relatively secure solution for managing lots of community accounts in a way that doesn't require changes to the PDS and gives a user experience that feels pretty normal as being able to login, approve scopes, and then continue with the access that you granted.

It's designed to be completely agnostic to Roomy, so that other apps can use the same arbiter for their own community management apps, etc.

This plan hasn't been prototyped yet, writing this out was the first step, and having to explain things actually changed the plan while I was writing it!

I initially didn't realize the distinction between policy to protect the community from the user, and policy to protect the community from the app. I was going to merge them into one thing at first. I think having a distinction between arbiter scopes and policies makes a lot more sense, though.

Anyway, if you have thoughts feel free to hit me up, this is definitely on the wild and experimental side of things. Gotta test it out!

PS Roomy Integration With the Arbiter

We've already got all Roomy spaces integrated with our current version of the arbiter, and I have some work-in-progress integration that allows Roomy admins to leverage the integration to control the Roomy space's native ATProto account:

Muni Town's avatar

Hello folks! This is an experimental post from our Roomy space's own ATProto account. ✍️ Crazy cool that each Roomy space now has an ATProto account. We've just got to hook up more features so you can use it.

Figuring out how we're going to update policies as we add more features has kept me from enabling this for everyone just yet, but I'm super excited to start landing new ATProto integrations in Roomy powered by this! 🚀