WorkOS permission sync

The platform's permission slugs are defined in code. WorkOS environments must be kept in sync with that definition or signed-in users will hit 403 Forbidden on routes whose permission slug does not exist on any of their roles.

This doc describes the manual procedure used today and the release-aligned automation we want to move to. The current process is run by hand against the active workos CLI environment.

For background on how permissions flow from WorkOS through the auth boundary into a request, see docs/architecture/auth-boundary.md. For local-dev specifics, see auth-local-dev.md.

Source of truth

modules/view-common/src/main/scala/dev/datadescriber/view/common/AppNavigation.scala is the canonical permission registry. Every readPermission / writePermission value in that file is a slug that must exist in every WorkOS environment the app is deployed to.

modules/web/src/main/scala/dev/datadescriber/web/RoutePermissionAuthorizer.scala consumes AppNavigation.permissionFor(...) to enforce these on inbound requests. Routes with a slug that no role grants in the active environment will reject every signed-in user.

Extract the current slug set from the source file:

grep -oE '"[a-z0-9_-]+:[a-z0-9_-]+"' \
  modules/view-common/src/main/scala/dev/datadescriber/view/common/AppNavigation.scala \
  | sort -u

As of this writing the set is:

actors:read       actors:write
assets:read       assets:write
catalog:read
dashboard:read
dataproducts:read dataproducts:write
datasets:read     datasets:write
contracts:read    contracts:write
termbank:read     termbank:write

When to sync

Sync any time a PR adds, removes, or renames a readPermission / writePermission in AppNavigation.scala. The sync must land in the target environment before the application image that requires the new slug starts serving traffic, otherwise existing users get 403 Forbidden on the affected routes until their role is updated.

Renames are the riskiest case: the old slug must keep working until every user's session has refreshed against the new role assignments. Treat a rename as add-new + dual-grant + remove-old across at least two deploys.

Manual sync procedure

1. Confirm the active environment

workos env list
workos env switch <name>   # if needed

The active: true entry is the environment every subsequent workos command will mutate. Double-check before any create / update / set-permissions call.

2. Diff the WorkOS permission set against the code

workos permission list --limit 1000 --json \
  | jq -r '.data[] | select(.system == false) | .slug' \
  | sort -u > /tmp/workos.slugs

grep -oE '"[a-z0-9_-]+:[a-z0-9_-]+"' \
  modules/view-common/src/main/scala/dev/datadescriber/view/common/AppNavigation.scala \
  | tr -d '"' | sort -u > /tmp/code.slugs

diff /tmp/code.slugs /tmp/workos.slugs

If the permission set is approaching 1000 entries, replace --limit 1000 with pagination (--after <cursor>) to avoid silent truncation.

Lines prefixed with < are missing in WorkOS (need to be created). Lines prefixed with > exist in WorkOS but are no longer referenced by code — review before deleting; an org-scoped role might still grant them.

3. Create missing permissions

workos permission create \
  --slug <slug> \
  --name "<Title Case>" \
  --description "<single sentence>" \
  --json

Naming conventions used in this environment:

  • name — Title Case, mirrors the slug. e.g. assets:write → "Assets Write".
  • description — one sentence describing the access granted, e.g. "Write access to datasets". Avoid placeholder text like "Temporary repo-local permission for platform auth testing"; that has bitten us before.

4. Patch stale names or descriptions

workos permission update <slug> \
  --name "<Title Case>" \
  --description "<single sentence>" \
  --json

update accepts --name and --description independently. The slug itself is immutable.

5. Assign to org-scoped roles

Environment-level roles (admin, member) are listed by workos role list. Org-scoped roles need an explicit --org:

workos role list --org <orgId> --json
workos role get <roleSlug> --org <orgId> --json

Replace the full permission set on a role:

workos role set-permissions <roleSlug> \
  --org <orgId> \
  --permissions slug1,slug2,slug3,...

Or add incrementally:

workos role add-permission <roleSlug> <permissionSlug> --org <orgId>

6. Tell affected users to re-sign-in

Permissions are baked into the sealed app session cookie at sign-in and only refresh on session refresh. Anyone currently signed in needs to sign out and back in before the new permission appears in their DD-internal JWT. See docs/architecture/auth-boundary.md for the cookie + refresh model.

The org-dd-local-debug ("DD Local Debug Access") role

This is the org-scoped role used by just auth-grant --permission <slug> to grant repo-local debug access without editing shared roles in place. It exists in every org we use for local development — in Staging that's both DataDescriber and Test Organization. The helper is documented in CLAUDE.md under "Local Docker Runtime".

When you add a new permission slug to AppNavigation.scala, assign it to org-dd-local-debug only when that access is explicitly approved, so local debug sessions keep working without per-developer firefighting:

workos role add-permission org-dd-local-debug <newSlug> --org <orgId>

Repeat for each org that has org-dd-local-debug (in Staging: DataDescriber and Test Organization).

Do not use set-permissions here — it replaces the entire permission set and will silently revoke every other platform slug if you pass only the new one.

This role is intentionally separate from the customer-facing roles (admin, member) so we never modify those for debug purposes.

Roadmap: release-aligned automated sync

The manual procedure above does not scale and is easy to forget. The target shape is automation that runs as part of the release pipeline, before the new application image starts serving traffic.

Constraints we want to hold:

  1. Single source of truth. A workos-seed.yml (or generated equivalent) lives in this repo, alongside AppNavigation.scala. It cannot drift from the code. Either generate the YAML from AppNavigation.scala at build time, or add a CI guard that fails the build when they disagree.
  2. Run from a controlled job, not from laptops. workos seed --file workos-seed.yml is the existing CLI capability we'd build on. Developers must not run it against shared environments.
  3. Pipeline order is seed-permissions → deploy-app → smoke-tests. The seed step is a deploy gate; if it fails, the app does not roll out.
  4. Destructive operations are gated. Never auto-run workos seed --clean in shared environments — a bad seed file would otherwise delete a permission that an org-scoped role still references. Renames and removals are explicit, multi-deploy operations (see "When to sync" above).
  5. Role↔permission ownership is explicit. The env-level role set (admin, member) is owned by code and seeded automatically. Org- scoped roles created by customers via Admin Portal are not touched by the seed.

Open questions to settle when this work is picked up:

  • Per-environment seed file vs single file with environment overlays.
  • Where the seed step lives in the deploy pipeline and how it authenticates (separate WorkOS API key per environment).
  • How slug renames roll out without revoking access mid-rollout.

This builds on existing future-work notes — see the WorkOS sync bullets in docs/testing/manual-auth-checklist.md and docs/architecture/auth-boundary.md.

Verification

After a manual sync, confirm:

  1. workos permission list (filtering out system: true entries) matches the code-extracted slug set exactly.
  2. workos role get org-dd-local-debug --org <orgId> includes the explicitly approved platform slugs for that dev organisation.
  3. Sign out and back in on a routed local session and confirm pages for the new permission(s) load.

The permission set in the active environment is in sync with the code when steps 1 and 2 pass; step 3 confirms a real user session sees the change.

Contract access (#893)

Provision contracts:read and contracts:write before enabling contract inventory access. Reads require the former; mutation flows require both. Existing actor permissions do not grant contract access. Do not automatically add these slugs to existing customer or debug roles: membership/role assignments require explicit approval. Creating the slugs does not grant access. The contract endpoints also enforce permissions at the service boundary; party lookup/selection retains its additional actors:read requirement.