Test Users and Permission Scopes¶
The npm run seed task provisions a deterministic matrix of users, organizations, and team memberships so contributors and QA can exercise every axis of Farm's authorization model without writing manual SQL. This page documents the matrix, the credentials, and the recipes used to test each scope.
The single source of truth for the matrix is the TEST_USERS constant exported from apps/api/src/database/seeds/initial-seed.ts. Updating that constant — and re-running the seed — is enough to extend the matrix.
Authorization axes¶
Farm combines three orthogonal authorization mechanisms:
- Role-based access — endpoints decorated with
@Roles("admin")reject any user whose JWTrolesclaim does not includeadmin. Admin-only pages include/elasticsearch, parts of/organizations, and several settings views. - Multi-tenancy via
OrgContextInterceptor— every protected request reads the optionalX-Organization-Idheader (UUID or organization slug). The interceptor verifies the user has aUserOrganizationmembership row for that org and rejects with403 Forbiddenotherwise. Resources written while a tenancy is active are scoped to that organization. - Team-scoped ownership —
Component,Documentation, and other entities can declare aTeamowner. Endpoints that mutate ownership-scoped resources require the caller to be a member of the owning team (via theteam_membersjoin table).
The personas below cover each axis in isolation so a single login can prove or disprove the behavior under test.
Persona matrix¶
Personas are named by their TEST PURPOSE — the combination of global JWT roles and per-tenant OrgRole they exercise — rather than by where they live. This is how each row maps to an authorization axis under test.
| Persona | Username | Password | Roles | Memberships (org → OrgRole) | Teams | Intended scope |
|---|---|---|---|---|---|---|
| Platform Admin | admin | Admin1234 | admin | farm-demo: OWNER | — | Full platform admin: reaches /elasticsearch, manages every org. |
| Org Owner | org-owner | OrgOwner1 | user | org-b: OWNER | — | Tenant owner of org-b; verifies catalog isolation when X-Organization-Id: org-b is set. |
| Org Admin | org-admin | OrgAdmin1 | user | farm-demo: ADMIN | — | Verifies endpoints that require OrgRole.ADMIN (or higher) inside a tenant. |
| Org Member | org-member | OrgMember1 | user | farm-demo: MEMBER | backend-team | Non-admin within the default tenant; verifies regular user flows and team-scoped writes on backend components. |
| Cross-Org Member | cross-org-member | CrossOrg1 | user | farm-demo: MEMBER, org-b: MEMBER | — | Multi-tenant SSO-style account; verifies that the same bearer token can switch tenants by changing X-Organization-Id. |
| Team Lead | team-lead | TeamLead1 | user | farm-demo: MEMBER | platform-team | Verifies ownership-scoped actions on platform-team resources. |
| Viewer | viewer | Viewer1234 | user | farm-demo: MEMBER | — | Read-only persona inside the default tenant; verifies that users without team membership cannot perform team-scoped writes. |
Passwords are reset on a fresh
npm run seedonly when the environment variableSEED_RESET_PASSWORDS=trueis set. By default existing passwords are preserved so that production seed runs do not silently rotate credentials. If you change a persona's password manually in your dev DB and want to re-sync to the seed value, runSEED_RESET_PASSWORDS=true npm run seed.
Switching tenancy in the Web UI and via API¶
The active organization is communicated to the API through the X-Organization-Id header, which accepts either an organization UUID or its slug.
- Web UI — the organization switcher in the top bar sets the header for every subsequent request automatically.
- curl — pass the header alongside the bearer token, for example:
If the header is omitted the request runs in a "no tenant" mode and only sees resources whose organizationId is null.
How to test¶
Recipe 1 — Admin-only routes¶
curl -X POST http://localhost:3000/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"Admin1234"}'
The admin username corresponds to the platformAdmin persona — the only persona whose JWT carries the global admin role. Use the returned accessToken to GET /api/v1/elasticsearch/indices — the response should be 200 OK with the cross-component index list. Logging in as any other persona and hitting the same URL should return 403 Forbidden because of @Roles("admin").
Recipe 2 — Tenant isolation with org-owner¶
curl -X POST http://localhost:3000/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"org-owner","password":"OrgOwner1"}'
With the resulting token:
curl -H "Authorization: Bearer $TOKEN" -H "X-Organization-Id: org-b" /api/v1/catalog/componentsreturns the components scoped toorg-b(empty list on a fresh seed — the demo catalog lives infarm-demo).curl -H "Authorization: Bearer $TOKEN" -H "X-Organization-Id: farm-demo" /api/v1/catalog/componentsreturns403 Forbiddenbecauseorg-ownerhas noUserOrganizationrow forfarm-demo.
In the Web UI, switch the organization in the header to Org B; the catalog and other org-scoped views should refresh to show the org-b tenant's data.
Recipe 3 — Team-scoped ownership with team-lead¶
curl -X POST http://localhost:3000/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"team-lead","password":"TeamLead1"}'
team-lead is a member of platform-team. Mutations against components owned by platform-team (for example, updating the description of the developer-portal component) succeed; the same mutation issued by viewer (who is in farm-demo but in no team) returns 403 Forbidden.
Recipe 4 — Verifying read-only behavior with viewer¶
viewer has the user role and farm-demo membership but no team. Use this persona to verify that read-only flows work end-to-end while team-scoped writes are correctly rejected — useful when adding new endpoints that must be protected by team ownership rather than just by JWT role.
Recipe 5 — Multi-tenant SSO with cross-org-member¶
curl -X POST http://localhost:3000/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"cross-org-member","password":"CrossOrg1"}'
cross-org-member holds two UserOrganization rows — one for farm-demo and one for org-b, both with OrgRole.MEMBER. The same bearer token therefore satisfies OrgContextInterceptor for either tenant depending solely on the X-Organization-Id header value:
curl -H "Authorization: Bearer $TOKEN" -H "X-Organization-Id: farm-demo" /api/v1/catalog/components→200 OK, returnsfarm-demodata.curl -H "Authorization: Bearer $TOKEN" -H "X-Organization-Id: org-b" /api/v1/catalog/components→200 OK, returnsorg-bdata.
This persona models the typical SSO scenario where one identity is provisioned into multiple tenants. Switching the organization in the Web UI's top bar exercises exactly the same code path.
Recipe 6 — ADMIN-mininum endpoints with org-admin¶
curl -X POST http://localhost:3000/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"org-admin","password":"OrgAdmin1"}'
org-admin holds OrgRole.ADMIN (not OWNER) inside farm-demo. Use this persona to verify that endpoints which call assertOrgRole(id, requesterId, OrgRole.ADMIN) (for example OrganizationService.update in apps/api/src/modules/organization/organization.service.ts) accept ADMIN as the minimum role:
- The same call performed as
org-member(MEMBER) returns403 Forbidden. - As
org-adminit returns200 OK, proving the guard correctly accepts ADMIN ≥ ADMIN, not only OWNER.
This persona is the missing axis between platformAdmin (global admin role) and org-member (MEMBER); without it, ADMIN-only endpoints could accidentally regress to OWNER-only without any test catching it.
Re-seeding behavior¶
seedUsers is idempotent. Re-running npm run seed against a populated database:
- Adds any persona that is missing.
- Merges roles using set semantics (no duplicates) for already-existing personas.
- Upserts missing
UserOrganizationrows from the persona'smemberships, with the declaredOrgRole(OWNER, ADMIN, or MEMBER) for each(orgSlug, role)pair. - Adds missing
team_membersrows from the persona'steamSlugs. - Never removes memberships, roles, or team affiliations that were added manually.
- Never rewrites existing passwords unless
SEED_RESET_PASSWORDS=true.
This means it is safe to run the seed in CI, in development containers, or after pulling new code.