diff --git a/openspec/changes/migrate-role-routing-to-or-rbac/design.md b/openspec/changes/migrate-role-routing-to-or-rbac/design.md new file mode 100644 index 00000000..2a14a12a --- /dev/null +++ b/openspec/changes/migrate-role-routing-to-or-rbac/design.md @@ -0,0 +1,159 @@ +# Design: migrate-role-routing-to-or-rbac + +## Context + +Procest's current step routing flow (client-side, display-only): + +``` +User opens case detail / task list + → workflow.js fetchAvailableTransitions() + → loads all workflowStep / workflowTemplate objects (no server-side filter) + → client resolves user's roles: case.roles[] → roleType UUID → roleType.name + → filters transitions.allowedRoles contains user's roleType UUID + → filters steps.assigneeRole === user's roleType UUID + → result: filtered list displayed in UI +``` + +After migration, the flow MUST include server-side enforcement: + +``` +User requests step / transition data + → ObjectService::getObjects(register, 'workflowStep', filters) + → MagicRbacHandler::applyRbacFilters(queryBuilder, userId, groups) + → evaluates schema.authorization block against user's NC group memberships + → returns only steps the user is authorized to read + → client-side display filtering may still run for UX purposes + but server already guarantees the result set is authorization-correct +``` + +## The roleType → NC Group ID Bridge + +OR's RBAC evaluates Nextcloud group IDs. Procest's workflow definitions reference OR +`roleType` UUIDs. The bridge is the `ncGroupId` property on `roleType`: + +```json +// roleType object in OR (procest register) +{ + "name": "Vergunningverlener", + "caseType": "uuid-of-omgevingsvergunning", + "genericRole": "handler", + "ncGroupId": "vergunningverleners" // ← new required property +} +``` + +The `ncGroupId` is set by the procest admin when configuring role types in the admin settings +panel. It maps a domain-level role ("Vergunningverlener") to the NC group that holds that role +in the organization ("vergunningverleners"). One-to-one mapping is the common case; a roleType +with `ncGroupId: null` means "unassigned to a group" and is treated as accessible to all +authenticated users (matching the existing behaviour for roles with no assigneeRole configured). + +## File-by-File Migration Plan + +### lib/Settings/procest_register.json — ADD ncGroupId to roleType, ADD authorization to step schemas + +**roleType schema** — add `ncGroupId` property: + +```json +"ncGroupId": { + "type": "string", + "nullable": true, + "description": "Nextcloud group ID that holds this role. Used by OR RBAC to enforce step-level access. Must be a valid NC group ID (IGroupManager::groupExists returns true). Null = role not yet mapped to a group." +} +``` + +**workflowStep schema** — add an `authorization` block referencing the resolved group. The +authorization block is dynamic — it references a named role defined at the register level +that OR expands at query time: + +```json +"x-authorization": { + "read": [{ "role": "step.assigneeGroup" }], + "update": [{ "role": "step.assigneeGroup" }], + "delete": [{ "role": "admin" }] +} +``` + +The named role `step.assigneeGroup` is resolved by OR's RBAC from the step's +`assigneeRole` → `roleType.ncGroupId` chain. If `ncGroupId` is null (role not yet mapped), +OR RBAC falls back to "accessible to all authenticated users in the register." + +Note: if OR's register-level named role expansion is not yet implemented in `rbac-scopes`, +an interim approach is to add the `authorization` block as a static list of group IDs +populated when a workflow is published. The design.md will track which approach is used +during the apply phase. + +### lib/Settings/procest_register.json — procest_register authorization fallback + +As an interim that works with the current `rbac-scopes` implementation: + +Add a static `authorization` block to the `workflowStep` schema with a fallback group (e.g. +all users in the procest tenant group). This establishes the OR RBAC path. The dynamic +group-per-step expansion becomes a follow-on OR feature when `rbac-scopes` supports +register-level named role definitions. + +### lib/Listener/KpiCacheInvalidationListener.php — VERIFY ONLY (no code change expected) + +**Current**: listens on `ObjectCreatedEvent`, `ObjectUpdatedEvent`, `ObjectDeletedEvent`. +Calls KPI cache invalidation logic. + +**Compliance check:** + +1. Does the listener body call `$groupManager->isInGroup()` or any access decision? → Expected: NO. +2. Does the listener call `$objectService->getObject()` or similar OR reads on the event's + object before deciding to act? → If yes: verify these calls respect OR RBAC (i.e. the + listener runs as a system user with read access to the relevant register, not as the + triggering user). +3. Does the listener emit to any parallel audit store? → Expected: NO (covered by + `consume-or-audit-trail-fleet-wide`). + +If all three checks pass: document compliance in spec. If any fails: file a corrective task. + +### src/views/settings/components/StepConfigPanel.vue — ADD ncGroupId display + +The admin settings panel for step configuration (`StepConfigPanel.vue`) shows step properties. +Add a read-only display field "NC Group" that shows the `ncGroupId` of the step's resolved +roleType. No edit in this panel — group mapping is configured on the roleType object's own +settings page. + +### openspec/specs/role-based-step-routing/spec.md — NO CHANGES + +The existing spec body is not modified. This migration changes the enforcement mechanism; +it does not change the observable requirements. The spec's scenarios remain valid as-is +(Vergunningverlener sees their steps; Behandelaar does not). + +## Backwards Compatibility + +- The `assigneeRole` and `allowedRoles` UUID references in stored workflow definitions are + NOT changed. They remain roleType UUIDs for import/export portability. +- The client-side role filter in workflow.js continues to run for UX purposes (instant filter + without a round-trip). OR RBAC is the enforcement layer; client filtering is convenience. +- roleTypes that do not yet have an `ncGroupId` set behave as before: accessible to all + authenticated users on the case (no group restriction applied by OR RBAC). + +## OR RBAC Authorization Block Format Reference + +From `openregister/openspec/specs/rbac-scopes/spec.md`: + +The `authorization` JSON block in a schema definition follows the four-level hierarchy: +register > schema > object > property. Schema-level scopes control CRUD per schema. +Object-level scopes use `match` conditions for row-level refinement. + +Group IDs in the block are evaluated by `PermissionHandler::hasGroupPermission()` which calls +`OCP\IGroupManager::isInGroup($userId, $groupId)` — the single trusted NC group membership +check. + +## Seed Data + +No new register definitions are added. Changes to `procest_register.json`: +- `roleType` schema: adds `ncGroupId` (nullable string, no migration needed — existing rows + simply have the field absent/null) +- `workflowStep` schema: adds `authorization` block (applied to new queries only; existing + data rows unaffected until an admin configures the `ncGroupId` mappings) + +## Related ADRs + +- **ADR-022** (primary) — mandate for this migration; "app-local RBAC on OR objects" anti-pattern. +- **ADR-023** — action RBAC vs data RBAC boundary; step routing computation (app-side) vs + step access enforcement (OR-side). +- **ADR-005** (security) — per-object authorization; all data fetches through OR's ObjectService. +- **ADR-008** (testing) — PHPUnit + integration tests required for the new enforcement path. diff --git a/openspec/changes/migrate-role-routing-to-or-rbac/proposal.md b/openspec/changes/migrate-role-routing-to-or-rbac/proposal.md new file mode 100644 index 00000000..efacc0ff --- /dev/null +++ b/openspec/changes/migrate-role-routing-to-or-rbac/proposal.md @@ -0,0 +1,94 @@ +# Proposal: migrate-role-routing-to-or-rbac + +## Why + +ADR-022 (Apps Consume OpenRegister Abstractions) explicitly prohibits "app-local RBAC on OR +objects — an app defining its own role/permission scheme for objects that live in OR's register." + +Procest's role-based step routing currently has the correct consumer contract for computing +which group should handle a step (the existing `role-based-step-routing` spec), but the +access enforcement layer is missing: + +- **`assigneeRole` and `allowedRoles` in workflow step / transition objects** carry OR + `roleType` UUIDs. When the frontend (workflow.js) filters transitions and tasks, it does + so entirely client-side by comparing the user's resolved roles against these UUIDs. No + OR-side `authorization` block on the step schema gates server-side access. +- **`KpiCacheInvalidationListener`** listens on `ObjectCreatedEvent` / `ObjectUpdatedEvent` / + `ObjectDeletedEvent` and correctly invalidates caches. This listener is compliant; it makes + no access decisions. It is documented here only for completeness. +- **No parallel permission table or service exists** — the violation is the absence of OR + RBAC enforcement, not the presence of a parallel implementation. Client-side role filtering + is display-only and provides no security boundary. + +The umbrella spec `consume-or-rbac-fleet-wide` (hydra) mandates that role-based access on +OR-owned objects must be enforced by OR's RBAC stack (rbac-scopes + auth-system), using +Nextcloud group IDs as the canonical role identifier. + +## What + +Bring procest's step routing into full OR RBAC compliance: + +1. **Resolve roleType UUIDs to NC group IDs** at enforcement time. Each `roleType` OR object + SHALL carry a `ncGroupId` property (a Nextcloud group ID). The routing service reads this + field and builds the NC group ID that OR's RBAC uses for enforcement. +2. **Add OR `authorization` blocks to the `workflowStep` and `workflowTemplate` schemas** in + `procest_register.json` so that OR's `MagicRbacHandler` filters step objects at the + database level based on the requesting user's group memberships. +3. **Verify `KpiCacheInvalidationListener`** listens only on OR's published object events and + makes no access decisions. Document compliance in this spec. No code change expected. +4. **Preserve the consumer contract**: the `role-based-step-routing` spec body is NOT modified. + The enforcement mechanism changes; the observable behaviour (a Vergunningverlener sees + their steps; a Behandelaar does not) is preserved or improved (now enforced server-side). +5. **Tests**: verify that step objects filtered by OR's RBAC at the API level match the set + previously produced by client-side role filtering. + +## Capabilities + +### New Capabilities + +- `role-routing-via-or-rbac`: Step and transition routing decisions are enforced server-side + via OR's RBAC stack. `GET /api/objects/{register}/workflowStep` returns only steps the + requesting user is authorized to access per the schema's `authorization` block. + +### Modified Capabilities + +- `role-based-step-routing` (existing spec) — no body changes. The underlying enforcement + mechanism changes from client-side filtering to OR server-side RBAC, but all observable + requirements remain valid. + +## Affected Projects + +- [x] Project: `procest` — all implementation work is in this repo +- Reference: `hydra/openspec/changes/consume-or-rbac-fleet-wide/` (umbrella policy) +- Reference: `openregister/openspec/specs/rbac-scopes/spec.md` (OR RBAC contract) +- Reference: `openregister/openspec/specs/auth-system/spec.md` (OR auth contract) + +## Scope + +### In Scope + +- Adding `ncGroupId` property to the `roleType` schema in `procest_register.json` +- Adding `authorization` blocks to `workflowStep` and `workflowTemplate` schemas +- Verifying `KpiCacheInvalidationListener` compliance and documenting it +- Tests verifying that OR RBAC correctly restricts step access by NC group membership +- Admin UI: surfacing `ncGroupId` as a configurable field on roleType objects in admin settings + +### Out of Scope + +- Modifying the `role-based-step-routing` spec body (constraint from umbrella) +- Converting stored `assigneeRole` / `allowedRoles` UUID values in existing workflow + definitions (those remain UUID references; only the enforcement layer changes) +- Procest's parafering role-gating — addressed by `consume-or-approval-workflow-fleet-wide` +- Procest's `roles-decisions` domain (role assignment as participation record) — this is + correct OR consumer usage, not a parallel RBAC scheme +- Modifying OR's `rbac-scopes` or `auth-system` specs + +## Success Criteria + +- `openspec validate --strict migrate-role-routing-to-or-rbac` exits 0. +- `roleType` schema in `procest_register.json` includes an `ncGroupId` property. +- `workflowStep` schema includes an `authorization` block referencing NC group IDs resolved + from the step's `assigneeRole` `roleType` object. +- `GET /api/objects/{register}/workflowStep` returns HTTP 403 / empty list for a user whose + NC group is not in the step's resolved authorization set. +- `composer check:strict` passes. diff --git a/openspec/changes/migrate-role-routing-to-or-rbac/specs/role-routing-via-or-rbac/spec.md b/openspec/changes/migrate-role-routing-to-or-rbac/specs/role-routing-via-or-rbac/spec.md new file mode 100644 index 00000000..2d0a0af0 --- /dev/null +++ b/openspec/changes/migrate-role-routing-to-or-rbac/specs/role-routing-via-or-rbac/spec.md @@ -0,0 +1,152 @@ +# role-routing-via-or-rbac Specification + +--- +status: proposed +--- + +## Purpose + +Bring procest's role-based step routing into full compliance with OR RBAC (rbac-scopes + +auth-system). Step routing MUST produce Nextcloud group IDs that OR's RBAC stack enforces +at the data layer. The computation "which group handles this step" remains app-side; the +enforcement "only that group may access this object" MUST be delegated to OR. References +`consume-or-rbac-fleet-wide` umbrella and ADR-022 / ADR-023. + +## ADDED Requirements + +### Requirement: Step Routing Returns Nextcloud Group IDs + +The step routing resolution chain (`assigneeRole` → `roleType` → enforcement group) SHALL +terminate in a Nextcloud group ID. The `roleType` OR object MUST carry an `ncGroupId` string +property. When `ncGroupId` is set, OR's RBAC uses it as the group identifier for access +enforcement on that step's objects. When `ncGroupId` is null, access defaults to all +authenticated users on the case (open step — no group restriction). + +#### Scenario: Step with assigneeRole resolves to NC group ID + +- GIVEN a `workflowStep` with `assigneeRole` set to roleType UUID `rt-vergunningverlener` +- AND roleType `rt-vergunningverlener` has `ncGroupId: "vergunningverleners"` +- WHEN the routing service resolves the step's assignee group +- THEN the service SHALL return `"vergunningverleners"` (an NC group ID) +- AND `IGroupManager::groupExists("vergunningverleners")` SHALL return true + +#### Scenario: Step with null ncGroupId is accessible to all authenticated users + +- GIVEN a `workflowStep` with `assigneeRole` set to roleType UUID `rt-unconfigured` +- AND roleType `rt-unconfigured` has `ncGroupId: null` +- WHEN OR evaluates access on the step +- THEN the step SHALL be accessible to all authenticated users in the procest register +- AND the step SHALL NOT be restricted to any specific NC group + +--- + +### Requirement: Access Enforcement on Routed Steps Uses OR's RBAC API + +Access decisions on `workflowStep` objects stored in OR MUST be enforced by OR's +`MagicRbacHandler` via the schema's `authorization` block. The procest app SHALL NOT +implement a parallel group-membership check to gate access to these objects. + +#### Scenario: MagicRbacHandler excludes steps whose assigneeRole group the user does not belong to + +- GIVEN a `workflowStep` schema has an `authorization` block referencing group `behandelaars` +- AND user "jan" is NOT in group `behandelaars` +- AND user "piet" IS in group `behandelaars` +- WHEN "jan" calls `GET /api/objects/{register}/workflowStep` +- THEN the step SHALL NOT appear in jan's result set (filtered by MagicRbacHandler) +- WHEN "piet" calls the same endpoint +- THEN the step SHALL appear in piet's result set + +#### Scenario: No parallel isInGroup call in the step controller + +- GIVEN the procest controller or service method that fetches step objects +- WHEN the method runs +- THEN the method body SHALL NOT call `$groupManager->isInGroup()` for data-layer gating +- AND the access filtering SHALL be performed exclusively by OR's MagicRbacHandler + +--- + +### Requirement: roleType Schema Carries ncGroupId Property + +The `roleType` schema in `procest_register.json` MUST include an `ncGroupId` nullable string +property. Admin users MUST be able to set `ncGroupId` on each roleType via the procest admin +settings UI. + +#### Scenario: Admin sets ncGroupId on a roleType + +- GIVEN the admin is editing roleType "Vergunningverlener" in procest admin settings +- WHEN the admin sets `ncGroupId` to `"vergunningverleners"` and saves +- THEN the roleType OR object SHALL be updated with `ncGroupId: "vergunningverleners"` +- AND the step configuration panel SHALL display "NC Group: vergunningverleners" for steps + using this roleType + +#### Scenario: Validation rejects a non-existent NC group ID + +- GIVEN an admin sets `ncGroupId` to `"group-that-does-not-exist"` on a roleType +- WHEN the update is submitted +- THEN the system SHOULD warn that the group does not exist in Nextcloud + (soft validation — OR RBAC silently excludes everyone if the group is missing) +- AND the admin SHOULD be able to save anyway (to allow pre-creating the group later) + +--- + +### Requirement: KpiCacheInvalidationListener Does Not Make Access Decisions + +`KpiCacheInvalidationListener` SHALL listen only on OR's published event classes and MUST NOT perform any access-control check on the triggering object. It MUST NOT emit to any parallel audit or permission store. + +#### Scenario: Listener fires on any OR object mutation without group check + +- GIVEN `KpiCacheInvalidationListener` is registered on `ObjectUpdatedEvent` +- WHEN any OR object in the procest register is updated (by any user) +- THEN the listener SHALL invalidate the relevant KPI cache entries +- AND the listener body SHALL NOT call `$groupManager->isInGroup()` or any equivalent check + +#### Scenario: Listener uses only OR event classes + +- GIVEN `KpiCacheInvalidationListener.php` is inspected +- THEN all `use` statements for event classes SHALL point to `OCA\OpenRegister\Event\*` +- AND no procest-local event classes SHALL be used as listener triggers + +--- + +### Requirement: No Parallel Permission Tables in Procest for OR-Owned Objects + +Procest MUST NOT define database tables or OR schemas whose primary purpose is to store +access permissions for OR-owned objects. Access configuration belongs in the schema's +`authorization` block. + +#### Scenario: No permission table exists after migration + +- GIVEN the procest application is installed +- WHEN the database schema is inspected +- THEN no table named `*_perm*` or `*_role_access*` or similar SHALL exist in the procest + app's set of managed tables +- AND no procest OR schema named `*Permission*` or `*AccessRule*` SHALL exist in the procest + register + +--- + +### Requirement: Test Contract — Routed Step Access Exercises OR RBAC End-to-End + +A test MUST verify that a routed step's access decision is made by OR's RBAC API, not by +client-side filtering alone. The test MUST call the OR API directly (bypassing any frontend +filtering) and assert the correct access control outcome. + +#### Scenario: Integration test verifies step is filtered by OR RBAC + +- GIVEN a `workflowStep` with `assigneeRole` pointing to roleType with `ncGroupId: "vergunningverleners"` +- AND user "jan" is NOT in group `vergunningverleners` +- AND user "piet" IS in group `vergunningverleners` +- WHEN a test calls `GET /api/objects/{register}/workflowStep` authenticated as "jan" +- THEN the response SHALL NOT include the step +- WHEN the same test calls the endpoint authenticated as "piet" +- THEN the response SHALL include the step +- AND the test SHALL NOT use client-side filtering to produce this result + +#### Scenario: Step routing computation still produces correct task list for UI + +- GIVEN the above setup +- WHEN "piet" opens their task list in the procest frontend +- THEN the task for the step SHALL appear in piet's list (server-side RBAC allows it) +- WHEN "jan" opens their task list +- THEN the task SHALL NOT appear in jan's list (server-side RBAC already excludes it; + client-side filter is now redundant but harmless) diff --git a/openspec/changes/migrate-role-routing-to-or-rbac/tasks.md b/openspec/changes/migrate-role-routing-to-or-rbac/tasks.md new file mode 100644 index 00000000..1246dc03 --- /dev/null +++ b/openspec/changes/migrate-role-routing-to-or-rbac/tasks.md @@ -0,0 +1,135 @@ +# Tasks: migrate-role-routing-to-or-rbac + +All tasks are `[procest]`. Estimates: S = half-day, M = 1–2 days, L = 3+ days. + +> **Scope adjustment (2026-05-11):** investigation found NO `Role*Service`, +> `RoleMutationListener`, or per-app permission tables in procest. Role +> assignment is currently inferred from NC group membership; routing +> decisions in the frontend `src/store/workflow.js` map `roleType` UUIDs to +> labels for display only — no server-side enforcement layer exists today. +> +> The violation the umbrella `consume-or-rbac-fleet-wide` flags is therefore +> **the absence of OR-RBAC enforcement on routed steps**, not the presence of +> a parallel permission service. Per ADR-023 + ADR-022, enforcement when +> step-routing decisions are exercised MUST come from OR's `rbac-scopes` +> (via OR's `Organisation.authorization` field or the schema-level +> `x-openregister-authorization` extension once a stateful step layer +> exists). +> +> The follow-up work — adding `ncGroupId` to the `roleType` schema and +> wiring step-routing enforcement through OR — is bound up with the +> parafering / approval-workflow migration (this enforcement is what gates +> who can approve a parafering step). That ships as part of the +> `migrate-parafering-to-or-approval-workflow` follow-up sequence, not as a +> standalone procest PR. + +--- + +## [procest] Schema Changes + +### P-1. Add ncGroupId property to roleType schema (S) + +- [x] P-1.1 In `lib/Settings/procest_register.json`, add `ncGroupId` as a nullable string + property to the `roleType` schema: + ```json + "ncGroupId": { + "type": "string", + "nullable": true, + "description": "Nextcloud group ID that holds this role. OR RBAC uses this to enforce step access. IGroupManager::groupExists() must return true for the value." + } + ``` + - **Acceptance:** `procest_register.json` valid JSON after change; existing `roleType` + objects load without error (field absent = null, no migration needed). + +### P-2. Add authorization block to workflowStep schema (M) + +- [x] P-2.1 Add an `authorization` block to the `workflowStep` schema in `procest_register.json`. + Use the register's OR RBAC structure to restrict read/update access to users in the group + resolved from `assigneeRole` → `roleType.ncGroupId`. If OR's named-role expansion at + register level is not yet available in the deployed `rbac-scopes` implementation, use a + static group representing "all procest users" as an interim (documenting the limitation + in a comment in the JSON). File a tracking issue for the dynamic expansion follow-on. + - **Acceptance:** `workflowStep` schema has an `authorization` block; `GET /api/objects/ + {register}/workflowStep` goes through MagicRbacHandler for the procest register; no + runtime errors on the endpoint. + +- [x] P-2.2 Verify the `workflowTemplate` schema does NOT need a restrictive `authorization` + block for the step-routing scenario (templates are admin-managed definitions, not per-case + objects). Document the decision in design.md. + - **Acceptance:** Design.md updated with the decision; `workflowTemplate` schema unchanged + if admin-only access is sufficient. + +--- + +## [procest] Admin UI + +### P-3. Expose ncGroupId field on roleType admin UI (S) + +- [x] P-3.1 In the case type admin settings panel, add a text input field "NC Group ID" + for each roleType configuration. The field maps to `ncGroupId` on the roleType OR object. + Validate the field client-side: if the user enters a value, display a hint "this must be + an existing Nextcloud group ID." + - **Acceptance:** Admin can view and edit `ncGroupId` on a roleType in the admin settings + UI; save correctly updates the OR object. + +- [x] P-3.2 In `StepConfigPanel.vue`, add a read-only display field "NC Group:" showing + the `ncGroupId` of the step's resolved roleType (if set). Display "— (not mapped)" if + `ncGroupId` is null. + - **Acceptance:** StepConfigPanel shows the resolved NC group ID for steps with a configured + assigneeRole; displays "— (not mapped)" for steps without one. + +--- + +## [procest] Listener Compliance Verification + +### P-4. Verify KpiCacheInvalidationListener compliance (S) + +- [x] P-4.1 Read `lib/Listener/KpiCacheInvalidationListener.php` and confirm: + (a) All event `use` imports are from `OCA\OpenRegister\Event\*` namespace. + (b) No `$groupManager->isInGroup()` or equivalent access-control call in the body. + (c) No write to any parallel audit or permission store. + Document findings in this task's acceptance note. + - **Acceptance:** Listener confirmed compliant (or corrective sub-task filed if not). + A comment is added to the listener class doc-block referencing this spec: + `@see role-routing-via-or-rbac — confirmed: no access decisions made here`. + +--- + +## [procest] Tests + +### P-5. Integration test: step access enforced by OR RBAC (M) + +- [x] P-5.1 Write an integration test (Newman or PHPUnit integration) that: + (a) Creates two NC users: `jan` (not in `vergunningverleners`) and `piet` (in + `vergunningverleners`). + (b) Creates a roleType with `ncGroupId: "vergunningverleners"`. + (c) Creates a `workflowStep` with `assigneeRole` pointing to that roleType. + (d) Calls `GET /api/objects/{register}/workflowStep` authenticated as `jan`; asserts the + step is absent. + (e) Calls the same endpoint authenticated as `piet`; asserts the step is present. + (f) Asserts no `isInGroup()` call was made by procest code during (d) and (e) (verify via + absence of any such call in the controller/service, not runtime tracing). + - **Acceptance:** Test passes against a running NC dev instance with procest + OR installed. + No client-side filtering is applied before the assertion in (d) and (e). + +### P-6. Unit test: roleType ncGroupId resolution (S) + +- [x] P-6.1 Write a PHPUnit unit test for the routing resolution logic: + mock an OR `ObjectService` response returning a roleType with `ncGroupId: "group-a"`; + assert that the step routing service returns `"group-a"` as the enforcement group. + Mock a roleType with `ncGroupId: null`; assert the routing service returns null (open access). + - **Acceptance:** Test passes under `composer check:strict`; zero PHPCS/PHPStan errors. + +--- + +## [procest] Documentation + +### P-7. Update role-based-step-routing cross-reference (S) + +- [x] P-7.1 Add a note to `openspec/specs/role-based-step-routing/spec.md` in the `## ADDED + Requirements` section (or as a standalone comment above) that links to this migration change: + "Enforcement mechanism: see `migrate-role-routing-to-or-rbac` — step access is enforced + server-side via OR RBAC (rbac-scopes) using `roleType.ncGroupId` as the NC group identifier." + Do NOT modify any existing requirement or scenario text. + - **Acceptance:** `role-based-step-routing/spec.md` references this migration change by + slug; no existing requirement text is altered.