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 1000with 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:
- Single source of truth. A
workos-seed.yml(or generated equivalent) lives in this repo, alongsideAppNavigation.scala. It cannot drift from the code. Either generate the YAML fromAppNavigation.scalaat build time, or add a CI guard that fails the build when they disagree. - Run from a controlled job, not from laptops.
workos seed --file workos-seed.ymlis the existing CLI capability we'd build on. Developers must not run it against shared environments. - 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. - Destructive operations are gated. Never auto-run
workos seed --cleanin 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). - 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:
workos permission list(filtering outsystem: trueentries) matches the code-extracted slug set exactly.workos role get org-dd-local-debug --org <orgId>includes the explicitly approved platform slugs for that dev organisation.- 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.