## Implementation Plan: Workspace-first RBAC & Environment Access Scoping

**Branch**: `285-workspace-rbac-environment-access` | **Date**: 2026-05-09 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `specs/285-workspace-rbac-environment-access/spec.md`

## Summary

Prepare the reserved workspace-first RBAC slice by collapsing the current dual authorization model into one workspace-first access contract. The implementation path keeps `workspace_memberships` as the sole role-bearing truth, replaces role-bearing `ManagedEnvironmentMembership` semantics with a narrow managed-environment access-scope overlay, retargets `CapabilityResolver`, `WorkspaceContext`, `User::canAccessTenant()`, and the key environment-owned policies to that contract, and migrates the current tenant-membership management surface so it no longer edits a second role system.

This plan is intentionally narrow. Filament remains v5 on Livewire v4, provider registration remains in `apps/platform/bootstrap/providers.php`, `ProviderConnectionResource` remains non-globally-searchable, destructive membership actions stay server-authorized and confirmation-protected, and provider capability or source-taxonomy work from Specs `283` and `284` stays separate. Implementation should start only when Specs `280`, `281`, and `283` are present on the runtime branch.

## Inherited Baseline / Explicit Delta

### Inherited baseline

- `apps/platform/app/Models/WorkspaceMembership.php`, `WorkspaceMembershipManager`, `WorkspaceMembershipPolicy`, `WorkspaceRole`, and `WorkspaceCapabilityResolver` already provide workspace-scoped role truth, audit logging, and last-owner protection.
- `apps/platform/app/Models/ManagedEnvironmentMembership.php`, `TenantMembershipManager`, `TenantRole`, `RoleCapabilityMap`, and `CapabilityResolver` still carry a second environment-scoped role truth.
- `apps/platform/app/Models/User.php` still treats managed-environment memberships as the source for Filament tenant access, default tenant selection, and `canAccessTenant()`.
- `apps/platform/app/Support/Workspaces/WorkspaceContext.php` already treats workspace membership as the first shell boundary but still delegates managed-environment access to `User::canAccessTenant()`.
- `apps/platform/app/Policies/ProviderConnectionPolicy.php`, `OperationRunPolicy.php`, `FindingPolicy.php`, `EvidenceSnapshotPolicy.php`, `ReviewPackPolicy.php`, and `TenantReviewPolicy.php` all enforce workspace and managed-environment access differently.
- `apps/platform/app/Filament/Resources/TenantResource/Pages/ManageTenantMemberships.php` and `TenantMembershipsRelationManager.php` currently expose managed-environment membership CRUD with role selectors.
- `apps/platform/app/Support/Operations/OperationRunCapabilityResolver.php` already distinguishes workspace-owned and environment-owned capabilities, and `ProviderOperationStartGate` from Spec `283` is expected to remain the downstream provider-capability gate after local RBAC passes.

### Explicit delta in this plan

- Make workspace membership the only role-bearing access authority for workspace-owned and managed-environment-owned surfaces.
- Replace the current role-bearing meaning of `ManagedEnvironmentMembership` with a narrow access-scope overlay contract. Whether the implementation keeps the current table name or renames it in place is a bounded implementation choice; dual-write or fallback semantics are forbidden.
- Rework `CapabilityResolver` so it resolves the current role from workspace membership and uses managed-environment scope only for visibility narrowing.
- Rework `User::canAccessTenant()`, `User::getTenants()`, and `WorkspaceContext` so environment accessibility follows the same workspace-first contract.
- Retarget key policies and Filament access gates to use that contract consistently.
- Migrate the current tenant-membership UI into workspace role management plus managed-environment scope management so operators can no longer assign roles in two places.
- Keep provider capability, route-shell, and taxonomy prerequisites explicit instead of silently absorbing them.

## Technical Context

**Language/Version**: PHP 8.4.15, Laravel 12.52  
**Primary Dependencies**: Filament 5.2.1, Livewire 4.1.4, Pest 4.3.1, existing auth resolvers, policies, and Filament relation-manager surfaces  
**Storage**: PostgreSQL, reusing existing membership persistence rather than adding a new table  
**Testing**: Pest unit tests, Pest feature tests, and one Pest browser smoke  
**Validation Lanes**: fast-feedback, confidence, browser  
**Target Platform**: Laravel monolith in `apps/platform`  
**Project Type**: web application  
**Performance Goals**: preserve request-local auth caching and avoid N+1 membership or scope queries on representative environment-owned lists, run lists, and bulk-authorization preflight  
**Constraints**: no `/system` RBAC redesign, no new role family, no mandatory environment-level ACL product, no provider-boundary work from Specs `281` to `284`, no copy/localization work from Spec `286`, no no-legacy guardrail pack from Spec `287`, no compatibility shim or dual-write path  
**Scale/Scope**: one workspace-first authorization cutover over the existing admin panel, managed-environment selection, membership management, and environment-owned policies

## Likely Affected Repo Surfaces

- `apps/platform/app/Models/User.php`
- `apps/platform/app/Models/WorkspaceMembership.php`
- `apps/platform/app/Models/ManagedEnvironmentMembership.php` or its in-slice successor
- `apps/platform/app/Filament/Resources/TenantResource.php`
- `apps/platform/app/Filament/Resources/Workspaces/WorkspaceResource.php`
- `apps/platform/app/Filament/Resources/ProviderConnectionResource.php`
- `apps/platform/app/Services/Auth/WorkspaceCapabilityResolver.php`
- `apps/platform/app/Services/Auth/CapabilityResolver.php`
- `apps/platform/app/Services/Auth/WorkspaceMembershipManager.php`
- `apps/platform/app/Services/Auth/TenantMembershipManager.php` or its in-slice successor
- `apps/platform/app/Support/Workspaces/WorkspaceContext.php`
- `apps/platform/app/Policies/ProviderConnectionPolicy.php`
- `apps/platform/app/Policies/OperationRunPolicy.php`
- `apps/platform/app/Policies/FindingPolicy.php`
- `apps/platform/app/Policies/EvidenceSnapshotPolicy.php`
- `apps/platform/app/Policies/ReviewPackPolicy.php`
- `apps/platform/app/Policies/TenantReviewPolicy.php`
- `apps/platform/app/Policies/TenantOnboardingSessionPolicy.php`
- `apps/platform/app/Filament/Resources/TenantResource/Pages/ManageTenantMemberships.php`
- `apps/platform/app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php`
- `apps/platform/app/Filament/Resources/Workspaces/RelationManagers/WorkspaceMembershipsRelationManager.php`
- `apps/platform/app/Support/Operations/OperationRunCapabilityResolver.php`
- `apps/platform/app/Support/Tenants/TenantInteractionLane.php` and `TenantOperabilityService` only if environment scope needs one shared access question instead of duplicated page-local checks
- representative proof files under `apps/platform/tests/Unit/Auth/`, `apps/platform/tests/Feature/Auth/`, `apps/platform/tests/Feature/Rbac/`, `apps/platform/tests/Feature/Filament/`, and `apps/platform/tests/Browser/`

## Filament v5 / Capability Surface Notes

- **Livewire v4.0+ compliance**: all touched Filament work remains on Filament v5 with Livewire v4.
- **Provider registration location**: provider registration stays in `apps/platform/bootstrap/providers.php`; nothing moves to `bootstrap/app.php`.
- **Global search rule**: `ProviderConnectionResource` remains non-globally-searchable; touched searchable resources such as `TenantResource` and `WorkspaceResource` keep existing valid View destinations and must not gain broken search links.
- **Destructive actions**: workspace membership and managed-environment scope removals remain `->action(...)` plus `->requiresConfirmation()` and server-side authorization. No URL-only destructive affordance is introduced.
- **Asset strategy**: no new asset registration or `filament:assets` behavior is introduced.

## Workspace-first Access Contract Fit

- Keep one canonical access decision shape for environment-owned surfaces:
  - `workspace_role`
  - `workspace_member`
  - `managed_environment_allowed`
  - `failed_boundary` when access is denied
  - `required_capability`
  - `capability_allowed`
  - `provider_capability_context` only as an optional downstream note after local RBAC passes
  - downstream provider-capability or operability outcome where applicable
- Keep the default environment-access rule explicit: no scope rows means inheritance across the currently selectable managed environments in the workspace; explicit scope rows mean allowlist narrowing only.
- Keep managed-environment scope subordinate to workspace membership. No scope row may grant access to a user who lacks workspace membership.
- Keep role-to-capability mappings derived from one workspace role value. Reusing the current capability constants is in scope; inventing a new role family is not.
- Keep current provider capability and operability checks downstream. This slice determines whether local RBAC allows the request to proceed to those deeper checks.
- Keep denied-access diagnostics derived from the shared access decision only. Boundary-safe logs may capture workspace, managed environment, failed boundary, and required capability, but they must not expose raw provider payloads or secrets.

## UI / Surface Guardrail Plan

- **Guardrail scope**: changed surfaces
- **Native vs custom classification summary**: native Filament relation managers plus shared shell/context logic
- **Shared-family relevance**: membership management, managed-environment selection, related drilldowns, and page access guards
- **State layers in scope**: shell, page, relation manager, modal, remembered context, URL-query
- **Audience modes in scope**: operator-MSP, support-platform
- **Decision/diagnostic/raw hierarchy plan**: workspace role decision first, managed-environment scope second, deeper diagnostics only when access is denied or narrowed
- **Raw/support gating plan**: audit history and raw identifiers stay secondary; debug semantics do not become primary page copy
- **One-primary-action / duplicate-truth control**: workspace membership surfaces manage roles; managed-environment scope surfaces manage visibility only; the shell uses one shared access outcome instead of repeating different denial stories on each page
- **Handling modes by drift class or surface**: review-mandatory
- **Repository-signal treatment**: review-mandatory until all in-scope policies and selection APIs converge on the same access contract
- **Special surface test profiles**: standard-native-filament, global-context-shell, shared-detail-family
- **Required tests or manual smoke**: functional-core, state-contract, browser-smoke
- **Exception path and spread control**: none; any temporary alias or duplicate role editor becomes a blocker or explicit split
- **Active feature PR close-out entry**: Guardrail

## Shared Pattern & System Fit

- **Cross-cutting feature marker**: yes
- **Systems touched**: workspace membership management, managed-environment scope management, environment selection, environment-owned page policies, and run drilldowns
- **Shared abstractions reused**: `WorkspaceCapabilityResolver`, `CapabilityResolver`, `WorkspaceContext`, `OperationRunCapabilityResolver`, `WorkspaceMembershipManager`, and the existing Filament relation-manager patterns
- **New abstraction introduced? why?**: one unified workspace-first access-decision path is expected because the repo currently has two partially overlapping resolver stacks
- **Why the existing abstraction was sufficient or insufficient**: the current abstractions already own caching, capability checks, and membership queries, but they are split by workspace versus environment truth and therefore cannot answer one canonical access question today
- **Bounded deviation / spread control**: no bounded exception is planned; implementation should replace, not wrap, the duplicated role-bearing environment path

## OperationRun UX Impact

- **Touches OperationRun start/completion/link UX?**: yes
- **Central contract reused**: `OperationRunCapabilityResolver`, `OperationRunPolicy`, and the current provider-operation start path for downstream provider gating
- **Delegated UX behaviors**: workspace-bound versus environment-bound run access, required capability lookup, and downstream provider gating remain delegated to the shared run auth seams
- **Surface-owned behavior kept local**: existing run links and monitoring surfaces stay local; only the access decision changes
- **Queued DB-notification policy**: `N/A`
- **Terminal notification path**: existing central lifecycle mechanism
- **Exception path**: none

## Provider Boundary & Portability Fit

- **Shared provider/platform boundary touched?**: no
- **Provider-owned seams**: `N/A`
- **Platform-core seams**: `N/A`
- **Neutral platform terms / contracts preserved**: downstream provider capability inputs from Spec `283` remain provider-neutral and unchanged
- **Retained provider-specific semantics and why**: `N/A`
- **Bounded extraction or follow-up path**: `N/A`

## Constitution Check

*GATE: Must pass before implementation begins and again after design artifacts are complete.*

- Inventory-first / snapshot truth: PASS. The slice changes local authorization only.
- Read/write separation: PASS. Membership and scope mutations are DB-only and audit-backed; no new remote write flow is introduced.
- Graph contract path: PASS. No new Graph calls are introduced.
- Deterministic capabilities: PASS with implementation condition. Capability results for environment-owned pages must be derivable from workspace role plus explicit scope plus existing capability constants.
- RBAC-UX plane separation: PASS. `/admin` versus `/system` remains unchanged.
- Workspace isolation: PASS. Workspace membership becomes the canonical first boundary.
- Managed-environment isolation: PASS. Managed-environment access remains explicit, but only as a narrowing overlay.
- Destructive action discipline: PASS by preservation. Membership and scope removals remain confirmation-protected.
- Global search safety: PASS with implementation condition. Searchable resources touched by the cutover must still honor deny-as-not-found for inaccessible environments.
- OperationRun / Ops-UX: PASS. The feature reuses existing run access seams.
- Data minimization: PASS. No new persistence is added.
- Test governance: PASS. Proof stays bounded to unit, feature, and one browser smoke.
- Proportionality / no premature abstraction: PASS with implementation condition. The access-decision path must replace the split model rather than sit beside it.
- Persisted truth / behavioral state: PASS. No new persisted truth or state family is required.
- UI semantics / shared pattern first / Filament-native UI: PASS. Existing native relation managers and shared shell contracts remain the primary surfaces.
- Provider boundary: PASS. The slice consumes provider capability context but does not redefine provider/platform boundaries.

**Gate evaluation**: PASS for artifact quality, with explicit external prerequisites from Specs `280`, `281`, and `283`.

**Post-design re-check**: PASS when `research.md`, `data-model.md`, `quickstart.md`, `contracts/workspace-rbac-environment-access.logical.openapi.yaml`, `tasks.md`, and `checklists/requirements.md` keep the same workspace-first role truth, optional scope-overlay rule, and proof commands.

## Test Governance Check

- **Test purpose / classification by changed surface**: Unit, Feature, Browser
- **Affected validation lanes**: fast-feedback, confidence, browser
- **Why this lane mix is the narrowest sufficient proof**: resolver and scope inheritance belong in unit tests; policy and Filament access behavior belong in feature tests; one browser smoke proves real shell and membership-surface continuity
- **Narrowest proving command(s)**:
  - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Unit/Auth/WorkspaceFirstCapabilityResolverTest.php tests/Unit/Auth/ManagedEnvironmentAccessScopeResolverTest.php)`
  - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Feature/Auth/WorkspaceFirstManagedEnvironmentAccessTest.php tests/Feature/Rbac/ProviderConnectionWorkspaceFirstPolicyTest.php tests/Feature/Rbac/OperationRunWorkspaceFirstAuthorizationTest.php tests/Feature/Rbac/GovernanceArtifactsWorkspaceFirstAuthorizationTest.php tests/Feature/Filament/WorkspaceMembershipRoleManagementTest.php tests/Feature/Filament/ManagedEnvironmentAccessScopeManagementTest.php)`
  - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Browser/Spec285WorkspaceRbacEnvironmentAccessSmokeTest.php)`
  - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail bin pint --dirty --format agent)`
- **Fixture / helper / factory / seed / context cost risks**: moderate because proof needs workspace memberships, explicit environment-scope rows, managed environments, and representative environment-owned records without widening global test defaults
- **Expensive defaults or shared helper growth introduced?**: no; any new helper should stay feature-local and opt-in
- **Heavy-family additions, promotions, or visibility changes**: none beyond one bounded browser smoke
- **Surface-class relief / special coverage rule**: standard-native-filament relief for relation-manager CRUD, plus one global-context-shell smoke for access continuity
- **Closing validation and reviewer handoff**: rerun the commands above, verify no policy still uses a role-bearing environment membership path, verify managed-environment scope rows cannot outlive or bypass workspace membership, verify the current tenant-membership surface no longer edits roles, verify representative environment-owned lists, run lists, and bulk-authorization preflight stay query-bounded under the shared contract, verify touched searchable resources do not leak inaccessible environments through search results, verify denied-access logging remains diagnosable without raw provider detail, and verify run access still distinguishes workspace-bound versus environment-bound runs correctly
- **Budget / baseline / trend follow-up**: contained feature-local increase only
- **Review-stop questions**: did the implementation keep dual role authority, did it introduce a new role family, did environment scope become a second ACL product, did it widen into route or provider taxonomy work, did any shell access path skip the shared contract
- **Escalation path**: `document-in-feature` for bounded migration notes only; `reject-or-split` if dual truth remains
- **Active feature PR close-out entry**: Guardrail
- **Why no dedicated follow-up spec is needed**: the remaining adjacent concerns are already tracked as Specs `284`, `286`, and `287`

## Review Checklist Status

- **Review checklist artifact**: `checklists/requirements.md`
- **Review outcome class**: `blocked-by-prerequisites`
- **Workflow outcome**: `keep`
- **Test-governance outcome**: `keep`
- **Readiness note**: runtime work remains externally gated on Specs `280`, `281`, and `283` being present on the implementation branch

## Rollout Considerations

- Land the workspace-first access resolver and the policy retargeting atomically so pages do not alternate between old and new access semantics.
- Move the membership-management surface split in the same slice as the resolver change so operators cannot keep editing a second role authority after the backend cutover.
- Keep environment scope opt-in and narrow from the start; otherwise the feature could accidentally hard-require scope rows for all members.
- Preserve downstream provider capability and operability gates as-is; this slice should stop once local RBAC says the request may proceed.

## Risk Controls

- Reject any implementation that leaves role-bearing capability checks on `ManagedEnvironmentMembership`.
- Reject any implementation that introduces dual-write or fallback reads between workspace and environment role truth.
- Reject any implementation that adds a second role selector to the environment-scope surface.
- Reject any implementation that makes environment scope mandatory for all workspace members.
- Reject any implementation that widens into provider-capability, route-shell, source-taxonomy, copy/localization, or guardrail-pack work reserved for adjacent specs.

## Research & Design Outputs

- `research.md` records the repo-truth decisions, the scope-overlay rule, and the rejected alternatives.
- `data-model.md` captures the canonical workspace role truth, the logical environment-scope overlay, and the shared access-decision payloads.
- `quickstart.md` gives reviewers the preconditions, bounded validation flow, and exact commands.
- `contracts/workspace-rbac-environment-access.logical.openapi.yaml` models the logical workspace membership summary, managed-environment access decision, and run-access decision payloads.
- `checklists/requirements.md` records package readiness, boundedness, and the prerequisite note.

## Project Structure

### Documentation (this feature)

```text
specs/285-workspace-rbac-environment-access/
├── checklists/
│   └── requirements.md
├── contracts/
│   └── workspace-rbac-environment-access.logical.openapi.yaml
├── data-model.md
├── plan.md
├── quickstart.md
├── research.md
├── spec.md
└── tasks.md
```

### Source Code (expected implementation surfaces)

```text
apps/platform/
├── app/
│   ├── Filament/
│   │   └── Resources/
│   │       ├── TenantResource/
│   │       │   ├── Pages/
│   │       │   │   └── ManageTenantMemberships.php
│   │       │   └── RelationManagers/
│   │       │       └── TenantMembershipsRelationManager.php
│   │       └── Workspaces/
│   │           └── RelationManagers/
│   │               └── WorkspaceMembershipsRelationManager.php
│   ├── Models/
│   │   ├── User.php
│   │   ├── WorkspaceMembership.php
│   │   └── ManagedEnvironmentMembership.php
│   ├── Policies/
│   │   ├── ProviderConnectionPolicy.php
│   │   ├── OperationRunPolicy.php
│   │   ├── FindingPolicy.php
│   │   ├── EvidenceSnapshotPolicy.php
│   │   ├── ReviewPackPolicy.php
│   │   ├── TenantReviewPolicy.php
│   │   └── TenantOnboardingSessionPolicy.php
│   ├── Services/
│   │   └── Auth/
│   │       ├── WorkspaceCapabilityResolver.php
│   │       ├── CapabilityResolver.php
│   │       ├── WorkspaceMembershipManager.php
│   │       └── TenantMembershipManager.php
│   └── Support/
│       ├── Operations/
│       │   └── OperationRunCapabilityResolver.php
│       └── Workspaces/
│           └── WorkspaceContext.php
└── tests/
    ├── Browser/
    ├── Feature/
    │   ├── Auth/
    │   ├── Filament/
    │   └── Rbac/
    └── Unit/
        └── Auth/
```

**Structure Decision**: keep the documentation package self-contained under `specs/285-workspace-rbac-environment-access/`; later runtime work should modify the existing auth, policy, model, and Filament seams directly in `apps/platform/` instead of adding a parallel RBAC subsystem.

## Complexity Tracking

| Violation | Why Needed | Simpler Alternative Rejected Because |
|---|---|---|
| Canonical replacement of existing role-bearing environment membership semantics | The current codebase already has duplicate role truth and cannot satisfy workspace-first tenancy without replacing one side | Synchronizing two role-bearing stores would add more compatibility logic and preserve the drift |
| One unified workspace-first access-decision path | Multiple policies and selection APIs need one shared answer for workspace membership plus environment scope | Page-local helpers would keep drift and inconsistent 404/403 semantics |

## Proportionality Review

- **Current operator problem**: the current product answers one authorization question from two role-bearing truth sources, which causes inconsistent page access, duplicated operator administration, and workspace-first cutover drift.
- **Existing structure is insufficient because**: the repo already contains both `WorkspaceCapabilityResolver` and environment-scoped `CapabilityResolver`, but they currently resolve different role sources and cannot guarantee one shared answer for user access, context selection, policy checks, search visibility, and run drilldowns.
- **Narrowest correct implementation**: retarget the existing shared auth seams so workspace membership is the only role-bearing truth and managed-environment scope becomes narrowing-only, instead of adding a third resolver family, a new role catalog, or a new persistence layer.
- **Ownership cost created**: auth services, user helpers, context helpers, policies, relation managers, audits, and the bounded proof suite all need coordinated changes, but the cost is bounded because the slice removes duplicate semantics instead of adding another permanent subsystem.
- **Alternative intentionally rejected**: keeping role-bearing managed-environment memberships and synchronizing them with workspace memberships. That would preserve the current ambiguity and add more compatibility logic to a pre-production codebase.
- **Release truth**: current-release truth
