Skip to content
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
23a3284
Add RBAC support proposal
cursoragent Jul 3, 2026
beccaa0
Add privacy boundary and privacy page to RBAC proposal
cursoragent Jul 3, 2026
8d631f2
Add public privacy page and usage documentation
cursoragent Jul 3, 2026
87e2177
feat(rbac): add core RBAC migration, registry, and server utilities
cursoragent Jul 3, 2026
6ce5328
feat(rbac): add MCP caller context roles and permissions
cursoragent Jul 3, 2026
c31e6ae
feat(rbac): add admin UI for users and roles management
cursoragent Jul 3, 2026
f5bfe4b
test(e2e): use unique admin RBAC fixture emails per run
cursoragent Jul 3, 2026
a3748d9
fix(e2e): run assignRole D1 writes through wrangler-env wrapper
cursoragent Jul 3, 2026
67b7a49
test(e2e): seed admin RBAC users via D1 and login directly
cursoragent Jul 3, 2026
2d872de
Merge branch 'cursor/rbac-mcp-context-66a6' into cursor/rbac-integrat…
cursoragent Jul 3, 2026
df2320b
Merge branch 'cursor/rbac-privacy-page-66a6' into cursor/rbac-integra…
cursoragent Jul 3, 2026
943a6d6
docs(rbac): ship authorization architecture doc and agent skill
cursoragent Jul 3, 2026
bb2831b
fix(e2e): request full user page in admin RBAC spec
cursoragent Jul 3, 2026
50c383a
chore: trigger CI after retargeting PR to main
cursoragent Jul 3, 2026
c5e4b14
fix(rbac): make last-admin guard atomic and fail signup on missing de…
cursoragent Jul 3, 2026
f4299a7
fix(rbac): address CodeRabbit review — resilient MCP role lookup, aud…
cursoragent Jul 3, 2026
aa8b9a2
fix(rbac): roll back user row when default role assignment fails at s…
cursoragent Jul 3, 2026
6e843e0
fix(rbac): carry pagination query string on admin role POSTs
cursoragent Jul 3, 2026
9ae04b1
fix(rbac): backfill user role for pre-rbac accounts and fail closed o…
cursoragent Jul 3, 2026
01d2f4d
feat(rbac): seed default admin fixture plus regular twix account
cursoragent Jul 3, 2026
144ba5a
fix(rbac): keep companion fixture account local-only
cursoragent Jul 3, 2026
b27500a
chore: rename test fixtures to jane@example.com / ilikecode
cursoragent Jul 3, 2026
da0c7e2
docs(rbac): drop the rbac skill in favor of the authorization doc
cursoragent Jul 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
199 changes: 199 additions & 0 deletions .agents/skills/rbac/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
---
name: rbac
description:
Guard app routes and MCP capabilities with Kody's typed RBAC layer. Use when
adding permissions, protecting admin endpoints, checking own vs any access,
extending the permission registry, or reviewing the admin privacy boundary.
---

# RBAC (role-based access control)

Kody uses Epic Stack-style RBAC: users have roles, roles have permissions,
effective permissions are the union across roles, and permission strings follow
`action:entity:access`.

Read
[`docs/contributing/architecture/authorization.md`](../../../docs/contributing/architecture/authorization.md)
for the full shipped model. This skill is the copy-paste cheat sheet.

## When to use `own` vs `any`

| Access | Use for |
| ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `own` | Default. Every existing data path — secrets, packages, jobs, memories, email, storage. Scoped by `userId` at the data layer. |
| `any` | **Account administration only** — listing users, reading roles, assigning/removing roles. Only inside handlers that explicitly call a guard with an `:any` permission. |

Rules:

- Never add `:any` permissions for content entities. The registry currently has
only `user` and `role`.
- No query helper infers `any` from context. The guard at the handler entry is
the only escape hatch from per-user isolation.

## Guard an app route

Import from `packages/worker/src/app/permissions-server.ts`.

**HTML shell** — require a role:

```ts
import { requireUserWithRole } from '#app/permissions-server.ts'

export function createAdminUsersHandler(env: Env) {
return {
middleware: [],
async handler({ request }) {
try {
await requireUserWithRole(request, env, 'admin')
} catch (error) {
if (error instanceof Response) return error
throw error
}
// ... render shell
},
}
}
```

**JSON API** — require a specific permission per action:

```ts
import { requireUserWithPermission } from '#app/permissions-server.ts'

export function createAdminUsersApiHandler(env: Env) {
return {
middleware: [],
async handler({ request }) {
try {
if (request.method === 'GET') {
await requireUserWithPermission(request, env, 'read:user:any')
// ... return list
}
if (request.method === 'POST') {
const actor = await requireUserWithPermission(
request,
env,
'update:user:any',
)
// ... mutate; log audit event (category 'admin')
}
} catch (error) {
if (error instanceof Response) return error
throw error
}
},
}
}
```

Guards throw `Response` (401 unauthenticated, 403 forbidden). Always catch
`instanceof Response` and return it.

**Client-only check** (cosmetic — never a security boundary):

```ts
import { userHasRole } from '#app/permissions.ts'

const showAdminLink =
isLoggedIn && session != null && userHasRole(session, 'admin')
```

## Guard an MCP capability

Import from `packages/worker/src/mcp/capabilities/meta/require-permission.ts`.

```ts
import { requireMcpUserWithPermission } from './require-permission.ts'

export async function myAdminCapability(ctx: McpCallerContext) {
const user = requireMcpUserWithPermission(ctx, 'read:user:any')
// user.roles and user.permissions are fresh per request
}
```

Roles load in `buildMcpUserContextFromGrantProps` — they do **not** live in
OAuth grant props. No existing capabilities use this yet; add it when shipping
admin functionality behind `search`/`execute`.

## Add a new permission entity (end-to-end)

Example: adding entity `invitation` (hypothetical — do not add without a product
need).

1. **Registry** — add to `permissionEntities` in
`packages/worker/src/app/permissions.ts`:

```ts
export const permissionEntities = ['user', 'role', 'invitation'] as const
```

2. **Migration** — next free prefix under `packages/worker/migrations/`:

```sql
INSERT OR IGNORE INTO permissions (action, entity, access) VALUES ('create', 'invitation', 'own');
-- ... all action × access combinations for the new entity

INSERT OR IGNORE INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id FROM roles r CROSS JOIN permissions p
WHERE r.name = 'user' AND p.entity = 'invitation' AND p.access = 'own';

INSERT OR IGNORE INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id FROM roles r CROSS JOIN permissions p
WHERE r.name = 'admin' AND p.entity = 'invitation';
```

3. **Role attachment** — decide which roles get `own` vs `any` for the entity.
Match the pattern in `0043-rbac.sql`.

4. **Drift test** — `packages/worker/src/app/permissions.node.test.ts` parses
migration `INSERT` rows and compares to `listRegistryPermissionStrings()`.
Run `npm run validate`; drift fails the test.

5. **Guards** — use literal `PermissionString` at every new handler/capability
entry point.

## Privacy boundary rules

Admin endpoints are **metadata-only**. When touching admin code:

- Query only `users`, `user_roles`, `roles`, `permissions`, `role_permissions`.
Never join content tables.
- Return only fields listed in `adminUserListItemFieldNames` for user list items
(`id`, `username`, `email`, `created_at`, `updated_at`, `roles`).
- If you add a field to the admin users payload, update
`adminUserListItemFieldNames` **and** the shape test in
`admin-users.node.test.ts` deliberately — do not bypass the test.
- Do not add content entities to `permissionEntities` without an explicit
product decision and doc update to `authorization.md` and
`docs/use/privacy.md`.

## Last-admin guardrail

Never remove the `admin` role with a plain delete. Use the atomic helper — the
last-admin check lives inside the `DELETE` statement so concurrent removals
cannot race a stale count:

```ts
import { removeAdminRolePreservingLastAdmin } from '#app/permissions-server.ts'

const { removed } = await removeAdminRolePreservingLastAdmin({
db: env.APP_DB,
userId: targetUserId,
})
// removed === false means either the target was the last admin (return 409)
// or the target did not have the role (idempotent no-op) — see
// handleRemoveRoleAction in admin-users.ts for the disambiguation.
```

Copy the full pattern from `handleRemoveRoleAction` in
`packages/worker/src/app/handlers/admin-users.ts`.

## Quick reference

| Module | Purpose |
| ----------------------- | -------------------------------------- |
| `permissions.ts` | Typed registry, pure `userHas*` checks |
| `permissions-db.ts` | D1 load/assign/remove |
| `permissions-server.ts` | Request guards |
| `require-permission.ts` | MCP guard |
| `0043-rbac.sql` | Schema + seed |
14 changes: 7 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,13 +109,13 @@ Copy `packages/worker/.env.example` to `packages/worker/.env` if missing.

### Seeding a test account

After `npm run migrate:local`, seed the local E2E fixture login
(`kody@example.com` / `iliketwix`) per
[`docs/contributing/setup.md`](./docs/contributing/setup.md). These credentials
are fixtures only, not privileged runtime accounts. If
`node tools/seed-test-data.ts --local` fails because Wrangler cannot resolve
`APP_DB` without the worker config wrapper, run the seed SQL through
`./wrangler-env.ts` instead (same pattern as the migrate script).
After `npm run migrate:local`, seed the local fixture logins per
[`docs/contributing/setup.md`](./docs/contributing/setup.md): `kody@example.com`
/ `iliketwix` (seeded with the `admin` role) and `twix@example.com` /
`iliketwix` (regular account). These credentials are local test fixtures only.
The seed script resolves the worker Wrangler config automatically (same default
as `wrangler-env.ts`), so `node tools/seed-test-data.ts --local` works without
extra flags.

### Local limitations

Expand Down
4 changes: 4 additions & 0 deletions docs/contributing/architecture/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ data.
1. Cookie-based app sessions for browser users
2. OAuth bearer tokens for MCP access

Authorization (roles and permissions) is layered on top of authentication. See
[Authorization](./authorization.md) for the RBAC model, admin routes, and the
`any`-access exception for account administration.

## Browser app sessions

Session cookie behavior is implemented in
Expand Down
Loading