CapyDB/ docs
Guides

Runtime role (split role model)

A second login per project, app_user, that owns nothing and cannot bypass row-level security - so your RLS policies apply to request traffic while the owner keeps running migrations.

Your project's credential owns its tables, and a table owner bypasses row-level security unless the table is FORCEd. The split role model gives the project a second login for request traffic, app_user, that owns nothing - so every RLS policy applies to everything it runs - while the owner's credential stays the path for migrations, backfills and admin jobs.

The runtime role is rolling out. GET /v1/projects/{projectID}/roles/app reports available: false until it is offered on the platform, and enabling it answers 404 until then. A project that already has the role can read and rotate it either way.

What app_user is

  • A login role that owns nothing, cannot create roles, is not a superuser and cannot bypass row security (NOBYPASSRLS).
  • It is never a member of the owner, so it can never act as the owner.
  • The owner is a member of it with SET but without INHERIT: the owner's credential can switch to it (BEGIN; SET LOCAL ROLE app_user; ...) without gaining anything from it.
  • The name is fixed, which is also the default runtime role of capyrls, so one grants file applies to production and every preview.

A CapyDB database role cannot create roles itself, which is why CapyDB issues this one for you.

Enable it

Requirements: Postgres 16 or newer (older majors are refused with 400; upgrade first), and an organization admin in the dashboard or an organization-wide API key with projects:write and manager rights (manager: true - every key an admin creates; a key minted when a member without the admin role connects an MCP client has none). The project must be ready (or failed) and not in the middle of another operation.

capydb roles app enable --wait
# or: curl -X POST https://capydb.dev/api/capydb/v1/projects/$PROJECT_ID/roles/app \
#       -H "Authorization: Bearer $CAPYDB_API_KEY"

The response is a job (project.app_role_enable). While it runs the project is in rotating_credentials, like an owner rotation. A project that already has the role gets 409; rotate it instead.

When the job completes, the project's connection details carry the role's strings under connections.app, next to the owner's:

curl -s -H "Authorization: Bearer $CAPYDB_API_KEY" \
  https://capydb.dev/api/capydb/v1/projects/$PROJECT_ID/connections | jq '.connections.app'
{
  "username": "app_user",
  "direct_url": "postgres://app_user:password@host:5432/db?sslmode=verify-full",
  "pooled_url": "postgres://app_user:password@host:6432/db?sslmode=verify-full"
}

In the dashboard, the project's Connections page has an App role (row-level security) section. While the role is offered and the project does not have it, the section offers Create app role (workspace admins; disabled for everyone else). Once the project has it, the section shows the role's pooled string (labelled DATABASE_APP_URL) and direct string under the owner's, and a Rotate app role password action that asks for a confirmation first. Where the role is not offered and the project has none, the section is not shown.

connections.app is omitted until the project has the role. The top-level connection fields stay the owner's. In a linked directory, capydb env pull writes them as DATABASE_APP_URL and DATABASE_APP_POOL_URL, and capydb roles app show reports the role's status. GET /v1/projects/{projectID}/roles/app reports enabled, created_at and rotated_at, never the password.

Grant it access

app_user starts with no privileges on your tables. Grant what request traffic needs, including for tables created later:

  • Converting from Supabase: capydb migrate rls --role-model split (capyrls' split mode) builds a bundle targeted at app_user that checks the role exists, grants table and sequence privileges and sets default privileges for tables the owner creates later. It creates no roles and FORCEs nothing - the owner is the service path. See Supabase RLS.
  • By hand, over the owner's direct URL:
GRANT USAGE ON SCHEMA public TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO app_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
  GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
  GRANT USAGE, SELECT ON SEQUENCES TO app_user;

Then point your application at connections.app.pooled_url and keep the owner's URL for migrations:

WorkloadCredential
Request traffic under RLSapp_user, pooled URL
Migrations, backfills, admin jobsOwner, direct URL

SECURITY DEFINER functions owned by the owner still run with the owner's rights, and so bypass row security on tables that are not FORCEd.

Rotate it

capydb roles app rotate --wait && capydb env pull
# or: curl -X POST https://capydb.dev/api/capydb/v1/projects/$PROJECT_ID/roles/app/rotate \
#       -H "Authorization: Bearer $CAPYDB_API_KEY"

Rotation replaces the password in place (job project.app_role_rotate); the old password stops working when the job completes. There is no grace window, unlike an owner rotation with grace_hours, so deploy the new strings promptly. Rotating the owner does not touch app_user, and rotating app_user does not touch the owner.

Previews, restores and upgrades

  • Previews get their own password. A preview of a project with the role gets an app_user password of its own, issued once and kept across the preview's resets, and its connection details carry it under connections.app. A clone never accepts production's passwords - for app_user, the owner, or logins still in a rotation grace window. Previews created before 2026-09-30 keep the logins they were cloned with until they are reset or recreated.
  • Restores keep privileges. A backup restore and a major upgrade re-apply the table and function privileges from the source, so app_user keeps its grants.
  • Replacements keep the current password. An in-place point-in-time overwrite and a major upgrade (and its rollback) carry the role's current password, so connections.app keeps working.

Enabling and rotating are recorded in the audit log as project.app_role_enable_requested and project.app_role_rotation_requested.