# Research: RBAC UI Enforcement Helper v2 (Suite-wide, Mixed Visibility, Record-Scoped) **Branch**: `066-rbac-ui-enforcement-helper-v2` **Date**: 2026-01-30 **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas-066-rbac-ui-enforcement-helper-v2/specs/066-rbac-ui-enforcement-helper/spec.md` **Plan**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas-066-rbac-ui-enforcement-helper-v2/specs/066-rbac-ui-enforcement-helper/plan.md` ## Decisions ### D-001 — Mixed visibility needs explicit composition primitives **Decision**: `UiEnforcement` supports mixed visibility via explicit composition methods: - `preserveVisibility()` is allowed only on tenant-scoped surfaces where routing already denies non-members (404). - Record-scoped / cross-tenant lists must use `andVisibleWhen(...)` / `andHiddenWhen(...)` (or default RBAC visibility), so non-members cannot discover actions. **Rationale**: Filament does not provide a safe way to introspect/merge existing `->visible()` / `->hidden()` closures. Explicit composition avoids brittle “read back and wrap” behavior and preserves the non-member “cannot discover” contract. **Alternatives considered**: - Allow `preserveVisibility()` everywhere: rejected because it can leak action discoverability on record-scoped lists. - Implicitly merge existing closures (helper tries to wrap previous `visible/hidden`): rejected as fragile and hard to review/test. --- ### D-002 — Tenant resolution must be explicit and per-surface **Decision**: `UiEnforcement` supports tenant resolution modes: - `tenantFromFilament()` (default) for tenant-scoped pages/actions. - `tenantFromRecord()` for record-scoped resources where record == tenant. - `tenantFrom(callable)` for record → tenant mapping. **Rationale**: v2 targets record-scoped surfaces where `Filament::getTenant()` is intentionally null; the helper must not hard-depend on Filament tenancy context. **Alternatives considered**: - Require `Filament::getTenant()` always: rejected because it blocks record-scoped resources by design. --- ### D-003 — Bulk preflight is authorization-only by default **Decision**: Default bulk “all-or-nothing” preflight applies to authorization only: - If any selected record is unauthorized, the bulk action is disabled for members and cannot execute. - Business eligibility (e.g., inactive/archived) remains separate and may be skipped with deterministic feedback. **Rationale**: Mixing “eligibility” into authorization preflight tends to create confusing UX for operators and makes migrations riskier (unexpected disablement). Keeping them separate preserves predictable security semantics and reduces incidental coupling. **Alternatives considered**: - Disable bulk actions when any record is ineligible: rejected as overly strict and likely to regress existing workflows. - Partial execution for authorized subset: rejected (v2 contract requires all-or-nothing for authorization). --- ### D-004 — Record-scoped non-member denial is 404 **Decision**: For record-scoped tenant pages/actions, non-members are denied as not found (404) on direct access/execution attempts. **Rationale**: Prevents tenant/record enumeration and aligns with the constitution’s deny-as-not-found rule for non-members. **Alternatives considered**: - Return 403 for non-members: rejected as it can leak record existence. --- ### D-005 — Standard disabled tooltip copy **Decision**: Default `UiTooltips` message for member-without-capability is: > “Insufficient permission — ask a tenant Owner.” **Rationale**: Clear, non-leaky, and gives the user the correct next step without exposing capability names or internal policy details. **Alternatives considered**: - “Insufficient permission.”: rejected as too vague for operators. - Include capability identifier in tooltip: rejected as noisy and may leak internal policy shape; can be added selectively where appropriate. --- ### D-006 — Tests are the contract (per surface) **Decision**: Each migrated surface gets at least one focused integration test covering: - non-member: hidden + cannot execute (deny-as-not-found/404 where reachable), - member without capability: visible but disabled + standard tooltip + cannot execute, - member with capability: enabled executes (and destructive actions require confirmation). Additionally, DB-only render is enforced for migrated pages/actions using `Http::preventStrayRequests()`. **Rationale**: The helper exists to standardize behavior; tests must assert the user-facing contract and prevent drift. **Alternatives considered**: - Rely on unit tests for helper only: rejected because Filament execution semantics and closures are integration-sensitive.