···11+# PDS Admin Portal
22+33+## Overview
44+55+Bluesky'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.
66+77+The 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.
88+99+## Prerequisites
1010+1111+- A PDS instance running behind pds-gatekeeper
1212+- HTTPS with a valid TLS certificate (required for ATProto OAuth flows)
1313+- SMTP configured on the PDS for email functionality (used by the PDS itself, not strictly by the admin portal)
1414+1515+## Quick Start
1616+1717+### 1. Create an RBAC configuration file
1818+1919+Copy the example configuration as a starting point:
2020+2121+```sh
2222+cp examples/admin_rbac.yaml /path/to/your/admin_rbac.yaml
2323+```
2424+2525+### 2. Find your team members' DIDs
2626+2727+Use [`goat`](https://github.com/bluesky-social/indigo/tree/main/cmd/goat) to resolve a handle to its DID:
2828+2929+```sh
3030+goat resolve alice.example.com
3131+```
3232+3333+The DID is the `id` field in the output (e.g. `did:plc:abcdef1234567890`).
3434+3535+### 3. Set environment variables
3636+3737+```sh
3838+# Required
3939+GATEKEEPER_ADMIN_RBAC_CONFIG=/path/to/your/admin_rbac.yaml
4040+PDS_ADMIN_PASSWORD=your-pds-admin-password
4141+4242+# Optional
4343+GATEKEEPER_ADMIN_COOKIE_SECRET=<64-character-hex-string>
4444+GATEKEEPER_ADMIN_SESSION_TTL_HOURS=24
4545+```
4646+4747+### 4. Restart pds-gatekeeper
4848+4949+```sh
5050+# If running with systemd:
5151+sudo systemctl restart pds-gatekeeper
5252+5353+# If running with Docker:
5454+docker restart pds-gatekeeper
5555+```
5656+5757+### 5. Navigate to the admin portal
5858+5959+Open your browser and go to:
6060+6161+```
6262+https://your-pds.example.com/admin/login
6363+```
6464+6565+## RBAC Configuration
6666+6767+The RBAC configuration is a YAML file with two top-level sections: `roles` and `members`.
6868+6969+- **Roles** define named sets of endpoint patterns that grant access to specific admin operations.
7070+- **Members** map an ATProto DID to one or more roles.
7171+7272+A member's effective permissions are the **union** of all endpoints from all of their assigned roles.
7373+7474+Endpoint patterns support wildcard matching: `com.atproto.admin.*` matches all endpoints under the `com.atproto.admin` namespace.
7575+7676+Example:
7777+7878+```yaml
7979+roles:
8080+ pds-admin:
8181+ endpoints:
8282+ - "com.atproto.admin.*"
8383+ - "com.atproto.server.createInviteCode"
8484+ - "com.atproto.server.createAccount"
8585+8686+ moderator:
8787+ endpoints:
8888+ - "com.atproto.admin.getAccountInfo"
8989+ - "com.atproto.admin.getAccountInfos"
9090+ - "com.atproto.admin.searchAccounts"
9191+ - "com.atproto.admin.getSubjectStatus"
9292+ - "com.atproto.admin.updateSubjectStatus"
9393+ - "com.atproto.admin.sendEmail"
9494+ - "com.atproto.admin.getInviteCodes"
9595+9696+ invite-manager:
9797+ endpoints:
9898+ - "com.atproto.server.createInviteCode"
9999+ - "com.atproto.admin.getInviteCodes"
100100+ - "com.atproto.admin.disableInviteCodes"
101101+ - "com.atproto.admin.enableAccountInvites"
102102+ - "com.atproto.admin.disableAccountInvites"
103103+104104+members:
105105+ - did: "did:plc:abcdef1234567890"
106106+ roles:
107107+ - pds-admin
108108+109109+ - did: "did:plc:bbbbbbbbbbbbbbbb"
110110+ roles:
111111+ - moderator
112112+ - invite-manager
113113+```
114114+115115+## Available Roles (Reference)
116116+117117+### Suggested role templates
118118+119119+You can make your own roles and teams
120120+121121+| Role | Description | Endpoints |
122122+|---|---|---|
123123+| `pds-admin` | Full administrative access | `com.atproto.admin.*`, `createInviteCode`, `createAccount` |
124124+| `moderator` | View accounts, manage takedowns, search, send email, view invite codes | `getAccountInfo`, `getAccountInfos`, `searchAccounts`, `getSubjectStatus`, `updateSubjectStatus`, `sendEmail`, `getInviteCodes` |
125125+| `invite-manager` | Manage invite codes and per-account invite permissions | `createInviteCode`, `getInviteCodes`, `disableInviteCodes`, `enableAccountInvites`, `disableAccountInvites` |
126126+127127+### All admin XRPC endpoints
128128+129129+| Endpoint | Description |
130130+|---|---|
131131+| `com.atproto.admin.getAccountInfo` | View single account details |
132132+| `com.atproto.admin.getAccountInfos` | View multiple accounts |
133133+| `com.atproto.admin.searchAccounts` | Search accounts |
134134+| `com.atproto.admin.getSubjectStatus` | Get takedown status |
135135+| `com.atproto.admin.updateSubjectStatus` | Apply or remove takedowns |
136136+| `com.atproto.admin.deleteAccount` | Permanently delete an account |
137137+| `com.atproto.admin.updateAccountPassword` | Reset account password |
138138+| `com.atproto.admin.enableAccountInvites` | Enable invites for an account |
139139+| `com.atproto.admin.disableAccountInvites` | Disable invites for an account |
140140+| `com.atproto.admin.getInviteCodes` | List invite codes |
141141+| `com.atproto.admin.disableInviteCodes` | Disable specific invite codes |
142142+| `com.atproto.admin.sendEmail` | Send email to an account |
143143+| `com.atproto.server.createInviteCode` | Create a new invite code |
144144+| `com.atproto.server.createAccount` | Create a new account |
145145+146146+## How It Works
147147+148148+### 1. OAuth Login
149149+150150+The 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.
151151+152152+### 2. Session Creation
153153+154154+Gatekeeper 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.
155155+156156+### 3. Request Flow
157157+158158+When the user performs an admin action, gatekeeper:
159159+160160+1. Validates the session cookie signature and expiration
161161+2. Looks up the user's DID in the RBAC configuration
162162+3. Checks whether the user's roles grant access to the target XRPC endpoint
163163+4. If authorized, proxies the request to the PDS with `Authorization: Basic` using `PDS_ADMIN_PASSWORD`
164164+5. Returns the PDS response to the user
165165+166166+### 4. UI Rendering
167167+168168+The 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.
169169+170170+## Environment Variables
171171+172172+| Variable | Required | Default | Description |
173173+|---|---|---|---|
174174+| `GATEKEEPER_ADMIN_RBAC_CONFIG` | Yes | — | Path to the RBAC YAML configuration file |
175175+| `PDS_ADMIN_PASSWORD` | Yes | — | PDS admin password used for proxied requests |
176176+| `GATEKEEPER_ADMIN_COOKIE_SECRET` | No | Derived from `GATEKEEPER_JWE_KEY` | 32-byte hex key for signing session cookies |
177177+| `GATEKEEPER_ADMIN_SESSION_TTL_HOURS` | No | `24` | Admin session lifetime in hours |
178178+179179+## Security Considerations
180180+181181+- **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.
182182+- **OAuth security**: The OAuth flow uses DPoP binding and PKCE to prevent token interception and replay attacks.
183183+- **Cookie protections**: Session cookies are signed (tamper-proof) and set with `HttpOnly`, `Secure`, and `SameSite=Lax` attributes.
184184+- **Server-side enforcement**: RBAC is enforced in route handlers, not just in template rendering. Manipulating the UI cannot bypass access controls.
185185+- **Session lifecycle**: Sessions expire after a configurable TTL. Expired sessions are cleaned up automatically.
186186+- **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.
187187+188188+## Troubleshooting
189189+190190+**OAuth callback failures**
191191+Ensure 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.
192192+193193+**"Access Denied" after login**
194194+Verify that the DID in your RBAC configuration exactly matches the DID of the authenticating identity. Use `goat resolve {handle}` to confirm the correct DID.
195195+196196+**Session expired**
197197+Sessions expire after the configured TTL (default 24 hours). Either increase `GATEKEEPER_ADMIN_SESSION_TTL_HOURS` or log in again.
198198+199199+**403 on admin action**
200200+The 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.
201201+202202+**Admin portal not appearing**
203203+Confirm 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.
Problem: Bluesky's reference PDS using a PDS_ADMIN_PASSWORD with basic auth as an admin api authz. If the PDS is managed by a team, then this becomes a shared password.
Solution: have pds-gateway manage the PDS_ADMIN_PASSWORD and have the PDS administration team login to pds-gateway via OAuth with their corresponding PDS, and then they'll be given a portal scoped to their capabilities specified by the RBAC scpecification, see ADMIN.md. Admin portal is inspired by https://github.com/betamax/pds-admin. When an admin team member wants to perform an operation (e.g. send a new users an invite code), the team member can click through the portal to do so and the pds-gateway will perform the operations with the PDS_ADMIN_PASSWORD on their behalf. This prevents the shared password problem whilst giving admins a tool to manage their PDS easier!