Microservice to bring 2FA to self hosted PDSes
1# PDS Admin Portal
2
3## Overview
4
5Bluesky's PDS admin API relies on `PDS_ADMIN_PASSWORD` — a single shared secret that grants unrestricted access to every administrative endpoint. This is workable for solo operators but becomes a liability when multiple team members need admin access. There is no way to limit what any individual can do, no audit trail of who performed an action, and credential rotation affects everyone simultaneously.
6
7The pds-gatekeeper admin portal solves this by introducing role-based access control (RBAC). Team members authenticate with ATProto OAuth using their own identity, and gatekeeper enforces per-user permissions based on a YAML configuration file. Authorized requests are proxied to the PDS using the admin password on behalf of the authenticated user — the password itself is never exposed to browsers or end users.
8
9## Prerequisites
10
11- A PDS instance running behind pds-gatekeeper
12- HTTPS with a valid TLS certificate (required for ATProto OAuth flows)
13- SMTP configured on the PDS for email functionality (used by the PDS itself, not strictly by the admin portal)
14
15## Quick Start
16
17### 1. Create an RBAC configuration file
18
19Copy the example configuration as a starting point:
20
21```sh
22cp examples/admin_rbac.yaml /path/to/your/admin_rbac.yaml
23```
24
25### 2. Find your team members' DIDs
26
27Use [`goat`](https://github.com/bluesky-social/indigo/tree/main/cmd/goat) to resolve a handle to its DID:
28
29```sh
30goat resolve alice.example.com
31```
32
33The DID is the `id` field in the output (e.g. `did:plc:abcdef1234567890`).
34
35### 3. Set environment variables
36
37```sh
38# Required
39GATEKEEPER_ADMIN_RBAC_CONFIG=/path/to/your/admin_rbac.yaml
40PDS_ADMIN_PASSWORD=your-pds-admin-password
41
42# Optional
43GATEKEEPER_ADMIN_COOKIE_SECRET=<64-character-hex-string>
44GATEKEEPER_ADMIN_SESSION_TTL_HOURS=24
45```
46
47### 4. Restart pds-gatekeeper
48
49```sh
50# If running with systemd:
51sudo systemctl restart pds-gatekeeper
52
53# If running with Docker:
54docker restart pds-gatekeeper
55```
56
57### 5. Navigate to the admin portal
58
59Open your browser and go to:
60
61```
62https://your-pds.example.com/admin/login
63```
64
65## RBAC Configuration
66
67The RBAC configuration is a YAML file with two top-level sections: `roles` and `members`.
68
69- **Roles** define named sets of endpoint patterns that grant access to specific admin operations.
70- **Members** map an ATProto DID to one or more roles.
71
72A member's effective permissions are the **union** of all endpoints from all of their assigned roles.
73
74Endpoint patterns support wildcard matching: `com.atproto.admin.*` matches all endpoints under the `com.atproto.admin` namespace.
75
76Example:
77
78```yaml
79roles:
80 pds-admin:
81 endpoints:
82 - "com.atproto.admin.*"
83 - "com.atproto.server.createInviteCode"
84 - "com.atproto.server.createAccount"
85
86 moderator:
87 endpoints:
88 - "com.atproto.admin.getAccountInfo"
89 - "com.atproto.admin.getAccountInfos"
90 - "com.atproto.admin.getSubjectStatus"
91 - "com.atproto.admin.updateSubjectStatus"
92 - "com.atproto.admin.sendEmail"
93 - "com.atproto.admin.getInviteCodes"
94
95 invite-manager:
96 endpoints:
97 - "com.atproto.server.createInviteCode"
98 - "com.atproto.admin.getInviteCodes"
99 - "com.atproto.admin.disableInviteCodes"
100 - "com.atproto.admin.enableAccountInvites"
101 - "com.atproto.admin.disableAccountInvites"
102
103members:
104 - did: "did:plc:abcdef1234567890"
105 roles:
106 - pds-admin
107
108 - did: "did:plc:bbbbbbbbbbbbbbbb"
109 roles:
110 - moderator
111 - invite-manager
112```
113
114## Available Roles (Reference)
115
116### Suggested role templates
117
118You can make your own roles and teams
119
120| Role | Description | Endpoints |
121|---|---|---|
122| `pds-admin` | Full administrative access | `com.atproto.admin.*`, `createInviteCode`, `createAccount` |
123| `moderator` | View accounts, manage takedowns, send email, view invite codes | `getAccountInfo`, `getAccountInfos`, `getSubjectStatus`, `updateSubjectStatus`, `sendEmail`, `getInviteCodes` |
124| `invite-manager` | Manage invite codes and per-account invite permissions | `createInviteCode`, `getInviteCodes`, `disableInviteCodes`, `enableAccountInvites`, `disableAccountInvites` |
125
126### All admin XRPC endpoints
127
128| Endpoint | Description |
129|---|---|
130| `com.atproto.admin.getAccountInfo` | View single account details |
131| `com.atproto.admin.getAccountInfos` | View multiple accounts |
132| `com.atproto.admin.getSubjectStatus` | Get takedown status |
133| `com.atproto.admin.updateSubjectStatus` | Apply or remove takedowns |
134| `com.atproto.admin.deleteAccount` | Permanently delete an account |
135| `com.atproto.admin.updateAccountPassword` | Reset account password |
136| `com.atproto.admin.enableAccountInvites` | Enable invites for an account |
137| `com.atproto.admin.disableAccountInvites` | Disable invites for an account |
138| `com.atproto.admin.getInviteCodes` | List invite codes |
139| `com.atproto.admin.disableInviteCodes` | Disable specific invite codes |
140| `com.atproto.admin.sendEmail` | Send email to an account |
141| `com.atproto.server.createInviteCode` | Create a new invite code |
142| `com.atproto.server.createAccount` | Create a new account |
143
144## How It Works
145
146### 1. OAuth Login
147
148The user navigates to `/admin/login` and enters their ATProto handle. Gatekeeper initiates an OAuth authorization flow, redirecting the user to their identity's authorization server. The user authenticates there and is redirected back to gatekeeper with an authorization code.
149
150### 2. Session Creation
151
152Gatekeeper exchanges the authorization code for tokens, extracts the user's DID from the OAuth session, and checks it against the RBAC configuration. If the DID is found in the members list, a signed session cookie is created and set in the browser.
153
154### 3. Request Flow
155
156When the user performs an admin action, gatekeeper:
157
1581. Validates the session cookie signature and expiration
1592. Looks up the user's DID in the RBAC configuration
1603. Checks whether the user's roles grant access to the target XRPC endpoint
1614. If authorized, proxies the request to the PDS with `Authorization: Basic` using `PDS_ADMIN_PASSWORD`
1625. Returns the PDS response to the user
163
164### 4. UI Rendering
165
166The admin portal uses server-rendered pages that show or hide actions based on the authenticated user's permissions. However, RBAC is always enforced server-side in route handlers regardless of what the UI displays — hiding a button in the template is a convenience, not a security boundary.
167
168## Environment Variables
169
170| Variable | Required | Default | Description |
171|---|---|---|---|
172| `GATEKEEPER_ADMIN_RBAC_CONFIG` | Yes | — | Path to the RBAC YAML configuration file |
173| `PDS_ADMIN_PASSWORD` | Yes | — | PDS admin password used for proxied requests |
174| `GATEKEEPER_ADMIN_COOKIE_SECRET` | No | Derived from `GATEKEEPER_JWE_KEY` | 32-byte hex key for signing session cookies |
175| `GATEKEEPER_ADMIN_SESSION_TTL_HOURS` | No | `24` | Admin session lifetime in hours |
176
177## Security Considerations
178
179- **Password isolation**: `PDS_ADMIN_PASSWORD` is never sent to or accessible from browsers. It is only used server-side when proxying authorized requests to the PDS.
180- **OAuth security**: The OAuth flow uses DPoP binding and PKCE to prevent token interception and replay attacks.
181- **Cookie protections**: Session cookies are signed (tamper-proof) and set with `HttpOnly`, `Secure`, and `SameSite=Lax` attributes.
182- **Server-side enforcement**: RBAC is enforced in route handlers, not just in template rendering. Manipulating the UI cannot bypass access controls.
183- **Session lifecycle**: Sessions expire after a configurable TTL. Expired sessions are cleaned up automatically.
184- **Opt-in activation**: The admin portal is completely opt-in. If `GATEKEEPER_ADMIN_RBAC_CONFIG` is not set, no admin routes are mounted and the portal is entirely inactive.
185
186## Troubleshooting
187
188**OAuth callback failures**
189Ensure HTTPS is properly configured with a valid certificate, DNS resolves correctly for your PDS hostname, and the hostname the user accesses matches the PDS configuration.
190
191**"Access Denied" after login**
192Verify that the DID in your RBAC configuration exactly matches the DID of the authenticating identity. Use `goat resolve {handle}` to confirm the correct DID.
193
194**Session expired**
195Sessions expire after the configured TTL (default 24 hours). Either increase `GATEKEEPER_ADMIN_SESSION_TTL_HOURS` or log in again.
196
197**403 on admin action**
198The authenticated user's roles do not include the endpoint being accessed. Check the `members` and `roles` sections of your RBAC config to ensure the required endpoint pattern is granted.
199
200**Admin portal not appearing**
201Confirm that `GATEKEEPER_ADMIN_RBAC_CONFIG` is set in the environment, the file path is correct, and the file exists and is readable by the gatekeeper process.