Authorization (RBAC)

September 20, 2026 · View on GitHub

Kody is multi-user with strict per-user isolation as the default. Role-based access control (RBAC) adds a narrow, explicitly-guarded exception for deployment operators: permissions with access = 'any' allow specific account-administration endpoints to cross user boundaries. Operator-owned system email for reserved platform addresses is the other deliberate exception: it is stored under the reserved system:email owner id, not any human account. See Project intent and the per-user-isolation invariant in Primitives map.

User-approved platform feedback is a third narrow exception. A submission crosses into the admin review surface only after the user explicitly approves it. The exception covers that attributed submission and its triage state, never unrelated user content.

Community activity metadata is a fourth narrow exception. Admins may see who forked or rated a deliberately public community listing and when, plus rating scores. This boundary never exposes forked package source, rating notes, secrets, or unrelated account content.

For browser and MCP authentication mechanics, see Authentication.

Model

Users have roles. Roles have permissions. A user's effective permissions are the union of all permissions attached to their roles.

Permission strings follow action:entity:access:

PartValuesMeaning
actioncreate, read, update, deleteCRUD-style verb
entityuser, roleWhat the permission applies to
accessown, anyScope: own account only, or any account

Examples: read:user:own, update:user:any, read:role:any.

  • own — the default mental model for all existing data paths. Ordinary users operate only on their own account and content.
  • any — honored only inside handlers that call requireUserWithPermission(..., '<action>:<entity>:any') or requireMcpUserWithPermission(ctx, '<action>:<entity>:any'). No general query helper infers any from context.

The typed registry in packages/worker/universal/permissions.ts is the source of truth for valid permission strings. Call sites pass literal PermissionString values so typos and undeclared entities are compile errors.

Baseline roles

The squashed baseline (packages/worker/migrations/0001-squashed-init.sql) defines the RBAC tables and seeds two roles:

  • user — default role assigned at signup. Permissions: create|read|update|delete:{entity}:own for every entity in the registry.
  • admin — operator role. Everything user has, plus create|read|update|delete:{entity}:any for every entity in the registry.

Role names are typed: roleNames = ['user', 'admin'] in permissions.ts.

There is no runtime path that grants admin. The first admin is bootstrapped with SQL (see First admin bootstrap below).

Database tables

Four D1 tables. The one account-scoped table, user_roles, is keyed on integer users.id (the session identifier), not the MCP stable userId (users.stable_user_id):

TablePurpose
rolesNamed roles (user, admin)
permissions(action, entity, access) tuples, unique
role_permissionsMany-to-many: which permissions each role has
user_rolesMany-to-many: which roles each user has

user_roles is included in the account-deletion cascade list in packages/worker/src/app/account-deletion.ts (kind: 'db_user_id').

Typed registry and extending it

packages/worker/universal/permissions.ts exports:

  • permissionActions, permissionEntities, permissionAccesses — const arrays
  • PermissionString — template literal type
  • parsePermissionString, buildPermissionString
  • listRegistryPermissionStrings() — Cartesian product of all registry tuples
  • userHasPermission, userHasRole — pure checks usable on server and client

To add a new entity:

  1. Add the entity name to permissionEntities in packages/worker/universal/permissions.ts.
  2. Write a migration (next free prefix under packages/worker/migrations/) inserting the new permission rows and attaching them to roles via role_permissions.
  3. The drift test in packages/worker/src/identity/permissions.node.test.ts compares migration INSERT rows to listRegistryPermissionStrings() and role attachment rules — drift fails npm run validate.

Server guards

packages/worker/src/app/permissions-server.ts provides request guards and re-exports DB helpers from permissions-db.ts:

FunctionBehavior
getUserRolesAndPermissionsOne D1 query: union of roles and permissions for a user
requireUserWithPermissionAuthenticated + permission; else 401/403
requireUserWithRoleAuthenticated + role; else 401/403
assignUserRoleInsert into user_roles; returns whether a row was inserted
removeUserRoleDelete from user_roles
removeAdminRolePreservingLastAdminAtomic admin removal that refuses to delete the last admin

Guards throw a Response on failure:

  • Unauthenticated: 401 JSON (when Accept prefers JSON) or redirect to /login?redirectTo=... for HTML shells.
  • Authenticated but unauthorized: 403.

Handlers catch the thrown Response and return it:

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

async handler({ request }) {
	try {
		const actor = await requireUserWithPermission(request, env, 'read:user:any')
		// ... authorized work
	} catch (error) {
		if (error instanceof Response) return error
		throw error
	}
}

Roles and permissions load fresh per request in readAuthenticatedAppUser (packages/worker/src/app/authenticated-user.ts). They are not stored in the session cookie, so revocation takes effect immediately. If the roles query fails transiently, the lookup fails closed: the user stays authenticated with empty roles and permissions until the query recovers (the MCP context lookup behaves the same way).

Where guards are used

Route / handlerGuard
GET /adminrequireUserWithRole('admin') → redirect to users
GET /admin/usersrequireUserWithRole('admin')
GET /admin/users.jsonrequireUserWithPermission('read:user:any')
POST /admin/users.json (roles, plan)requireUserWithPermission('update:user:any')
GET /admin/rolesrequireUserWithRole('admin')
GET /admin/roles.jsonrequireUserWithPermission('read:role:any')
GET /admin/system-emailrequireUserWithRole('admin')
GET /admin/system-email.jsonrequireUserWithRole('admin')
GET /admin/bannersrequireUserWithRole('admin')
GET/POST /admin/banners.jsonrequireUserWithRole('admin')
adminBannerList / adminBannerSave / adminBannerDelete MCPrequiredRole: 'admin'

Handlers: packages/worker/src/app/handlers/admin-users.ts, packages/worker/src/app/handlers/admin-roles.ts, packages/worker/src/app/handlers/admin-banners.ts.

The users list accepts q (username/email substring), role, and verification=stalled (unverified person accounts whose latest signup/verify send is still accepted after 60 minutes). Stall is derived from the user row; it is not a stored delivery status.

POST /admin/users.json dispatches on an action field: assign_role, remove_role, update_plan (set or clear — plan: null — a user's entitlement plan; see Entitlements), and create_user (pre-verified account plus a password-setup link). Those mutations emit audit events via logAuditEvent with category admin.

Last-admin guardrail

Removing the admin role from a user is blocked when that user is the last remaining admin. The check runs inside the DELETE statement itself (removeAdminRolePreservingLastAdmin), so concurrent removals cannot race a stale count and leave zero admins. The API returns 409 with a clear error message and logs a failure audit event with reason last_admin. When the helper reports nothing was removed, the handler distinguishes "target is the last admin" (409) from "target did not have the role" (idempotent no-op) — see handleRemoveRoleAction in admin-users.ts.

Signup and admin-created user setup fail with 500 if the seeded user role cannot be assigned (for example after a partial migration), rather than creating an account with no roles. The just-created users row is rolled back so the account creation can be retried once the environment is fixed.

Session payload and client helpers

POST /session (packages/worker/src/app/handlers/session.ts) includes roles and permissions in the session payload when the cookie is valid.

The client session store (packages/worker/client/session.ts) parses and exposes them as SessionInfo. Pure helpers userHasPermission and userHasRole from permissions.ts work on the client payload for conditional rendering — for example, the Admin nav link in packages/worker/client/app.tsx is shown only when userHasRole(session, 'admin').

Client checks are cosmetic only; every mutation is re-checked server-side.

Signup and seeding

Every new account receives the user role in the signup transaction (packages/worker/src/app/handlers/auth.ts via assignUserRole). If role assignment fails, the user row is rolled back so signup can be retried.

Admins can also create a pre-verified account by email from /admin/users. That action is guarded by requireUserWithPermission('update:user:any'), uses the shared adminCreateUserWithPasswordSetup service, assigns only the default user role, and returns a password-setup link for the admin to send manually. It does not grant admin and does not send email automatically.

tools/seed-test-data.ts seeds the default fixture account (kody@example.com) with the admin role and a companion regular account (jane@example.com) with the user role only, so both sides of RBAC are testable out of the box. Custom --email accounts stay non-admin unless --admin is passed; --no-admin opts the default account out.

MCP context

MCP requests authenticate via OAuth bearer tokens. Roles must not ride in grant props — they would go stale on revocation.

Instead, packages/worker/src/mcp-auth.ts calls buildMcpUserContextFromGrantProps (packages/worker/src/mcp-auth-user-context.ts) when building McpCallerContext: look up the users row by the grant's email, then call getUserRolesAndPermissions. The shared schema in packages/shared/src/chat.ts (mcpUserContextSchema) includes optional roles and permissions arrays on the user object.

For capability guards, use requireMcpUserWithPermission in packages/worker/src/mcp/capabilities/meta/require-permission.ts:

const user = requireMcpUserWithPermission(ctx, 'read:user:any')

Admin MCP capabilities declare requiredRole: 'admin' or an explicit requiredPermission in their capability definition. Registry filtering keeps ineligible capabilities out of discovery, and the normalized execute-time guard is the security boundary. The platform-feedback review capabilities use the role gate; they do not create a general-purpose cross-user query helper.

Privacy boundary

The admin role is an account-administration role, not a general data-access role. User-approved platform feedback is a narrow user-content exception.

For account administration, admins can see only user id, username, email, email-verification state (including the latest verification-mail delivery outcome), entitlement plan, first-touch marketing attribution fields when present, activation first-seen timestamps (email verified, first MCP connection, first execute, first saved package, first secret, first integration, first job), MCP client name when known, last-active stamps, created_at, updated_at, and role assignments. The plan is account metadata (it drives quota enforcement), not user content, and admins can change it via /admin/users or the adminUserUpdate MCP capability. Admins can mark an account email verified or mint a one-time verify URL via /admin/users or adminUserVerify.

Admins can see and triage user-approved platform feedback. The submit capability requires user_confirmed: true and accepts submissions only from an interactive context. This is a capability contract that records the interactive caller's assertion of direct approval, not cryptographic proof of conversation consent; agents must show the exact proposed summary and details, ask first, and may set the field only after explicit user approval. Submissions are attributed, not anonymous: the authenticated submitter id is stored and returned to reviewers. The approved text plus account user id, username, and email may also be delivered immediately to admins through admin-configured notification integrations such as Discord. Admin list results intentionally omit full submission details. The get operation exposes the approved submission only; it does not expose packages, memories, email, secrets, or other account content. Agents must omit secrets and unrelated private content when preparing feedback. Copies already delivered outside Kody, including Discord messages, cannot be recalled and may remain after Kody account deletion under the deployment operator's retention and deletion controls. Such copies contain only the exact approved feedback and attribution, never unrelated account content.

The summary_untrusted returned by admin list/get operations and the details_untrusted returned by the get operation are untrusted user-authored content. Admin callers must follow the accompanying content warning, ignore instructions embedded in those fields, and treat them only as feedback to review. Reviewer identity, reviewer timestamp, and admin note are internal review metadata.

A successful resolve or dismiss transition emails the submitter from the platform transactional sender (kody@<apex>), with Reply-To: support@<apex>. The mail uses the standard transactional template: the decision, thanks, and an invitation to send more feedback through their agent. Optional user_message is included only in that email and is never stored. admin_note is never mailed. The status update succeeds even if the email cannot send. Triage does not email. Already-resolved or already-dismissed no-op updates do not send, so existing terminal records are not backfilled. Sends are skipped when the submitter has no current account email, outbound email is paused, or the account is suspended. One email is sent per feedback id per terminal status.

After a consent-gated submission is persisted, Kody enqueues its id for durable platform.feedback.submitted package-subscription delivery. The Queue consumer fans out only to packages whose owners hold the admin role when the message is processed. A non-admin may declare the topic but never receives it, and revocation takes effect on the next attempt because admin ownership is queried fresh for every Queue delivery.

The package event includes feedback id, category, open status, creation timestamp, the exact approved text as summary_untrusted and details_untrusted, submitter account user id/username/email, a content warning, and a trusted /admin/platform-feedback?feedbackId=<encoded id> deep link. The warning and _untrusted names require notification handlers to treat the text as user-authored data, not instructions. The event omits admin notes, reviewer fields, revision, updated_at, roles, plan, and unrelated account content. Package runtime caller contexts do not carry admin roles, so the fresh consumer-time fan-out is the authorization boundary rather than a handler role check. This remains a narrow exception only for feedback shown to and explicitly approved by the user; it does not grant package runtime general admin roles. Username and email are stored submission-time snapshots. Package events never resolve mutable live profile data, and persisted feedback rows always carry both snapshots.

Submission awaits only Queue enqueue after persistence. An enqueue failure is logged without changing the successful response, preventing duplicate feedback from a client retry. Queue bodies remain opaque { feedbackId } messages. The consumer acknowledges invalid messages. After admin subscriber discovery, lazy parameter construction reloads feedback immediately before invocation. A deleted row raises a typed permanent cancellation that the consumer acknowledges without dispatch or retry; other lookup, discovery, or package-invocation wrapper infrastructure failures retry and can reach the DLQ. Redelivery keeps the same package invocation idempotency key; a stored failed invocation replays instead of automatically rerunning, so the DLQ is the recovery surface. Terminal handler execution failures remain isolated from sibling subscribers.

Platform-feedback review does not expose unrelated account content such as secrets, memories, packages, jobs, user inbox email, durable storage, MCP servers, or OAuth grants. None of it appears in platform-feedback admin payloads. Text a user explicitly approves as part of a feedback submission is visible only through the dedicated feedback exception.

Admins separately moderate deliberately shared community content. Public community listing snapshots can be reviewed for featuring, delisting, and deletion, and attributed community reports can be reviewed and resolved. Those community surfaces expose content users chose to publish or report; they do not grant access to private package source or unrelated account content.

Admins can inspect activity metadata for public community listings. The adminCommunityActivityList capability returns a paginated, newest-first feed of fork and rating rows with listing id/name/package name leaf, acting username, timestamp, and rating scores. Existing storage does not distinguish a one-click install from an ordinary fork, so both appear as fork. The capability omits stable user ids, forked package/source ids, origin commits, target package name leaves, rating notes, private package source, and all unrelated account content. Fork rows snapshot the public listing name and package name leaf so intentional listing deletion does not erase retained fork provenance; legacy orphan rows whose listing identity cannot be recovered use explicit deleted/unknown placeholders. adminCommunityOrphanForksCleanup is a separate audited maintenance mutation that previews or deletes leftover community_forks rows whose entity source and saved package are both gone. It can name those fork, package, and source ids because operators need them to confirm the row. Healthy inert forks (source present, no saved_packages row) are not orphans.

New fork and rating writes enqueue an opaque activity id for durable community.activity.recorded package-subscription delivery. The Queue consumer reloads only the same metadata projection and fans out only to packages whose owners hold the admin role at processing time. A non-admin may declare the topic but never receives it, and role revocation applies on the next attempt. Each write gets a distinct event id for package-invocation idempotency. Deleted activity is acknowledged as a permanent cancellation; transient lookup, discovery, and package-invocation infrastructure failures retry and can reach the dedicated DLQ.

The first community listing publish enqueues an opaque listing id for durable community.listing.published package-subscription delivery. Republishes do not enqueue this topic. Fan-out, payload redaction, and retry semantics match the admin events guide: admin owners only, metadata-only listing fields (including canonical public_url), permanent cancellation for missing or inactive listings, and enqueue failures that never fail communityPublish.

Admins can subscribe to public status-page incidents. The isolated status worker records component incidents in its own Durable Object, then best-effort POSTs metadata to the main worker. Fan-out of status.incident.opened and status.incident.resolved selects only packages whose owners hold the admin role at dispatch time. A non-admin may declare the topic but never receives it. The event contains the public status URL, component id, probe detail, and ISO timestamps. It omits probe logs, health-check bodies, user identities, secrets, and unrelated account content. Resolved incidents may also carry an optional operator retrospective on / and /status.json; that text is public operational narrative, not user data. Delivery is best-effort (no Queue); /status.json polling remains the backstop.

Admins can subscribe to fleet package-runtime error-rate elevations. The hourly usage-aggregation lane compares anonymous Analytics Engine totals for package_export, package_static_call, job_run, and workflow_run. When the combined error rate rises, Kody fans fleet.package_error_rate.elevated only to packages whose owners hold the admin role at dispatch time. The event contains window bounds, per-metric counts and rates, the public status URL, and the insights URL. When one account or a few accounts own the recent-window errors, it also names those usernames and package name leaves. It omits user ids, package UUIDs, emails, error strings, logs, and unrelated account content. This is operator telemetry about kody itself, not a user-data exception. Delivery is best-effort (no Queue). A six-hour cooldown suppresses repeat pages. A concentrated spike still pages the fleet; it does not reroute as a per-user storm.

Admins can subscribe to fleet entitlement crossings. The hourly usage_entitlement_alert lane sweeps the top ~15 active accounts and fans fleet.entitlement.crossed only to packages whose owners hold the admin role at dispatch time. One event fires per 80% or 100% crossing of a plan-limit resource, per first over-threshold runtime-duration month, per first over-threshold unique Dynamic Worker cost month, or per first three-of-seven execute-cap train. The event contains the stable user id, username, resource label and counts (or runtime duration, unique-worker days, or days at the execute cap), and admin insights/users URLs. It omits emails, plans, secrets, package source, and unrelated account content. Delivery is best-effort (no Queue). Staying over the same threshold does not emit again.

Admins can subscribe to person-account create and delete. Password signup, social-login signup, and admin-created person accounts fan user.created. Self-service account deletion fans user.deleted. Fan-out selects only packages whose owners hold the admin role at dispatch time. The event contains the stable user id, username, email, create source or delete timestamp, and first-touch marketing attribution when present. It omits passwords, roles, plan, secrets, and unrelated account content. Delivery is best-effort (no Queue) after the account change commits.

Admins can subscribe to verification-mail terminal failures. The first bounce, failure, rejection, or complaint on a signup/verify send fans user.email_verification.failed only to packages whose owners hold the admin role at dispatch time. The event contains the stable user id, username, email, delivery status, class (sender_block / other / null), an /admin/users/:stableUserId URL, and occurred_at. It omits SMTP transcripts, tokens, and unrelated account content. Delivery is best-effort (no Queue) after the user row already carries the bounce.

Admins can subscribe to stalled verification sends. The hourly email_verification_stall_alert lane fans user.email_verification.stalled only to packages whose owners hold the admin role at dispatch time when an unverified person account still has accepted after 60 minutes with no Cloudflare lifecycle event. The event contains the stable user id, username, email, accepted_at, stall threshold, an /admin/users/:stableUserId URL, and occurred_at. It omits SMTP transcripts, tokens, and unrelated account content. Delivery is best-effort (no Queue). Idempotency keys include the accepted timestamp so a later hourly scan of the same send does not emit again.

Admins can subscribe to outbound-mail abuse pauses. After one spam complaint or five bounced sends in a UTC day, Kody pauses that account's outbound email and fans user.email_outbound.paused only to packages whose owners hold the admin role at dispatch time. The event contains the stable user id, username, email, reason, bounce threshold when the reason is bounced, an admin user URL, and occurred_at. Delivery is best-effort (no Queue) after the pause write commits.

Admins can subscribe to hourly operator bursts. The auth_denial_alert lane fans auth.denial.burst when MCP auth failures in the last 60 minutes cross 50. The email_delivery_alert lane fans email.delivery.burst when shared-domain bounce or complaint outcomes in the last 60 minutes cross 20. Both payloads are count, threshold, window, insights URL, and observed_at. They omit user identities, tokens, recipients, and message content. A six-hour KV cooldown suppresses repeat pages. Delivery is best-effort (no Queue).

Admins can see operator-owned system mail for reserved platform addresses (kody, support, abuse, postmaster, security, admin, and psl). That mail is stored under system:email as platform content, not under Kent's or any other user's account. Admin reads through MCP (adminSystemEmailList, adminSystemEmailGet) and the /admin/system-email UI are audit logged. Admins can also send from those reserved addresses with adminSystemEmailSend (also audit logged, with redacted recipients): that channel speaks for the platform, so it uses no user mailbox, sender identity, or plan entitlement, and it never reads or writes user-owned mail. Stored system mail also fans out metadata (never bodies or attachment bytes) on the email.system-message.received package subscription topic, and only to packages saved by users who hold the admin role at dispatch time — a non-admin subscriber never receives the event, and revoking admin stops delivery immediately. Successful reserved-sender sends fan email.system-message.sent the same admin-only way. That outbound topic includes the sent correspondence (recipients, subject, and bodies) because those sends are not written to the dedicated inbound graph (the graph refuses provider-message-id rows). It does not include user-owned mailbox content.

This boundary is enforced structurally:

  1. The permission vocabulary cannot express general user content access. permissionEntities contains only user and role, so a guard like requireUserWithPermission(..., 'secret:read:any') is a compile error.
  2. Admin account queries touch identity tables only. /admin/users*.json and role handlers select explicit column lists from users, user_roles, and roles. They never join user content tables. /admin/system-email*.json is separate and reads only the operator-owned system_email_* graph; it never falls back to user rows or a Mailbox Durable Object.
  3. Platform feedback has a dedicated role-gated service boundary. Submit writes are scoped to the authenticated user and require user_confirmed: true from an interactive context at the capability boundary. Admin list reads use a summary projection that omits full details; get and triage operations address only the selected approved submission. They never join unrelated user-content tables.
  4. Community activity has a dedicated role-gated metadata projection. adminCommunityActivityList unions only community_forks and community_ratings, uses snapshotted/public listing identity plus acting username, and projects an explicit field allowlist. It never selects package source, rating notes, email, stable user ids, or unrelated user-content tables. Delivery reuses the same projection through fresh admin-owner fan-out.
  5. A shape test pins the admin users API payload. adminUserListItemFieldNames in admin-users.ts defines the allowed fields (stableUserId, username, email, email_verified, email_verified_at, plan fields, suspension / outbound-pause stamps, verification-mail delivery fields, first-touch UTM and landing fields, activation and last-active stamps, mcp_client_name, created_at, updated_at, roles). The unit test in admin-users.node.test.ts asserts every user object in the list response has exactly those keys — an accidental widening fails npm run validate.
  6. Existing owner-only paths take no admin bypass. Secret reveal remains session-authenticated and owner-only.

Platform feedback remains user-owned for account lifecycle operations. Account export includes the authenticated user's own submissions and may include their status, but redacts internal reviewer identity, reviewer timestamp, and admin note. Open and triaged feedback remains until it is resolved, dismissed, or the submitter deletes their account. Resolved and dismissed feedback is retained for 365 days after its last update and then pruned. Deleting the submitting account removes any remaining submissions; deleting an admin account clears that reviewer's attribution on surviving submissions instead of deleting another user's feedback. The pre-invocation reload cancels still-queued delivery when deletion wins the race, but cannot recall an external notification copy that was already delivered.

The public /privacy page and docs/use/privacy.md describe this boundary for end users. RBAC governs the application surface only; deployment operators with infrastructure access (D1, SECRET_STORE_KEY) sit outside application-level control.

First admin bootstrap

There is no application UI or API to grant the first admin role. Run SQL once through the Wrangler env wrapper:

node ./wrangler-env.ts d1 execute APP_DB --local --command \
  "INSERT OR IGNORE INTO user_roles (user_id, role_id)
   SELECT u.id, r.id FROM users u, roles r
   WHERE u.email = 'you@example.com' AND r.name = 'admin';"

For production, use --remote. After the first admin exists, further role assignment happens through the admin UI.

What to read when changing authorization

  • packages/worker/universal/permissions.ts — typed registry
  • packages/worker/src/identity/permissions-db.ts — D1 queries
  • packages/worker/src/app/permissions-server.ts — request guards
  • packages/worker/migrations/0001-squashed-init.sql — schema and seed data
  • packages/worker/src/app/handlers/admin-users.ts — users admin API
  • packages/worker/src/app/handlers/admin-roles.ts — roles admin API
  • packages/worker/src/mcp-auth-user-context.ts — MCP role loading
  • packages/worker/src/mcp/capabilities/meta/require-permission.ts — MCP guard