---
title: "Roles & Governance"
slug: rbac
section: Governance & Operations
status: stable
description: "Role-Based Access Control across brands, programs, and external stakeholders"
last-updated: 2026-10-07
---

# Roles & Governance

Role-Based Access Control across brands, programs, and external stakeholders

## How RBAC Affects Portals (Even When Users Don’t Log Into Globus)

Applicants and hosts do not log into Globus, but their portals call Globus APIs to read and write data. Globus enforces what portal users can do through authorization rules tied to:

> **Two Authority Models**
>
> RBAC governs sponsor staff access. Relationship-based authorization governs portal users — both are enforced in the Globus API layer.

## What RBAC Controls in This Ecosystem

RBAC governs all internal sponsor access within Globus. It determines what sponsor users can see, what they can do, where they can do it, and what audit trail is left behind.

External portal users (partners, hosts, applicants) do not use internal RBAC roles. Their access is controlled through relationship-based scoping, agreement rules, and ownership validation enforced by Globus APIs.

## Role Hierarchy

The RBAC model is structured in concentric scopes. Each level narrows the data and actions available to the user. Global roles see everything; program roles see only their program within their brand.

### Three-Layer Model

Within each scope tier, RBAC is enforced through three layers that work together to determine effective access:

## Global Roles > **Scope: All Brands** > > Global roles have visibility and authority across every brand and program in the ecosystem. These are typically executive, compliance, and system administration roles. 
## Brand Roles

Brand roles are scoped to a single brand. A brand admin for “Teacher Lounge” cannot see or manage data belonging to “Alliance Abroad” and vice versa.

Alliance Abroad / Wild Packs / Teacher Lounge / StepWest / GeoVisions
## Program Roles (Operational)

Program roles are the most granular internal scope. Every grant is clamped to the user’s assigned programScope — a Host Manager for “Summer Work & Travel” within the “Alliance Abroad” brand cannot reach “Camp Counselor” data. Operational duties are split by journey (host vs. applicant) so access stays least-privilege, with a dedicated read-and-assist Support role.

> **Journey boundaries are enforced, not advisory**
>
> Host Manager and Applicant Manager are mirror-image roles: each owns one journey and can only read the other. Support Manager reads both but writes nothing operational beyond notes and contact/invite assists. To run both journeys with write access, a user holds both manager roles — see Multiple Roles & Resolution.

## Hub Roles

Hub roles are for central Hub staff who service programs across the ecosystem. The Hub tier is now just two roles: Hub Support (read-and-assist) and Hub Admin (broad operational authority plus delegation). The former journey-split Hub roles were merged into their program counterparts — hub_applicant into Applicant Manager and hub_host into Host Manager (QA 2026-07, Tasks #1912/#1913) — so journey operations are always carried by the manager roles, with hub-wide reach granted through scope rather than a separate role.

> **Hub scope is explicit, not implicit (QA 2026-07, Task #1875)**
>
> Hub roles no longer receive implicit all-brand access. A Hub role's reach is whatever brand and program scope is explicitly granted in Configure Access Scope — hub-wide access means an explicit all-brands grant, not a side effect of holding a Hub role. This closes the gap where a Hub assignment silently outran the scopes an admin thought they had granted.

## Permissions by Role

Each role’s grants in its own table. Global roles span the entire ecosystem; Program roles are clamped to programScope; Hub roles apply across their explicitly granted brand/program scope. Legend: Full = granted · View = read-only · Opt = optional grant (assignable, off by default) · Host / Applicant = limited to that journey’s queue and exports. Each table lists what the role is granted; the Denied line names the notable boundaries it does not cross.

Global roles

Program-scoped roles

Hub roles

## Groups & User Assignment

Permissions are granted exclusively through roles. Role groups are organizational containers only — they organize roles and teams for display and administration, but a group never grants access by itself (QA 2026-07, Tasks #1897/#1898: the role-group permission-bundle model was removed and the empty role_group_permissions table dropped). Permission resolution unions the user's direct roles with every role attached to a role group the user is a member of (addMembershipPermissions in server/rbac.ts), so a group grants access only through its attached roles.

> **Group membership has two surfaces**
>
> Membership can be managed on the Role Groups page (POST/DELETE /api/rbac/role-groups/:id/members) and from User Management (create/edit user with a role group). Both paths write the same membership rows and apply the delegation-ceiling, group-scope and SoD checks; a member inherits every role attached to the group.

> **Who can assign roles**
>
> Role assignment is bounded by the assigner’s delegation grant and scope. A Program Admin can assign roles up to Staff within their program; a Hub Admin can assign any role below System Admin ecosystem-wide (never system_admin or another Hub Admin). A user can never be granted a role that exceeds the assigner’s own ceiling.

### Predefined Groups

A starter set of organizational groups. Each names the team a role typically belongs to — assigning the role puts the user in the group automatically (see membership sync above). Groups organize; roles grant.

| --- | --- | --- | --- |
| Program Operations | Program Manager | Program | Runs the full applicant + host journey for one program. |
| Program Admins | Program Admin | Program | Program owners with rule edits, finance edits, and delegation within the program. |
| Host Desk | Host Manager | Program | Host-journey staff: placements, host profiles, host queue. Hub-wide host coverage is this same role with an explicit all-brands scope grant (the former Hub Host role was merged in). |
| Applicant Desk | Applicant Manager | Program | Applicant-journey staff: intake, approval, SEVIS, applicant queue. Hub-wide applicant coverage is this same role with an explicit all-brands scope grant (the former Hub Applicant role was merged in). |
| Program Support | Support Manager | Program | Read-only help desk for one program with notes and limited assists. |
| Front Desk (combined) | Host Manager + Support Manager | Program | Example multi-role user: host operations plus cross-journey read and assist. |
| Hub Support Team | Hub Support | Hub (explicit scope) | Read-only help desk across the explicitly granted scope with notes and limited assists. |
| Platform Administrators | Hub Admin | Hub / Global | Broad operational authority and delegation of every role below System Admin. |

_(row above is a template, repeated for each data row)_

These are templates, not a fixed list: a Program Admin can create additional groups within their program, and a Hub Admin can define new groups ecosystem-wide. Groups never widen access — a user's effective permissions come only from their roles.

### Multiple Roles & Resolution

A user may hold several roles at once. Effective access is the union of every granted permission: the most permissive grant always wins, and no role’s silence denies what another role grants. Denials are simply the absence of a grant, never an explicit override.

effective(user) = &cup; permissions(role) for every assigned role &rarr; most-permissive level wins (Full > View > none)
Worked example

A user holds Host Manager and Support Manager in the SWT program. Their effective access in that program resolves to:

host.placements.manage → Full (from Host Manager) — Support Manager's silence does not remove it / applicant.view → View (both grant View; level is unchanged) / host.profile.manage → Full (Host Manager Full beats Support Manager View) / applicant.notes.edit → Full (granted by both) / contact.edit, invite.resend → Full (from Support Manager) / applicant:approve → denied (neither role grants it)
> **Union is additive, but scope still clamps**
>
> The union widens permissions, never scope. Each grant carries its own scope: a program-clamped role never reaches another program just because a second role is broader. A user with Host Manager (Program A) and Hub Support (explicit all-brands scope) gets host write in Program A and read everywhere — not host write everywhere. Resolution is computed per-resource against the target’s scope.

### Single Authoritative Role Store

The user_roles table is the single authoritative store of a user's roles (QA 2026-07, Task #1871). The legacy users.role column still exists, but it is now a derived lockstep cache of the "primary" role — computed as the user's highest delegation tier, with role name as the tiebreak — and is rewritten whenever the role set changes. A reconciliation backfill brought the two stores into agreement, and drift guards (including a lint rule that forbids writing users.role directly) keep them locked together. No enforcement decision reads the derived column as authority.

The `user_profiles` table (`id`, `user_id` referencing `users.id`, `role` defaulting to `readonly`, `program_access` as a text array, `is_active`, timestamps) is not the role store. Its `id` is the staff notification recipient identity: role-routed staff notifications resolve each staff user who holds a routed role (as the `users.role` value or through role-group membership) to their `user_profiles.id`. Only users that are active (`users.is_active` true and status `active`) and have an active profile row (`user_profiles.is_active` true) are included. This is notification routing, not an access decision.

> **No silent readonly fallback**
>
> A user whose role cannot be resolved now fails loudly with an explicit error instead of being silently treated as readonly. The same principle applies at creation: the Create User flow requires an admin to pick a role explicitly — new accounts no longer default to readonly. Silent defaults hid both misconfiguration and unintended access levels.

### User Account Lifecycle Controls

User Management carries full account lifecycle controls (QA 2026-07, Task #1883): deactivate (block sign-in while preserving the account and its history), reactivate (restore a deactivated account), and strip access (remove all roles and scopes while leaving the account intact for audit continuity). Every lifecycle action is written to the audit trail with the acting admin.

Three related admin endpoints, all requiring `users.manage` and audit-logged. `POST /api/admin/users/:id/mfa/reset` clears another user's MFA enrolment so they must set MFA up again at next sign-in; it returns 404 for an unknown user, 400 if the user has no MFA enabled, and 403 `FORBIDDEN` when the target is a `system_admin` and the caller does not hold god-mode. `PATCH /api/rbac/users/:id/role` (body `role`, optional `reason`) sets the user's role: the role must exist and be assignable (400 otherwise), must not exceed the caller's delegation ceiling (403), and is subject to the Hub-role scope requirement and the Segregation of Duties check; the audit row is written in the same transaction as the change. `PATCH /api/rbac/users/:id/scope` applies a partial scope update (absent fields are left untouched); specific programs force all-brands, and a Hub-role user must keep an explicit brand/program scope. When a role or scope change narrows a manager's coverage, the hosts that manager owns are re-routed on a best-effort basis.

**MFA switch (`MFA_ENABLED`).** MFA enforcement on staff login is controlled by the `MFA_ENABLED` environment variable on the Hub. It is on only when the value is `true` or `1`; unset or any other value leaves it off. When on, a user who has enrolled MFA gets an MFA challenge at `POST /api/auth/login` (`mfaRequired` and a temporary token). A user whose role is `admin` or `system_admin` must have MFA: without enrolment the login response carries `mfaSetupRequired`, every route except `/api/auth/mfa/*` returns 403 `MFA_SETUP_REQUIRED` until setup completes, and those roles cannot disable MFA.

**Staff MFA sign-in and recovery codes.** After a correct password on an MFA-enabled account, `POST /api/auth/mfa/verify` (body `mfaToken` and a 6-digit `code`) completes sign-in, and `POST /api/auth/mfa/recovery` (body `mfaToken` and `recoveryCode`) does the same with a recovery code. Neither needs a session; the `mfaToken` is valid for 5 minutes, and an expired or invalid one returns 401 `UNAUTHORIZED`. A wrong code returns 401 with `error.code` `MFA_FAILED` and writes `mfa.verify_failed` or `mfa.recovery_failed` to the audit log. On the TOTP path the message reports the attempts remaining, and five failed attempts lock the account's MFA verification for 15 minutes (attempts during the lock also return `MFA_FAILED`). `POST /api/auth/mfa/regenerate-recovery` is self-service for the signed-in staff user and needs no permission key (like `mfa/status`, `mfa/setup`, `mfa/verify-setup` and `mfa/disable`). The body carries the caller's current 6-digit TOTP `code`. On success it replaces the recovery codes, returns the new list, invalidates the previous codes and writes `mfa.recovery_regenerated`; MFA not enabled or a wrong code returns 400 `VALIDATION_ERROR`.

**Activating a pending SSO account.** A first Microsoft (Entra) sign-in by an unmatched user creates a pending record that holds no roles. In User Management, at least one role must first be granted through the role cards (the per-role endpoints); otherwise saving the activation is refused in the browser with an "Assign a role first" message. The Edit dialog has no role input, so the activation save sends `status` (set to `active`) and never a `role`, and the Active switch defaults to on for a pending record so that `status` and `isActive` become true together. This prevents a stale role snapshot from reverting a role that was just granted.

**Admin user routes.** All of these require `users.manage`. `GET /api/admin/roles/assignable` lists the roles flagged assignable, ordered by name, for the role picker. `POST /api/admin/users/:id/link-sso` binds a Microsoft (Entra) identity to an existing user, from a pending account (`pendingUserId`) or by direct `oid`; it returns 400 `VALIDATION_ERROR` when no oid can be resolved and 409 `OID_ALREADY_BOUND` when that identity already belongs to another user. `DELETE /api/admin/users/:id` refuses to delete the caller's own account (400 `SELF_DELETE_FORBIDDEN`) and the last System Administrator (409 `LAST_SYSTEM_ADMIN`); deleting a System Administrator account also requires god-mode (403 `FORBIDDEN`). Staff profile images: `PATCH /api/auth/profile-image` (any signed-in staff user; multipart field `image`, image files only, 5 MB limit) sets the caller's own image, and `GET /api/auth/profile-image/:userId` serves it. A user may read their own image; reading another user's requires god-mode or the legacy `admin` role (403 otherwise, 404 when none is set).

**Staff SSO sign-in errors.** OIDC sign-in (Microsoft Entra, and Google when configured) starts at `GET /auth/login` and completes at `GET /auth/callback`. Failures use the standard error envelope with `error.code`:

- `SSO_NOT_CONFIGURED` (503): SSO is not configured, on either route.
- `SSO_INIT_FAILED` (500): building the authorization request failed; `details` carries the underlying message.
- `SSO_STATE_MISSING` (400): the callback arrived with no in-flight login in the session.
- `SSO_TENANT_FORBIDDEN` (403): the `tid` claim is missing or is not `ENTRA_TENANT_ID`. Checked only in legacy Entra mode (`OIDC_DISCOVERY_URL` unset) when `ENTRA_TENANT_ID` is set.
- `SSO_DOMAIN_FORBIDDEN` (403): a Google sign-in whose `hd` claim is missing or is not `GOOGLE_WORKSPACE_DOMAIN`; enforced only when that variable is set.
- `SSO_CALLBACK_FAILED` (401): the code exchange or token validation failed.
- `SSO_SESSION_FAILED` (500): the session could not be created or saved after the identity check succeeded.

Unmatched, conflicting, email-less or pending identities are redirected to `/auth/pending` instead of returning an error.

### Configure Access Scope

The Configure Access Scope dialog assigns a user's data boundary. Brand is now a first-class dimension in the dialog alongside program and season (QA 2026-07, Task #1866) — brand scope is granted explicitly, which is what makes the explicit Hub scope model above workable. Granting a program-only scope no longer locks a user out of their brand surfaces: program-scoped users retain access to the brand context their programs belong to.

## Role Retirements & Retired-Name Denylist

The QA 2026-07 RBAC overhaul consolidated the role vocabulary. Each retirement deleted the role row and its grants and defensively repointed any holders to the named successor:

| Retired Role | Successor | Note |
| --- | --- | --- |
| compliance (legacy) | compliance_admin | Casework keys folded in; review/waiver keys stayed with compliance_officer (SoD: authoring never co-holds review). |
| brand_viewer, program_viewer | readonly | travel.view folded in; readonly stays strictly view-only. |
| host_management (placeholder) | host_manager | "Coming Soon" placeholder deleted; sevis.ready_pool.view carried over. |
| job_order_only (legacy) | — | Database-only role with zero holders; nothing to merge. |
| hub_applicant | applicant_manager | Applicant-journey set absorbed; re-retired after a seed-replay resurrection, and the historical seed migration was hardened so replays can no longer recreate retired roles. |
| hub_host | host_manager | Host-journey set absorbed; Hub roles reduced to hub_support + hub_admin. |
| program_coordinator | program_manager | 27 unique permission keys folded in; field-permission rows re-homed. |
| executive, finance (placeholder) | readonly (executive only) | Zombie rows deleted; finance's write keys deliberately not backfilled onto view-only readonly — they already live on the operational manager roles. |
| operations | program_manager (+ applicant_manager for fees.request) | Four unique keys split by permission; residual holders drop to readonly. |

> **Retired names are denylisted — 409 ROLE_NAME_RETIRED**
>
> Retired role names sit on a shared denylist. Creating a role with a retired name, or renaming an existing role onto one, is rejected with HTTP 409 and error code ROLE_NAME_RETIRED. This prevents a retired role from quietly coming back with a different permission set under a trusted old name.

> **Compliance roles carry operational authority**
>
> As part of the consolidation, the compliance roles gained the operational permissions needed to act across the applicant and host journeys — compliance staff can work cases directly instead of holding a second operational role. The authoring/review segregation between Compliance Admin and Compliance Officer is unchanged.

`stage.transition.terminate` (restricted, compliance category, reason required) gates the seeded lifecycle transitions into terminal stages: `withdrawn`, `rejected_disqualified` and `not_placed`. The transition validator refuses the move unless the acting user holds the key. It is seeded to Compliance Officer and Compliance Admin (System Administrator holds every permission).

## Segregation of Duties

The union model above is deliberately additive — more roles only ever widen what a user can do. That is correct for capability, but some permission combinations are dangerous to hold together even when each is individually legitimate: they let one person both act and check their own action. Segregation of Duties (SoD) is a separate control layer that runs after resolution and names specific conflicting pairs. It does not change scope clamping or the most-permissive-wins rule; it simply refuses (or flags) certain combinations of effective permissions.

effective(user) = &cup; permissions(role) &rarr; sod_check(effective) &rarr; deny-at-assignment | alert-at-runtime
Two enforcement modes: Deny pairs are hard-blocked at assignment time — the role assignment that would give a user both halves is rejected before it takes effect. Alert pairs are permitted but raise a compliance alert to the audit console on runtime co-occurrence and are flagged for review. Any Deny pair can be granted only through an explicit, time-boxed Super Admin exception with mandatory justification, written to the audit trail. When a role or role-group assignment would create a Deny pair that no active exception or god-mode holder covers, the assignment is refused with 403 `FORBIDDEN` and `details.code` `SOD_CONFLICT`. `details` names the affected `userId` and `user`, a `conflicts` array (`pairId`, `name`, `keys`, `description` and, where resolvable, the roles or role groups that grant each half in `sources`) and a `remediation` note that a System Admin may grant a time-boxed exception for that user and pair. The rejection is audit-logged in its own committed transaction so it survives the rollback. When a change edits a role's permissions, only pairs newly created by that edit block it, and a pair the user already held is flagged for compliance review instead.

| --- | --- | --- |
| compliance.rules.manage + compliance.review.manage | The role that authors and locks compliance rules would also grant exceptions and overrides to them — the rule-setter checking its own rules. This is the structural reason for the Compliance Admin vs Officer split. | Deny (hard block at assignment) |
| users.manage + role_templates.manage | A user who can both administer users and author role templates can silently escalate their own or anyone's privileges. | Deny (hard block at assignment) |
| eligibility:override:create + eligibility:override:approve | Creating an eligibility override and approving it are a maker-checker pair; one user holding both defeats the second pair of eyes. | Alert (runtime co-occurrence + audit flag) |
| compliance.rules.author + compliance.rules.publish | Authoring a compliance rule and publishing it are a maker-checker pair; one user holding both can push unreviewed regulatory logic live. | Alert (runtime co-occurrence + audit flag) |

_(row above is a template, repeated for each data row)_

> **SoD is a layer, not a scope change**
>
> Segregation of Duties never narrows a single permission and never alters the scope grammar. It only inspects the set of permissions a user would effectively hold and blocks or flags named conflicts. A user can still hold either half of a pair freely; the control fires only when both land on the same actor within overlapping scope.

## Permission Glossary

Every permission token used in the role tables above, in plain language, with a concrete example of what holding it lets a person do. This is the admin-facing reference for what each grant means before you attach it to a role; the role tables show which roles hold each one.

`reporting.audit_export` (restricted, compliance category, no reason required) lets the holder download the complete, uncapped record set of a catalogue report for regulatory audit, bypassing the 5,000-row interactive download cap. It is seeded to both halves of the compliance split (Compliance Officer and Compliance Admin), and System Administrator holds every permission; the report export menus offer the complete-record download only to holders.

`operational_forms.submit` (category applicants) lets staff fill in and submit operational forms on an application's screening activity: start a submission (`POST /api/applications/:applicationId/screening/:activityKey/form/submissions`), save a draft (`PATCH /api/operational-form-submissions/:submissionId`), submit it (`POST /api/operational-form-submissions/:submissionId/submit`) and supersede it to correct it (`POST /api/operational-form-submissions/:submissionId/supersede`). Each route checks this permission and, for API keys, the `applications.write` scope. Reading uses `operational_forms.view`; authoring and publishing definitions use `operational_forms.author` and `operational_forms.publish`. Seeded holders include Compliance Officer, Compliance Admin, Program Manager, Program Admin and the Applicant Manager role (System Administrator holds every permission).

## Portal Roles

> **Strict Scoping**
>
> Portal roles are the most restricted. Partners (both external and internal), hosts, and applicants can only see data that is explicitly shared with them. They cannot see other partners' applicants, other hosts' placements, or other applicants' information.

## How Enforcement Works

RBAC is not advisory — it is enforced at every layer of the system. The following mechanisms ensure permissions are respected regardless of how a user interacts with the platform.

### Middleware Permission Checks

Every API request passes through permission middleware before reaching the handler. The middleware:

Extracts the user's roles and scope from the session token / Resolves the required permission for the requested resource and action / Checks that at least one of the user's roles grants the required permission / Verifies the user's scope includes the target brand/program / Returns 403 Forbidden if any check fails — no silent fallback
### Permission-Based God-Mode (system.admin)

Globus carries a single system-wide all-access override — “god-mode” — that skips both permission checks and data-access-scope checks. It was historically keyed off the is_super_admin account flag, which roughly two dozen server sites independently inspected to bypass enforcement (the core rbac gates, the maintenance-mode / partner-type / field-permission middleware, and the job-orders, partners, review, and regulatory-bundle handlers).

> **God-mode now resolves through the system.admin permission, not the is_super_admin flag.**
>
> The all-access override is resolved through one canonical helper — isGodMode(perms) = hasPermission(perms, "system.admin") — with userHasGodMode(userId) resolving it via getUserPermissions(userId). Every former flag-based bypass site now goes through this permission. The system.admin permission is seeded only to the System Administrator role, and the ["*"] permission contract returned to downstream god-mode callers is unchanged.

Why this matters for Hub Admin. The top operational role (Hub Admin) legitimately carries is_super_admin for unrelated reasons — deriving its unbounded delegation ceiling and SEVIS break-glass. Under the old flag-based bypass that incidental flag silently granted Hub Admin full god-mode it was never meant to have. Because Hub Admin is not granted the system.admin permission, it no longer bypasses permission or scope checks: normal enforcement applies. Nothing changes for ordinary users.

Certain is_super_admin uses are intentionally retained because they are not permission/scope bypasses: delegation-ceiling and SEVIS break-glass derivation, SQL column projections that select the flag into rows, the staff-user classifier, and the explicit category-B gates that genuinely require super-admin.

> **Cross-repo: implemented in Globus core; mirrored here.**
>
> The runtime change lives in the Globus core repository (server/rbac.ts and the rewired middleware/handlers) and mirrors ecosystem ticket #4024. This page documents the ecosystem-level governance rule — flag-based bypass is forbidden; the all-access override must be a single explicitly-granted permission.

### Actor Context Middleware

The actorContext.ts middleware runs on every request and establishes a unified identity context that downstream handlers consume. It resolves whether the current request originates from a session-based staff user or an API key, and attaches the resolved actor to the request object.

staff / api_key / partner / system
> **Single Source of Identity**
>
> All downstream middleware and route handlers read identity from the actor context — never from raw headers or session objects directly. This ensures consistent identity resolution across session auth, API key auth, and impersonation flows.

### Scope Filtering

Even when a user has the correct permission, scope filtering ensures they only see data within their assigned boundary. Every database query is automatically decorated with scope conditions:

-- Global role: no filter
SELECT * FROM applicants;
-- Brand role (Teacher Lounge):
SELECT * FROM applicants
WHERE brand_id = 'teacher-lounge';
-- Program role (Camp Counselor within Alliance Abroad):
SELECT * FROM applicants
WHERE brand_id = 'alliance-abroad'
AND program_id = 'camp-counselor';
### Impersonation

Impersonation has been retired (Task #2028): the impersonation.use permission is no longer registered or granted to any role and no impersonation routes exist; audit rows keep an impersonatedUserId column only for historical entries.

> **Impersonation is retired**
>
> No impersonation session exists (Task #2028), so permission gates always evaluate the signed-in user. Historical audit rows keep an impersonatedUserId column.

### Audit Coverage

All state-changing operations produce audit records. The audit system captures:

**Action labels in the RBAC audit log page.** The page (`client/src/pages/rbac-audit-log.tsx`) maps stored `action` keys to fixed labels and shows a title-cased version of the key for any key not listed. The map is a display list, not a guarantee of which keys the server emits: the server also writes keys the map does not list, for example `queue.change_readiness` and `brand.deleted`, which appear through the fallback label. For certain operational actions, such as reservation cancellations or extension requests, the log also displays any captured explanation or note, with a descriptive label. The page has an All activity tab (value `all`) and opens on it; the other tabs are `security` and `session`.

- Authentication: `auth.login`, `auth.logout`, `auth.login_failed`, `auth.login_mfa_pending` (MFA verification pending), `auth.password_change`.
- Users: `user.create`, `user.update`, `user.delete`, `user.password_reset` (Password reset by admin).
- Roles and groups: `create_role`, `update_role`, `delete_role`, `create_role_group`, `update_role_group`, `delete_role_group`, `update_role_permissions` (Permissions changed), `update_user_role` (Role reassigned), `update_user_scope` (Scope changed).
- Impersonation (feature retired, labels kept): `impersonation.start` (Impersonation started), `impersonation.end` (Impersonation ended).
- API keys: `api_key.create`, `api_key.revoke`.
- MFA: `mfa.enabled`, `mfa.setup_initiated` (MFA setup started), `mfa.verify_success` (MFA verified).
- Queues: `queue.assign`, `queue.unassign`, `queue.reassign`, `queue.move`, `queue.escalate`, `queue.bulk_approve`, `queue.readiness_change`, `queue.note_added`.
- Documents: `document.uploaded`, `document.replaced`, `document.updated`, `document.review.approved`, `document.review.rejected`, `document.review.resubmission_requested` and the matching `host_document.review.*` keys.
- Reservations and placements: `reservation.released`, `reservation.cancelled`, `reservation.deleted`, `placement.terminated`, `placement.cancelled`, `reservation.declined`, `extension_request.created`, `reservation.hold_extended`.
- Also labelled: brand, partner org, job order, SEVIS batch and certificate, season, program rule, webhook, portal sync and step review actions.

### Sensitive Data Redaction

Sensitive personal data (passport number, date of birth, banking details, etc.) is treated as a separate access layer. Even within a user's scope, sensitive fields require explicit read permission. Roles without this permission see redacted values.

With Permission

Passport: AB1234567

SSN: 123-45-6789

DOB: 1998-03-15

Without Permission

Passport: *****4567

SSN: ***-**-6789

DOB: ****-**-**

### PII Protection Middleware

Two dedicated middleware modules handle Personally Identifiable Information (PII) at the API layer, ensuring sensitive data is both access-logged and redacted where appropriate:

module
> **Defense in Depth**
>
> PII redaction is not a substitute for access control. The piiRedactor acts as a safety net — even if a code path accidentally includes sensitive fields in a response, the redactor ensures unauthorized users never see the raw values. The piiAccessLogger provides the audit evidence that access controls are functioning correctly.

### Client-Side Permissions

The frontend receives a permissions manifest on login that describes which UI elements to render. This is a UX convenience only — the backend enforces all permissions regardless of what the client displays. Buttons, menus, and navigation items are shown or hidden based on the manifest, but tampering with the client cannot bypass server-side enforcement.

## API Key Management

API keys provide authentication for external integrations, portal-to-Globus communication, and automated system interactions. Keys are managed via the /api/admin/api-keys admin interface and follow strict security practices.

# API Key admin endpoints

GET /api/admin/api-keys → List all keys (metadata only, no raw values)

POST /api/admin/api-keys → Create new key (returns raw key once)

POST /api/admin/api-keys/:id/revoke → Revoke a key immediately

PATCH /api/admin/api-keys/:id → Replace the scopes of a live key (body: scopes)

> **Server-Side Secrets Only**
>
> API keys are server-side secrets. They must never be exposed to browser clients, committed to version control, or included in client-side bundles. Portal backends store keys in environment variables or secret managers.

**Internal-session-only permission.** When an API-key request reaches a route gated by `requirePermission` on `job_orders.auto_create_missing`, the guard refuses it with 403 `FORBIDDEN` and the message "Automated job-order recovery requires an internal staff session."

### Read-only data-lake scope

The data_lake.read API-key scope is a read-only analytics credential. The API-key guard (validateApiKeyAccess) refuses any non-safe HTTP method (anything other than GET, HEAD or OPTIONS) with 403 and details.reason=read_only_scope when a key's entire scope set is data_lake.read, optionally alongside the PII companions (sensitive_data.view, applicant.pii.read) and partner:/program:/season:/brand: restriction tokens. Any other scope on the key, including the wildcard, means the rule does not apply, so existing keys are unchanged. The check sits on the credential rather than on individual routes, so it covers routes whose guard names no scope and that have no permission gate. data_lake.read is not a host-side scope, so such keys still reach /api/admin/* reads.

The scope is accepted as an alternative to the endpoint's own scope (isAuthenticatedOrApiKeyAnyScope, any-of) on these GET routes: /api/applications, /api/applicants, /api/programs, /api/seasons, /api/brands, /api/admin/placements, /api/admin/reservations, /api/admin/job-orders, /api/admin/job-orders/:id/matches and /api/applications/:id/stage-history; a bounded, name-only GET /api/admin/partners/organizations lookup is also opened to it. The `data_lake.read` scope maps to the staff permission `brand.legal.view` among others; it is the only non-`programs.manage` arm of the brand read gate for `GET /api/brands`. `brand.legal.view` (category brands, not restricted, no reason required) allows viewing a brand's legal policy pages (disclaimer and privacy policy); the brand legal-policy read routes accept `programs.manage` or `brand.legal.view`. The Legal role holds it together with `brand.legal.manage`, which edits those pages. Other scopes such as `applicants.read` do not grant it. A key that holds data_lake.read without the route's original scope and without the wildcard is treated as a new analytics caller and gets a restricted read path: brand/program/season/organization restrictions on the key are intersected, unsupported colon tokens fail closed with 403, and an organization-association with no season does not establish access to a specific season when the key is season-restricted. Session callers and keys with the original scope or the wildcard keep the original behavior.

## Partner RBAC System (Separate)

The Partner RBAC system is a completely separate authorization model from the internal staff RBAC. It governs access for host portal and recruiter portal users via dedicated database tables and middleware. This separation ensures that partner/host users never intersect with internal staff permissions and that the two models can evolve independently.

> **Two Distinct RBAC Systems**
>
> Staff RBAC uses the roles and permissions tables for sponsor users. Partner RBAC uses partner_roles and partner_permissions tables for external portal users. These systems are never mixed.

### Partner RBAC Tables

The Partner RBAC data model consists of dedicated tables that mirror the structure of the internal RBAC system but are completely isolated:

| Table | Purpose | Key Columns |
| --- | --- | --- |
| partner_roles | Defines available roles for partner/host portal users | name, description, type (host\|recruiter) |
| partner_permissions | Fine-grained permission keys mapped to partner roles | roleId, permissionKey, description |
| partner_users | External portal user accounts with role assignment | email, partnerOrgId, role, isActive |

There is no partnerRbacStorage.ts module. partner_roles and partner_permissions are created by migration 20260408120000_partner_rbac_tables.sql but have no Drizzle model; at runtime partner_users.role (admin, recruiter, finance, readonly) is resolved to capabilities through role_capabilities in server/services/hostCapabilityResolver.ts. The same resolver is also used by recruiting-portal guards: for a capability that does not start with `host.`, `hostRoleHasCapability` looks the role up in `role_capabilities` directly, reading `hiring_manager` as `recruiter` and `read_only` as `readonly` and using any other role key as given. Those role keys and grants are independent of the host vocabulary.

### Partner RBAC Roles

Partner users are assigned roles from the partner_roles table. These roles map to permission keys that control what the user can do within the portal:

role
### Partner RBAC Middleware

Partner RBAC is enforced through a dedicated middleware stack, separate from the internal staff RBAC middleware. These middleware modules run on all partner/host portal API routes:

| Middleware | Purpose |
| --- | --- |
| partnerAuth.ts | Authenticates the partner/host user and resolves their identity from the API key and user headers |
| partnerEnforcementGuard.ts | Validates that the authenticated partner user has the required permission for the requested action |
| partnerScopeFilter.ts | Applies organization-level data isolation — ensures queries only return data belonging to the user's org |
| partnerTypeGuard.ts | Validates that the partner type (host vs recruiter) matches the portal context of the requested endpoint |
| requireHostRole (partnerAuth.ts) | Guards host-specific endpoints by checking the user has a valid host role (admin, recruiter, finance, readonly) |
| requireHostCapability / requirePartnerUserCapability / requirePartnerScope (partnerAuth.ts) | Checks that the user's host role holds the capability (or the partner API key the scope) required for the endpoint |

> **Complete Isolation**
>
> The partner RBAC middleware stack is entirely separate from the staff RBAC middleware. A request from a partner portal user never touches the internal roles or permissions tables, and a staff user request never touches partner_roles or partner_permissions.

## Brand as First-Class Scope

> **Architectural Decision**
>
> Brand is a first-class scope in the RBAC model. It is not merely a tag or filter — it is the primary organizational boundary that determines data visibility, team membership, partner relationships, and compliance rules.

Every entity in the system — programs, seasons, applicants, partners, hosts, quotas — is scoped to a brand. Cross-brand operations require explicit global-level permissions.

### Recommended Scope Model

Permissions determine what actions are allowed. Scope determines where they apply. The recommended model separates these concerns:

Permission Layer

applicant:read / applicant:create / applicant:approve / sevis:submit / partner:manage / report:export
Scope Layer

global:* / brand:alliance-abroad / brand:wild-packs / program:camp-counselor
Effective Access

A user with applicant:approve + brand:wild-packs can approve applicants only within Wild Packs. They cannot approve applicants in Teacher Lounge even though they hold the approve permission.

## Portal Actor Alignment

Portal actors (partners, hosts, applicants) interact through portals, not Globus directly. Their RBAC is implicitly defined by the portal's capabilities and the agreements that bind them. Note: “Partner” includes both external recruiting companies and internal sponsor recruiting teams — both use the Partner Portal with config-driven RBAC differences:

applicants. Cannot see internal operations. Quota visibility is limited to their allocation.,
Host Organization / Host Portal / Own job orders and confirmed placements. Receives limited applicant profile data required for placement. / Cannot see applicant sensitive personal data beyond what
## Internal RBAC vs Portal Access

> **Two Distinct Authority Models**
>
> The ecosystem distinguishes between internal RBAC for sponsor users and relationship-based access for external portal users. These are separate models that serve different purposes.

### Internal RBAC (Sponsor Users)

Applies to sponsor staff, brand managers, compliance officers, and executives. These users log into Globus and are governed by:

Role assignment / Permission grants / Brand / Program / Season scope / Sensitive data access permissions / Audit logging / Delegation authority
### External Portal Access (Relationship-Based)

Applies to applicants, hosts, and partners. These users:

Do not have internal RBAC roles / Do not have brand-level visibility / Are scoped to their specific relationship only
Access is enforced by:

> **Centralized Authority**
>
> This keeps the authority model centralized within Globus while allowing controlled external interaction through portals. External users never inherit or interact with the internal RBAC system.

## RBAC Scope Diagram

The following diagram illustrates the concentric scope model. Each inner ring is fully contained by its parent — there is no way to access data outside your assigned ring without an explicit grant.

Global Scope

Super Admin, Compliance Admin, Compliance Officer, Executive, System Administrator

Alliance Abroad / Wild Packs
Portal (Partner / Host / Applicant)

Agreement-bound, single-entity scope, portal-only access. Partners include external agencies and internal sponsor staff.

## Partner Portal RBAC Governance

Partner Portal access is governed centrally within the Sponsor Core (Globus). The Partner Portal does not manage its own authority model — it reflects permissions determined by Globus. The portal serves both external recruitment companies and internal sponsor recruiting teams. RBAC rules are configurable by partner origin type (External vs Internal), allowing internal teams to have broader data visibility or additional capabilities without code changes.

> **Architectural Boundary**
>
> Partner Portal does not own business authority. All enforcement, quotas, and compliance controls are centralized in Globus. Globus decides. Partner Portal reflects.

### Governance Layers

Partner access is determined by two combined controls: the Partner Agreement (what the sponsor authorizes) and the Partner User Role (what the individual can do within that authorization).

### Partner Agreement (Sponsor-Controlled)

A time-bound authorization defined and managed entirely within Globus. If an agreement expires or is suspended, portal access is automatically revoked.

### Partner User Roles

Within each Partner Organization, individual users are assigned roles. Roles determine what actions can be performed. Agreements determine where those actions apply.

Effective Access Formula

Role Permissions &cap; Agreement Scope &cap; Brand / Program / Season Scope

A Recruiter with an agreement scoped to Wild Packs &rarr; Adventure Camp &rarr; Summer 2026 can only submit applicants for that exact combination. The role grants the “submit” action; the agreement limits where it applies.

### Partner Enforcement Model

All Partner actions are validated by Globus middleware. The Partner Portal UI reflects permissions but does not enforce security.

Permission checks on every protected endpoint / Scope filtering at query level — partners only see own applicants / Quota enforcement during intake and reservation (hard limits, not advisory) / Full mutation audit logging with partner identity and agreement context / Agreement status validation — expired or suspended agreements block all writes
### Partner Scope Hierarchy

The partner scope model is a strict subset of the ecosystem scope. Partners cannot access data outside their agreement boundary at any level.

## Host Portal RBAC Governance

Host Portal access control uses relationship-based authorization, not role-based access like internal Globus RBAC. Host users can only see data belonging to their own organization, and their capabilities are determined by the combination of their user role and their organization's agreement terms. For the full governance model, see Host Portal Governance .

### Governance Layers

### Host Agreement (Sponsor-Controlled)

A time-bound authorization defined and managed within Globus. Expired or suspended agreements automatically revoke portal access.

### Host User Roles

Within each Host Organization, individual users are assigned roles. Roles determine what actions can be performed. Agreements determine where those actions apply.

Effective Access Formula

Role Permissions &cap; Agreement Scope &cap; Organization Ownership

A Hiring Manager at Sunny Pines Camp with an agreement scoped to Camp Counselor &rarr; Summer 2026 can only create job orders for that exact combination. The role grants the “create job order” action; the agreement limits where it applies; the org ownership isolates their data.

### Host Activation Endpoints (Unauthenticated, Token-Scoped)

First-login activation and password reset run on a sibling path, /api/host/activation/*, that intentionally does not require a host-portal API key — the invitee has no credential yet. Authorization is enforced entirely by possession of the opaque single-use token in the activation link. Issuing the invite (POST /api/partner/host/users) is the authenticated half of the flow and is gated by host.users.manage (host_admin only). See Host User Invitation for the frozen contract.

**Staff activation (`/api/staff/activation/*`).** Staff welcome links use the same unauthenticated, single-use-token pattern against the local `users` row (not a host flow). `GET /api/staff/activation/status?token=` validates the token without consuming it (`400 INVALID_TOKEN` when it is missing, `410 INVALID_OR_EXPIRED_TOKEN` when unknown or expired). `POST /api/staff/activation/complete` with `token` and `password` sets the password and clears the token (`400 INVALID_TOKEN` when either is missing, `410 INVALID_OR_EXPIRED_TOKEN`). A password that fails the policy is refused with `400` and `error.code` `WEAK_PASSWORD`, with the failed rules (`too_short`, `insufficient_classes`, `common_password`, `contains_identity`) in `error.details.failures`. The policy requires at least 12 characters, at least three of the four character classes (lower, upper, digit, symbol), a password not on the bundled common-password list, and a password that does not contain the email local-part (when it is at least 4 characters) or the display name or any word of it that is at least 4 characters. Completion is audited as `user.activation_completed`.

### Host Enforcement Model

All Host actions are validated by Globus middleware. The Host Portal UI reflects permissions but does not enforce security.

Permission checks on every protected endpoint / Organization-scoped filtering — hosts only see own data (X-Host-Org-Id header) / Capacity enforcement on job orders (hard limits, not advisory) / Job order approval workflow — hosts cannot self-approve / Full mutation audit logging with host identity and agreement context / Agreement status validation — expired or suspended agreements block all writes
### Host Capability Registry

Every Host Portal capability resolves through a single shared registry rather than scattered if (role === 'host_admin') checks at call sites. The registry is the one place where a permission key (host.placements.manage, host.profile.manage, …) is mapped to the set of host user roles allowed to perform it.

Registry Contract

Single source of truth — the full Host RBAC Permission Keys table (see /docs/host-governance#host-rbac-permission-keys) is the data the registry serves; route guards, the /me bootstrap response, and the Host Portal UI all read from it. / Resolution shape — hostRoleHasCapability(role, capability) returns boolean (server/services/hostCapabilityResolver.ts), exposed to routes through the requireHostCapability middleware. The /me endpoint returns the full resolved set so the portal can render/hide controls without re-asking per click. / Role-only inputs — capability resolution is driven solely by the host user's role (admin | recruiter | finance | readonly). Agreement scope and organization ownership are enforced separately and never granted/denied by the registry. / Closed vocabulary — new capability keys are added by editing the registry, not by introducing ad-hoc string checks. Unknown keys deny by default. / Test coverage — every key in the registry has at least one positive (allowed role) and one negative (denied role) test, so the matrix in /docs/host-governance#host-rbac-permission-keys stays the executable spec.
> **No role-string checks at call sites**
>
> Route handlers must call requireHostCapability(key) (or read the resolved set on the request context). Inline comparisons against user.role drift away from the table on Host Governance and re-introduce the dispersed-authorization bug the registry exists to prevent.

### Host Scope Hierarchy

The host scope model is a strict subset of the ecosystem scope, further narrowed by organization ownership. Hosts cannot access data outside their organization boundary.

## Role Examples

Concrete examples showing how role, scope, and effective access combine:

| Example User | Role | Scope | Can Access |
| --- | --- | --- | --- |
| Compliance Policy Lead | Compliance Admin | All brands | Author/classify/lock compliance rules; program-number to program-type mapping |
| Global Compliance | Compliance Officer | All brands | All programs, SEVIS records, audit trails; review queue and per-failure overrides |
| Teacher Lounge PM | Program Manager | Teacher Lounge only | TL programs and applicants (the former Program Coordinator role was retired into Program Manager) |
| Wild Packs Ops | Brand Manager | Wild Packs only | WP programs, partners, and applicants |
| Alliance Abroad Admin | Brand Admin | Alliance Abroad only | AA programs, team, partners, seasons |
| Partner Recruiter | Partner (external) | Agreement-bound | Own applicants within assigned programs |
| Host Organization | Host (external) | Own placements | Own job orders and placement data |

> **Deny by Default**
>
> The RBAC system follows a deny-by-default model. If a permission is not explicitly granted to a role, the action is blocked. There are no implicit permissions, no inheritance across brands, and no “admin override” that bypasses the scope model.

## Compliance Rules Permission

The compliance.rules.manage permission is assigned to the Compliance Admin role and grants the ability to create, classify, and lock structured rule definitions classified as compliance-mandated. This permission governs the full rule definition lifecycle through the rule_definitions table. Operating under those rules — running the compliance review queue and overriding individual failures — is a separate permission (compliance.review.manage) held by the Compliance Officer; the two are kept apart by segregation of duties.

| Permission | Role | Scope | Grants |
| --- | --- | --- | --- |
| compliance.rules.manage | Compliance Admin | Global (all brands) | Create compliance rule definitions (with regulatory basis); classify existing business rules as compliance; set effective dates and retroactivity; lock rules across all affected program_rule_configs. Rule authoring and classification only — does not include overriding live failures or running the review queue. |
| compliance.review.manage | Compliance Officer | Global (all brands) | Operate compliance under the locked rules: manage the compliance review queue and override individual compliance failures with documented justification (stored in rule_overrides with full audit trail). Cannot author, classify, lock, or unlock rule definitions — that is the Compliance Admin. |
| business.rules.manage | Compliance Admin, Program Manager, Brand Admin | Limited to rule sets attached to the holder's programs or brand; unrestricted for holders of `compliance.rules.author` or `compliance.rules.govern` | Forward lifecycle edges of a Business Rule Set (draft to published, published to deprecated, deprecated to archived) and membership edits; `compliance.rules.author` authorises the same edges. A holder of only this permission needs program scope that intersects the set's attached programs, or brand scope that includes its brand, otherwise the call returns 403. |
| compliance.rules.govern | Compliance Admin (System Administrator holds every permission) | Global | Restricted, reason required. Override (rollback) lifecycle transitions on a Business Rule Set (published to draft, deprecated to published, archived to deprecated), each with a free-text reason. Also permanently deletes a rule definition through `DELETE /api/rules-engine/definitions/:id` (reason of at least 8 characters; 409 `RULE_HAS_OVERRIDES` while applicant overrides still reference the rule). |
| compliance.program_mapping.view | Compliance Admin (System Administrator holds every permission) | Global | Unrestricted, no reason required, read-only. Gates `GET /api/program-number-mappings` (effective-dated history; optional `programNumber` and `activeOnly=true` filters) and `GET /api/program-number-mappings/resolve` (query `programNumber`, required, else 400 `VALIDATION_ERROR`; optional `asOf`; returns the resolved program type as `resolved`). Both also accept an API key with scope `portal.read`. Changing mappings still requires `compliance.program_mapping.manage`. |
| program_rules.view | (retired) | — | Retired in the Task #535 program-rules cleanup and purged from the permissions table (Task #2013); no role holds it. The retirement migration names compliance.rules.author as the canonical replacement. |
| program_rules.edit | System Administrator, Program Manager | Global (System Administrator) / Program scoped (Program Manager) | Create and configure business rules via program_rule_configs. Toggle, adjust thresholds, configure lists. Cannot modify compliance-locked rules (API returns 403). Program Manager holds this within its program since the QA 2026-07 grant expansion (Task #1950); the seed change is paired with an idempotent name-keyed data migration because the RBAC seeder is insert-only for existing roles. |
| business.rules.manage | Compliance Admin, Program Manager, Brand Admin (System Administrator holds every permission) | Global (Compliance Admin) / Program or Brand scoped (Program Manager, Brand Admin) | Business Rule Set forward lifecycle edges (draft to published, published to deprecated, deprecated to archived); `compliance.rules.author` also authorises the forward edges. Set membership edits and metadata writes are gated on `compliance.rules.author`, not on this permission. Holders without `compliance.rules.govern`, `compliance.rules.author` or god-mode (API keys are exempt) are limited to sets whose attached programs or brand fall inside their scope. |
| compliance.rules.govern | Compliance Admin (System Administrator holds every permission) | Global (not scope limited) | Override (rollback) Business Rule Set transitions (published to draft, deprecated to published, archived to deprecated), each with a free-text reason on the audit log, and hard delete of a rule definition (`DELETE /api/rules-engine/definitions/:id`, reason of at least 8 characters, 409 `RULE_HAS_OVERRIDES` while applicant overrides reference it). Restricted, reason required. |
| compliance.program_mapping.view | Compliance Admin (System Administrator holds every permission) | Global | Read-only view of the resolved SEVIS program-number to program-type mapping (resolve and list routes); grants no edit authority, unlike `compliance.program_mapping.manage`. |

Navigation & Access Split

Rules are accessible through two navigation paths, each gated by different permissions:

| Navigation Path | Target User | Permission Required | What They See |
| --- | --- | --- | --- |
| Program Configuration → Program Rules | System Administrator, Program Manager (edit) | program_rules.edit | All rule definitions. Both roles configure business rules (editable toggles, parameters, season-level overrides) — globally for the System Administrator, program-scoped for the Program Manager (QA 2026-07, Task #1950). Compliance rules: read-only with shield icon, "Set by Compliance" label, disabled toggle. |
| Governance & Operations → Compliance Rules (authoring) | Compliance Admin | compliance.rules.manage | Rule authoring lifecycle: create structured rule definitions, classify business rules as compliance, set effective dates and retroactivity, lock rules across program_rule_configs. No override controls. |
| Governance & Operations → Compliance Review Queue | Compliance Officer | compliance.review.manage | Operational casework: the compliance review queue and per-failure override with documented justification. Rule definitions are read-only (shield icon, disabled toggle). |

Override authority is strictly controlled:

perm
> **Cross-Reference**
>
> For the linear story of compliance vs business rules, the Brand → Program → Season cascade, compliance_locked, and what each role sees on the Program Rules surface, see Program Rules — One-Page Read . For full details on rule definition anatomy, the evaluation pipeline, and override authority — see the Rules Engine — Compliance vs Business Rules section. For lifecycle touchpoints and how rules gate every phase transition, see Rules Engine — Every Lifecycle Touchpoint .

## Compliance Admin vs Officer

Compliance is split into two global roles so that the person who writes the rules is never the same person who grants exceptions to them. This is the segregation-of-duties pair compliance.rules.manage + compliance.review.manage made structural: the two permissions live on different roles and cannot be held together without an audited Super Admin exception.

Compliance Admin

Owns compliance policy. The sole non-superadmin holder of compliance.rules.manage and compliance.program_mapping.manage (System Administrator holds every permission, but is blocked from managing program mappings by a route-level gate).

Create compliance rule definitions with regulatory basis / Classify existing business rules as compliance-mandated / Set effective dating and retroactivity; lock rules across program_rule_configs / Maintain the program-number to program-type mapping
Compliance Officer

Operates under the locked rules. Holds compliance.review.manage plus cross-brand monitoring reads.

View all SEVIS records and audit trails across brands / Run the compliance review queue / Override individual compliance failures with documented justification / Cannot author, classify, lock, or unlock rule definitions

> **Why split the role**
>
> Before this split, a single Compliance Officer both authored compliance rules and granted overrides against them — a self-policing loop with no independent check. Moving rule authoring and classification to a dedicated Compliance Admin tier keeps day-to-day casework (queue, overrides) with the Officer while putting policy change behind a separate, accountable role. See Segregation of Duties.

## Program Number Mapping

Each SEVIS program number (for example P-3-12345) must resolve to a single program type — Summer Work & Travel, Camp Counselor, Intern, Trainee, or Teacher — because that type drives which compliance rules, document waves, and lifecycle phases apply. The mapping is administered through a dedicated Compliance Admin function rather than edited inline on applicant records, so classification stays consistent and auditable.

| --- | --- |
| Function | Create, edit, and retire program-number to program-type mappings, with effective dating so a renumbering does not retroactively reclassify historical records. |
| Permission | compliance.program_mapping.manage |
| Owner | Compliance Admin (global). System Administrator is rejected with 403 on the management routes even though that role carries every permission (custom gate). Not held by Compliance Officer or any operational role. |
| Read access | program_rules.view was retired (Task #2013) and no longer exists; operational roles consume the mapping but cannot change it. |
| Audit | Every create/edit/retire writes an audit entry (who, when, program number, old type, new type, effective date) via the standard audit vocabulary; mapping changes are queryable in the audit console. |
| Segregation of Duties | Holding compliance.program_mapping.manage together with applicant:approve is not one of the catalog pairs; it is checked separately each time a mapping is created, edited or retired. When the acting user holds both, an idempotent high-severity compliance case (reason `sod_program_mapping_and_applicant_approve`) is opened for review; the mapping write itself is not blocked. |

_(row above is a template, repeated for each data row)_

## Named Compliance Accounts

Compliance work is performed only through named individual user accounts. The previously shared Compliance AEG login — a single credential used by multiple compliance staff — is retired. A shared account makes the audit trail meaningless: every action resolves to one anonymous identity, so you can never tell who classified a rule, granted an override, or changed a program mapping.

> **Attribution is the point**
>
> Removing the shared account is what makes the Compliance Admin vs Officer split and the SoD controls enforceable: a control that fires on “who holds which permission” only works when every action maps to a real, named person.

## Finance Permissions

Finance data is sensitive and access is governed by six granular `fees.*` permissions. These permissions follow the same RBAC enforcement model as all other permissions — middleware checks, scope filtering, and audit logging all apply. For full context on finance workflows, charge catalogs, and NetSuite integration, see the Finance & NetSuite page.

| Permission | Role | Scope | Grants |
| --- | --- | --- | --- |
| fees.view | Finance Viewer, Editor and Admin presets | Brand / Program | View finance summary, charges, invoices, payments, finance audit, charge catalog and account parties |
| fees.trigger | Finance Editor and Admin presets | Brand / Program | Create charges, add/edit charge catalog entries, run the finance bulk import, raise invoice requests |
| fees.request | Not part of a finance preset | Brand / Program | Request a payment from an applicant |
| fees.mark_paid | Finance Editor and Admin presets | Brand / Program | Update the finance summary and mark charges paid |
| fees.deferred.approve | Finance Admin preset | Brand / Program | Approve a fee deferral (restricted, reason required) |
| fees.deferred.revoke | Finance Admin preset | Brand / Program | Revoke a fee deferral (restricted, reason required) |

### Finance Role Presets

Three convenience presets bundle the granular permissions for common staff profiles. Presets expand to the underlying permissions at assignment time — organizations can also assign individual permissions for fine-grained control.

| Preset | Includes |
| --- | --- |
| Finance Viewer | fees.view |
| Finance Editor | Finance Viewer + fees.trigger, fees.mark_paid |
| Finance Admin | Finance Editor + fees.deferred.approve, fees.deferred.revoke |

> **Scope Filtering Applies**
>
> All finance permissions respect the existing RBAC scope model. A program manager scoped to a specific brand can only view/edit finance data for that brand's applications. Scope filtering is enforced at the query level, not the UI level.

## Field-Level Permission Control

Role-based access controls who can access what. Field-level permissions control what they can do with each piece of data. This is critical because different actors (applicants, partners, sponsor staff) interact with the same application record but must have different read/write/override capabilities on individual fields.

### Permission Model

Field-level permissions are resolved using a four-dimensional lookup:

Role &rarr; Workflow Step &rarr; Field &rarr; Permission (No Access | View | Edit | Override)
This model extends the existing Step Library. Each workflow step already defines which fields are visible and editable. The permission layer adds actor-specific constraints on top of that structure.

No Access — field is not rendered in the UI for this role / View — field is visible but read-only; value cannot be changed / Edit — field is visible and the actor can modify the value / Override — field is editable and the actor can change values set by other roles (sponsor-only)

**Cross-role overwrite guard.** When a write is allowed at `edit` (or at `override` for a non-sponsor role), the server also checks who last set the value for that applicant, field and step. The write goes through when the field has no prior role owner or was last set by the same role. A value last set by a different role is denied with 403 `FIELD_OVERWRITE_DENIED` (`details.fieldPermission` carries `fieldKey`, `accessLevel`, `roleId` and `priorOwnerRoleId`); internally the decision reason is `CROSS_ROLE_OVERWRITE`. Only a sponsor role that resolves to `override` may overwrite another role's value. For a user with several roles the write is allowed if any writable role passes, and the check runs only when the request resolves an applicant id.
> **Not Hardcoded**
>
> Field-level permissions are configuration-driven, not hardcoded. Adding a new field or changing a permission level is an administrative action — not a code change. This keeps the system adaptable as programs and compliance requirements evolve.

Field permissions are administered through `/api/admin/field-permissions` (list, `usage-count`, create, `PATCH /:id`, `DELETE /:id`), and every route requires `field_permissions.manage`. Create and update run a lockout preview first: a change that would leave no role able to edit the field returns 409 with code `LOCKOUT_WARNING` and a `warning` preview payload, and moving a rule off its old entity, field, step or scope is checked the same way. Resubmit with `force: true` in the body or `?force=true` to apply it anyway. Delete does not run the preview. Create also returns 409 when a rule already exists for the same role, step, field and scope (update does not translate that case).

### Example Permission Matrix

The following illustrates how permissions differ across actors for the same application record:

| Field | Applicant | Partner | Sponsor |
| --- | --- | --- | --- |
| Passport Upload | Edit | View | Edit |
| Work Experience | Edit | Edit | Edit |
| Criminal Background Check | View | Upload | Override |
| Compliance Status | View | View | Override |
| SEVIS ID | No Access | No Access | Edit |
| Interview Notes | No Access | Edit | Edit |
| Placement Decision | View | View | Override |
| Risk Score | No Access | No Access | View |

> **Partners Must Never**
>
> Partners must never change compliance decisions, override rules, unlock restricted workflow phases, or access fields marked as sponsor-only. These constraints are enforced server-side — client-side UI hiding is supplementary, not the enforcement boundary.

### Field-Level Audit

Every field-level change is audit logged with:

Who changed the field (user ID, role, organization) / Previous value and new value / Timestamp (UTC) / IP address and session context / Whether the change was an override of another actor’s value

This audit trail is immutable and available to compliance officers for review. It provides a complete history of every data change across all actors, supporting regulatory requirements and internal governance.

## Settings & Feature Flags

Globus provides a centralized settings management system and a feature flag framework that control system-wide behavior without code deployments. Both are governed by RBAC — only users with the appropriate permissions can view or modify settings and flags.

### System Settings Model

System settings are organized into scoped tiers. Lower-scoped settings override higher-scoped defaults, providing flexibility without duplication.

Writing a setting requires the permission for its tier: `settings.global.manage` (global), `settings.program.manage` (program), `settings.season.manage` (season / operational cycle) or `settings.brand.manage` (brand). All four are restricted and require a reason. The global, program and season keys are System Administrator territory; the brand key is also seeded to Brand Admin, which is not granted the other three.

permission
Setting Categories

> **Settings Resolution Order**
>
> When resolving a setting value, the system walks from the most specific scope (Season) to the most general (Global) and returns the first defined value. If no override exists at any level, the global default applies. All setting changes are audit-logged with the previous value, new value, scope, and the user who made the change.

### Feature Flag Governance

Feature flags control the availability of new capabilities across the system. They follow the same scoped tier model as settings but include additional governance controls for safe rollout.

| Field | Type | Description |
| --- | --- | --- |
| key | VARCHAR | Unique identifier (e.g., feature.renewal_workflow, feature.ai_review) |
| label | VARCHAR | Human-readable name displayed in the admin UI |
| description | TEXT | Explanation of what the flag controls and its impact |
| enabled | BOOLEAN | Current state of the flag at the defined scope |
| scope_type | ENUM | global \| brand \| program \| season |
| scope_id | VARCHAR | ID of the scoped entity (null for global) |
| rollout_percentage | INTEGER | Percentage of entities that see the feature (0–100, for gradual rollout) |
| expires_at | TIMESTAMP | Auto-disable date for time-limited flags |

## BackOfficePrototype RBAC Permission Enforcement — Test Plan

**Scope:** Development environment (`development.globus.allianceabroad.com`) &nbsp;·&nbsp; **Status:** Executed baseline with targeted regression follow-up &nbsp;·&nbsp; **Test type:** Role-based screen, control, and server-gate enforcement &nbsp;·&nbsp; **Audit baseline:** 2026-07-22 &nbsp;·&nbsp; **Total documented test cases:** 81

This section records the complete BackOfficePrototype RBAC audit test suite referenced above (Enforcement, God-Mode, Scope Filtering, Segregation of Duties). It verifies that permission-linked staff screens, client controls, and protected API routes behave correctly under least-privilege identities. The plan is deliberately non-destructive: routes are loaded, controls are inspected, effective permissions are read, and any protected-write probe uses an invalid empty payload so no record can be created, updated, deleted, assigned, published, or otherwise changed.

> **Execution rule:** A client-side denial is useful for user experience, but it is never sufficient proof of authorization. Any exposed write control must be backed by a server-gate check. Conversely, a server 403 paired with an empty or misleading UI state is recorded as a client authorization-experience defect.

### Test Environment and Controlled Identities

| Item | Value / requirement |
|---|---|
| Environment | **Development only**. No QA or production execution is permitted for this plan. |
| Control identity | Authorized system-administrator QA control account; use only to inspect the test account and start/end impersonation. |
| Test identity | `manusqa_0722_readonly@managed.agent` — dedicated QA account, not a real staff identity. |
| Baseline role | **Readonly only**, no role group, no configured scope. |
| Temporary roles used | **Program Manager** and **Brand Admin**, each assigned in isolation and restored immediately after its test pass. |
| Session control | Impersonation must be time-bounded, reason-recorded, visibly bannered, and audit logged. |
| Safety boundary | Do not submit valid mutation payloads. Do not use real staff accounts. Do not change scope, profile, password, or non-test-user data. |
| Postcondition | Restore the dedicated QA account to **Readonly only** and verify its role, role-group, scope, profile, and password are unchanged. |

### Result Semantics

| Result | Meaning |
|---|---|
| **PASS** | The observed decision matched the expected permission boundary. |
| **PASS — LIMITED MODE** | The user received intentionally reduced read/audit access without administrative mutation capability. |
| **PASS SERVER / FINDING UI** | The server enforced correctly, but the UI exposed an unauthorized action or misleading state. |
| **FINDING** | The observed behavior conflicts with the documented/canonical permission boundary and requires remediation. |
| **OPEN** | A regression case is defined but still requires an isolated disposable-role or scoped test setup. |

### Standard Procedure for Every Screen Case

1. Confirm the impersonation banner, active role, and `/api/rbac/my-permissions` result before navigation.
2. Navigate through the in-app client router where possible, preserving the active impersonation context.
3. Verify the page outcome: explicit denial, limited read mode, or authorized read/manage controls.
4. Inspect visible mutation affordances. If an unauthorized write control is present, record it; do not click a control that can mutate data.
5. Where needed, perform a safe invalid-payload API probe with the active CSRF header. The request must be invalid by construction and must not create data.
6. Record route, role, effective grants, visible controls, HTTP status where applicable, and evidence reference.

### Direct Permission-Linked Screen Cases

The following table is the complete direct-route coverage contract generated from the audited source manifest. Each row is a distinct test case.

| ID | Route and screen | Static permission reference(s) | Role(s) | Expected result | Executed audit result | Status |
|---|---|---|---|---|---|---|
| RBAC-UI-001 | `/` — Dashboard | `job_orders.view, programs.manage, seasons.manage` | Readonly | Dashboard is available only as a read-level operational surface; no privileged administration controls are implied. | Dashboard rendered under the Readonly role with its live dashboard grant. | PASS |
| RBAC-UI-002 | `/admin/access-management` — Access Management | `audit_log.view, role_templates.manage, roles.view, users.manage` | Readonly; Program Manager | Allow only redacted access-change history with `audit_log.view`; require role/user grants for administration controls. | Both roles received the constrained audit-history view and no user, role, group, field-permission, or finance-preset mutation controls. | PASS — LIMITED MODE |
| RBAC-UI-003 | `/admin/account-manager-regions` — Account Manager Regions | `partners.manage` | Readonly; Program Manager | Deny page/API access without `partners.view` or `partners.manage`; expose management only with the appropriate partner grant. | The Program Manager UI rendered an empty management surface although the backing API returned 403; Readonly received a read-capable surface. | FINDING RBAC-C10 |
| RBAC-UI-004 | `/admin/brands` — Brands | `brand.legal.manage, system.admin` | Readonly; Program Manager; Brand Admin | Readonly may view only; brand creation/editing must require the documented brand-management authority; Program Manager must not gain it merely through program permissions. | Readonly had an exposed Create control but POST was 403. At audit time Program Manager saw Create Brand and reached validation because routes enforced `programs.manage`; RBAC-C09 is since fixed (POST /api/brands now requires `brands.manage`). | FINDING RBAC-C09 / RBAC-C12 |
| RBAC-UI-005 | `/admin/countries` — Countries | `system_settings.manage` | Readonly; Program Manager | Deny without `system_settings.manage`. | Explicit access denial under both roles. | PASS |
| RBAC-UI-006 | `/admin/program-mappings` — Program Mappings | `compliance.program_mapping.manage` | Readonly; Program Manager | Deny without `compliance.program_mapping.manage`. | Explicit access denial under both roles. | PASS |
| RBAC-UI-007 | `/admin/settings` — System Settings | `feature_flags.read, settings.global.manage` | Readonly; Program Manager | Readonly may see Feature Flags only through `feature_flags.read`; management tabs/actions require settings or system-admin authority. | Readonly saw only the Feature Flags tab with no edit/override controls. Program Manager was denied the administrative settings surface. | PASS |
| RBAC-UI-008 | `/admin/skill-categories` — Skill Categories | `system.admin` | Readonly; Program Manager | Deny without `system.admin`. | Explicit access denial under both roles. | PASS |
| RBAC-UI-009 | `/admin/staff-ratio-review` — Staff / Participant Ratio Review | `notifications.manage` | Readonly; Program Manager | Permit read review where granted; keep acknowledgement actions behind `notifications.manage`. | Readonly received a read surface; Program Manager received the permitted read surface. Source review confirmed the action-level gate. | PASS — ACTION-LEVEL GATE |
| RBAC-UI-010 | `/admin/staff-ratio-thresholds` — Staff / Participant Ratio Thresholds | `staff_ratio.adjust, staff_ratio.rules.manage` | Readonly; Program Manager | Readonly must not create threshold rules; Program Manager with `staff_ratio.adjust` may manage allowed controls. | Readonly saw a New Rule affordance but empty POST was 403. Program Manager was allowed/manage. | PASS SERVER / FINDING RBAC-C12 UI |
| RBAC-UI-011 | `/applicants` — Applicants | `applicant.journey.update, applicant.manage` | Readonly; Program Manager | Readonly may list/view; Program Manager may use authorized applicant-management controls. | Readonly list/view and Program Manager operational screen both rendered without an access denial. | PASS |
| RBAC-UI-012 | `/compliance-console` — Compliance Console | `compliance.console.view, compliance.rules.author` | Readonly; Program Manager | Deny without `compliance.console.view`; allow Program Manager with that grant. | Readonly was denied; Program Manager was allowed. | PASS |
| RBAC-UI-013 | `/compliance-console/cases` — Compliance Cases | `compliance.console.view` | Readonly; Program Manager | Apply the Compliance Console view boundary consistently. | Readonly denied; Program Manager allowed. | PASS |
| RBAC-UI-014 | `/compliance-console/facts` — Facts Browser | `compliance.console.view` | Readonly; Program Manager | Apply the Compliance Console view boundary consistently. | Readonly denied; Program Manager allowed. | PASS |
| RBAC-UI-015 | `/compliance-console/impact` — Policy Impact Preview | `compliance.console.view` | Readonly; Program Manager | Apply the Compliance Console view boundary consistently. | Readonly denied; Program Manager allowed. | PASS |
| RBAC-UI-016 | `/compliance-console/inspector` — Evaluation Inspector | `compliance.console.view` | Readonly; Program Manager | Apply the Compliance Console view boundary consistently. | Readonly denied; Program Manager allowed. | PASS |
| RBAC-UI-017 | `/compliance-console/overrides` — Override Governance | `compliance.console.view` | Readonly; Program Manager | Apply the Compliance Console view boundary consistently. | Readonly denied; Program Manager allowed. | PASS |
| RBAC-UI-018 | `/compliance-console/reason-codes` — Reason Code Administration | `compliance.console.view` | Readonly; Program Manager | Apply the Compliance Console view boundary consistently. | Readonly denied; Program Manager allowed. | PASS |
| RBAC-UI-019 | `/compliance-console/simulator` — Simulation Runner | `compliance.console.view` | Readonly; Program Manager | Apply the Compliance Console view boundary consistently. | Readonly denied; Program Manager allowed. | PASS |
| RBAC-UI-020 | `/compliance/rules` — Compliance Rules / Locks | `compliance.rules.assign, compliance.rules.author` | Readonly; Program Manager | Deny without rule-authoring authority; allow Program Manager with `compliance.rules.author`. | Readonly denied; Program Manager allowed. | PASS |
| RBAC-UI-021 | `/ds-resubmission` — DS Resubmission Queue | `sevis.ds_resubmission.view` | Readonly; Program Manager | Deny when `sevis.ds_resubmission.view` is absent. | Explicit access denial under both roles. | PASS |
| RBAC-UI-022 | `/executive-overview` — Executive Overview | `executive_overview.view, reporting.view` | Readonly; Program Manager | Allow view surface with executive/reporting grants. | Rendered for both roles under their live reporting/executive grants. | PASS |
| RBAC-UI-023 | `/finance` — Finance | `fees.view` | Readonly; Program Manager | Allow view surface with `fees.view`; do not imply finance mutation authority. | Rendered for both roles as a permitted view surface. | PASS |
| RBAC-UI-024 | `/form-templates` — Form Templates | `system.admin` | Readonly; Program Manager | Deny without `system.admin`. | Explicit access denial under both roles. | PASS |
| RBAC-UI-025 | `/guide` — Globus Guide | `system.admin` | Readonly; Program Manager | Allow the in-app documentation read surface; restrict any internal management action separately. | Help/documentation hub loaded for both roles after stabilization; no page-access defect. | PASS — ACTION-LEVEL REFERENCE |
| RBAC-UI-026 | `/housing` — Housing | `job_orders.create, job_orders.manage` | Readonly; Program Manager | Readonly may see the list where read grants allow it; create/manage controls require job-order authority. | Readonly saw a list/filtering surface with no create/action control; Program Manager was allowed operational access. | PASS |
| RBAC-UI-027 | `/matching` — Applicant Matching | `applications.manage` | Readonly; Program Manager | Readonly may see the read/list surface; matching actions require the relevant manage grant. | Readonly saw no mutation controls; Program Manager was allowed the operational screen. | PASS |
| RBAC-UI-028 | `/program-rules` — Program Rules | `compliance.rules.author, programs.manage` | Readonly; Program Manager | Deny without author/manage authority; allow Program Manager authoring. | Readonly denied; Program Manager allowed. | PASS |
| RBAC-UI-029 | `/programs` — Programs | `compliance.rules.author, programs.manage` | Readonly; Program Manager | Readonly list may be visible but must not expose edit capability; Program Manager may create/manage programs and seasons. | Readonly saw unguarded Edit Program row action, but direct edit returned Program not found without data or save controls. Program Manager had Create Program and actions. | PASS SERVER / FINDING RBAC-C11 UI |
| RBAC-UI-030 | `/reference-templates` — Reference Templates | `system.admin` | Readonly; Program Manager | Deny without template/system authority. | Explicit access denial under both roles. | PASS |
| RBAC-UI-031 | `/reporting` — Reporting | `compliance.view, reporting.view` | Readonly; Program Manager | Allow reporting read surface with reporting/compliance view grants. | Rendered for both roles. | PASS |
| RBAC-UI-032 | `/reporting/compliance` — Compliance Reporting | `compliance.view` | Readonly; Program Manager | Allow compliance reporting with `compliance.view`. | Rendered for both roles. | PASS |
| RBAC-UI-033 | `/reporting/step-analytics` — Step Analytics | `reporting.view` | Readonly; Program Manager | Allow analytics view with `reporting.view`. | No access denial under Readonly; Program Manager rendered the reporting surface. | PASS |
| RBAC-UI-034 | `/role-groups` — Role Groups | `system.admin` | Readonly; Program Manager | Deny without `system.admin`. | Explicit access denial under both roles. | PASS |
| RBAC-UI-035 | `/rule-studio` — Rule Studio | `compliance.rules.author, programs.manage` | Readonly; Program Manager | Deny without rule author/manage authority; allow Program Manager authoring. | Readonly denied; Program Manager allowed. | PASS |
| RBAC-UI-036 | `/rules/bundles` — Regulatory Rule Bundles | `compliance.bundles.manage, compliance.console.view, compliance.rules.assign` | Readonly; Program Manager | Deny without bundle/console/assignment authority; allow Program Manager bundle management. | Readonly denied; Program Manager allowed with New Regulatory Bundle. | PASS |
| RBAC-UI-037 | `/settings` — Personal Settings | `system.admin` | Readonly; Program Manager | Allow the actor's own personal settings only; do not treat this as platform-settings authority. | Personal settings route was accessible and did not expose administrative controls. | PASS — SELF-SERVICE |
| RBAC-UI-038 | `/sevis-batches` — SEVIS Batches | `sevis.batch.create` | Readonly; Program Manager | Readonly may view results only if granted; batch creation requires `sevis.batch.create`. | Readonly saw result list under `sevis.batch.view_results`; Program Manager was correctly denied batch creation/management. | PASS |
| RBAC-UI-039 | `/sevis-pool` — SEVIS Ready Pool | `sevis.batch.create, system.admin` | Readonly; Program Manager | Readonly may view ready-pool data only if granted; management requires batch/system authority. | Readonly saw data under `sevis.ready_pool.view`; Program Manager was correctly denied management. | PASS |
| RBAC-UI-040 | `/swt/academic-verification-queue` — SWT Academic Verification Queue | `compliance.overrides.review` | Readonly; Program Manager | Deny without compliance override-review authority; allow Program Manager with it. | Readonly denied; Program Manager allowed. | PASS |
| RBAC-UI-041 | `/swt/date-windows` — SWT Date Windows | `programs.manage` | Readonly; Program Manager | Deny without `programs.manage`; allow Program Manager. | Readonly denied; Program Manager allowed. | PASS |
| RBAC-UI-042 | `/swt/enrollment-proof-queue` — SWT Enrollment Proof Queue | `compliance.overrides.review` | Readonly; Program Manager | Deny without compliance override-review authority; allow Program Manager with it. | Readonly denied; Program Manager allowed. | PASS |
| RBAC-UI-043 | `/swt/vacation-date-review` — SWT Vacation Date Review | `compliance.overrides.review` | Readonly; Program Manager | Deny without compliance override-review authority; allow Program Manager with it. | Readonly denied; Program Manager allowed. | PASS |
| RBAC-UI-044 | `/users` — User Management | `system.admin` | Readonly; Program Manager; Brand Admin | Deny without `system.admin`; the page must not expose user-management data or controls. | Explicit access denial under all three least-privilege roles. | PASS |
| RBAC-UI-045 | `/workflow-studio` — Workflow Studio | `operational_forms.view, programs.manage, screening.author` | Readonly; Program Manager | Allow view with `operational_forms.view`; author/publish controls require corresponding grants. | Readonly received view surface; Program Manager was allowed/manage. | PASS |
| RBAC-UI-046 | `/workflow-studio/builder` — Workflow Builder | `system.admin` | Readonly; Program Manager | Permit program-selection/read context where designed; restrict publishing/system-admin actions separately. | Both roles could reach the program-selection context. Source review identified `system.admin` as action-level, not page-level. | PASS — CONTEXT/ACTION GATE |
| RBAC-UI-047 | `/workflow-studio/operational-forms` — Operational Forms Studio | `operational_forms.author, operational_forms.publish, operational_forms.view` | Readonly; Program Manager | Allow view with `operational_forms.view`; restrict author/publish controls to grants. | Readonly opened the Interview Sheets view surface; Program Manager had allowed/manage access. | PASS |
| RBAC-UI-048 | `/workflow-studio/screening` — Screening Studio | `operational_forms.view, screening.author, screening.create_custom_type, screening.publish_blueprint, system.admin` | Readonly; Program Manager | Require valid program context before authoring; do not expose authoring without grants. | No access denial, but both sessions required a program selection and did not expose unauthorized content. Classified as a context prerequisite, not a bypass. | PASS — CONTEXT PREREQUISITE |

### Supplemental In-App and Embedded-Screen Cases

These screens were tested through the active in-app session in addition to the direct-route manifest. They cover operational routes, authoring sub-surfaces, and sidebar destinations discovered during the execution sweep.

| ID | Route / screen | Role(s) | Expected result | Executed audit result | Status |
|---|---|---|---|---|---|
| RBAC-SUP-001 | `/admin/stage-management` | Program Manager | Program lifecycle controls are available with program-management authority. | Allowed/manage; Add Stage and lifecycle controls visible. | PASS |
| RBAC-SUP-002 | `/operational-cycles` | Program Manager | Operational-cycle inventory and permitted management controls are available. | Allowed/manage. | PASS |
| RBAC-SUP-003 | `/admin/agreement-library` | Readonly; Program Manager | Readonly is view-only; Program Manager may manage agreements. | Readonly view and Program Manager New Agreement surface observed. | PASS |
| RBAC-SUP-004 | `/admin/agreement-assignments` | Readonly; Program Manager | Readonly is view-only; Program Manager may manage assignments. | Readonly view and Program Manager New Portal Sign-in Assignment observed. | PASS |
| RBAC-SUP-005 | `/admin/agreement-acceptances` | Program Manager | Acceptance/signature audit remains read/audit surface. | Allowed/view with View actions only. | PASS |
| RBAC-SUP-006 | `/workflow-studio/steps` | Program Manager | Step Library authoring controls are available with the role grant. | New Agreement Step and New Step visible. | PASS |
| RBAC-SUP-007 | `/workflow-studio/ai-capabilities` | Program Manager | Authorized log/view surface is available without unrelated administration. | Allowed/view. | PASS |
| RBAC-SUP-008 | `/rules` | Program Manager | Rule Library is available with rule-authoring authority. | Allowed. | PASS |
| RBAC-SUP-009 | `/rules/new` | Program Manager | Rule author can reach the new-rule form and Save control. | Allowed/manage; Save visible. | PASS |
| RBAC-SUP-010 | `/rules/templates` | Program Manager | Rule templates are visible to the authorized rule author. | Allowed. | PASS |
| RBAC-SUP-011 | `/rules/effective` | Program Manager | Effective-rule read surface is available to the authorized role. | Allowed/view. | PASS |
| RBAC-SUP-012 | `/admin/queue-management` | Program Manager | Queue administration is available only under queue-management authority. | Allowed. | PASS |
| RBAC-SUP-013 | `/admin/sla-policies` | Program Manager | SLA-policy controls are available only under policy authority. | Allowed/create surface. | PASS |
| RBAC-SUP-014 | `/notifications` | Program Manager | Internal notification surface honors notification grants. | Allowed. | PASS |
| RBAC-SUP-015 | `/notifications-external` | Program Manager | External notification creation honors the active send grant. | Allowed; Create Notification visible. | PASS |
| RBAC-SUP-016 | `/review-hub` | Program Manager | Document-review workspace is available within program operations. | Allowed. | PASS |
| RBAC-SUP-017 | `/command-center` | Program Manager | Operational command-center view is available within program operations. | Allowed. | PASS |
| RBAC-SUP-018 | `/staff/placements` | Program Manager | Placement operations surface is available within the role grant. | Allowed. | PASS |
| RBAC-SUP-019 | `/sevis-checkin-management` | Program Manager | Permitted SEVIS check-in compliance surface is available. | Allowed. | PASS |
| RBAC-SUP-020 | `/checkin-review` | Program Manager | Permitted check-in review surface is available. | Allowed. | PASS |
| RBAC-SUP-021 | `/travel-requests` | Program Manager | Travel-request review surface is available where the role holds travel authority. | Allowed. | PASS |
| RBAC-SUP-022 | `/admin/programs` | Brand Admin | Brand Admin route confirms the seeded role is live; results must not be used to isolate `brands.manage` because it also carries `programs.manage`. | Allowed with season-creation controls. | PASS — NON-ISOLATING CONTROL |

### API, Regression, and Security Cases

These cases verify that server gates enforce authorization independently of UI visibility and capture the confirmed audit findings as repeatable regression tests.

| ID | Test case | Procedure | Expected result | Executed audit result / baseline | Status |
|---|---|---|---|---|---|
| RBAC-API-001 | Readonly Brands mutation denial | As Readonly, send an empty invalid `POST /api/brands` payload with the active CSRF header; do not send a valid payload. | 403 Forbidden before validation or persistence. | Observed 403 Permission denied; no record changed. | PASS |
| RBAC-API-002 | Readonly staff-ratio rule mutation denial | As Readonly, send an empty invalid `POST /api/staff-ratio/rules` payload with the active CSRF header; do not send a valid payload. | 403 Forbidden before validation or persistence. | Observed 403 Permission denied; no record changed. | PASS |
| RBAC-REG-001 | Brands canonical-permission regression | Create a disposable custom role with `programs.manage` but without `brands.manage`, then test Brands list/create and `POST /api/brands` using invalid payload only. Repeat with only `brands.manage`. | The chosen canonical permission policy must be enforced consistently in UI and API; no unrelated permission may grant Brands CRUD. | Audit found Program Manager reached Brands creation via `programs.manage` while `brands.manage=false`. | FIXED IN CODE — RBAC-C09 (regression test still to run) |
| RBAC-REG-002 | Account Manager Regions 403 rendering | As a role without `partners.view`/`partners.manage`, load `/admin/account-manager-regions` and observe both page and GET API response. | Page renders explicit access-denied state when API returns 403; it must not show an empty management surface. | Audit observed server 403 with an empty client management page. | OPEN — RBAC-C10 |
| RBAC-REG-003 | Programs edit affordance | As Readonly, open Programs list and inspect every row action; follow any Edit Program action without submitting changes. | Edit action is absent/disabled when no edit authority exists; direct edit route renders a clear denial rather than not-found. | Audit observed exposed Edit Program action; direct route settled to Program not found without editable data. | OPEN — RBAC-C11 |
| RBAC-REG-004 | Readonly write-control visibility | As Readonly, inspect Brands and Staff Ratio Thresholds creation controls before attempting any API probe. | Create/New Rule controls are hidden or disabled with an explanatory read-only state. | Audit observed active controls; server gates correctly denied writes. | OPEN — RBAC-C12 |
| RBAC-REG-005 | Operational-cycle alias consistency | Use a disposable custom role containing only `operational_cycles.manage`, then compare client navigation/control visibility and server API decision. | Client and server recognize the same canonical key/alias behavior. | Static audit identified potential UI/server alias divergence. | OPEN — RBAC-C03 |
| RBAC-REG-006 | Brand-scope client contract | Use two scoped disposable test identities or controlled scopes spanning different brands; test an in-scope and out-of-scope client action plus backing API. | Client uses `brandIds`/`allBrands` consistently and never offers an out-of-scope mutation that server rejects. | Static audit found brand fields absent from client scope evaluation. | OPEN — RBAC-C04 |
| RBAC-REG-007 | Multi-role/group effective access | Create a disposable group with two non-conflicting roles and compare my-permissions, effective-permission source view, and protected route decisions. | All representations resolve the same additive permission set, still clamped by scope. | Not run: audit deliberately preserved a single-role test account. | OPEN — RBAC-C06 |
| RBAC-SEC-001 | Impersonation guard | Start a time-bounded impersonation using a dedicated QA account, with a recorded reason; validate banner and audit attribution, then end session. | Target, duration, reason, and audit trail are required; session is visibly bounded. | Audit-time observation: a 30-minute audited workflow. Impersonation has since been retired (Task #2028), so this case no longer applies. | RETIRED |
| RBAC-SEC-002 | Test-account restoration | After every temporary role test, restore the dedicated account to its original Readonly-only role and re-verify user list, role group, scope, profile, and password remain unchanged. | No residual privilege or changed identity data remains. | Readonly restored; no role group, scope, password, profile, or non-test user changed. | PASS |

### Acceptance Criteria

The plan passes only when the following conditions hold:

| Area | Acceptance criterion |
|---|---|
| Screen access | Every direct screen case has an explicit allow, deny, limited-mode, or context-prerequisite outcome matched to an effective permission decision. |
| Client controls | A role lacking a mutation permission never receives an active creation/edit/delete/publish control. If a read surface is intentional, it is labeled read-only. |
| Server gates | Every protected mutation endpoint rejects a role missing the required permission before validation or persistence. |
| Permission semantics | Canonical permission keys used in the registry, seed metadata, client guards, and server middleware represent the same authority boundary. |
| Scope | Brand, program, and operational-cycle scope are evaluated consistently in UI and API; an out-of-scope control is never offered. |
| Error handling | API 403 decisions render an explicit access-denied state, not an empty state or a false 404. |
| Test hygiene | All temporary roles are removed and the dedicated QA account is restored to Readonly-only state. |

### Required Remediation Gates

The following issues must be closed or formally accepted before the RBAC audit can be considered fully remediated.

| Finding | Gate |
|---|---|
| **RBAC-C09 — Brands permission drift** | Choose and implement a single canonical authority (`brands.manage` or an explicitly documented replacement); test a custom role containing only that authority and a negative role containing only the alternative. |
| **RBAC-C10 — Account Manager Regions 403 handling** | Add client permission guard/error rendering; prove a role without partner access sees an explicit denial. |
| **RBAC-C11 — Programs edit action** | Hide/disable Edit Program without edit authority; direct route must return an authorization-specific UI state. |
| **RBAC-C12 — Readonly write affordances** | Remove or disable Create Brand and New Rule controls for Readonly; retain server-gate regression tests. |
| **RBAC-C03 / C04 / C06** | Complete custom-role alias, brand-scope, and multi-role/group test cases using disposable test fixtures. |

### Evidence Package

The following artefacts contain the source manifest, complete live evidence, and code-level analysis supporting this plan.

| Artefact | Purpose |
|---|---|
| `BackOfficePrototype_RBAC_Audit_2026-07-22.md` | Executive audit report, taxonomy, findings, and priorities. |
| `development_session_baseline.md` | Chronological role, route, control, API, and restoration evidence. |
| `static_permission_route_manifest.json` | Complete direct-route test contract used for `RBAC-UI-001` through `RBAC-UI-048`. |
| `permission_route_matrix.csv` | Page-to-permission-to-route mapping, including dynamic/embedded screens. |
| `rbac_permission_catalogue.csv` | Canonical permission metadata and category/flag references. |
| `code_level_findings.md` | Source-backed findings and regression rationale. |

**Completion state of the executed baseline:** The dedicated QA account was restored to **Readonly only** after the Readonly, Program Manager, and Brand Admin test passes. No real staff user or business record was modified.
