critical priority low complexity backend pending backend specialist Tier 1

Acceptance Criteria

AccessDenialService implements the IAccessDenialService interface defined in task-001 with no missing methods
isRoleBlocked stream emits true immediately when the current role is 'global_admin'
isRoleBlocked stream emits false for all non-blocked roles (coordinator, peer_mentor, org_admin)
isRoleBlocked stream re-emits on role changes without requiring a service restart or re-subscription
isRoleBlocked stream emits true when role is null or undefined (unauthenticated edge case)
Service correctly reads role from the RBAC layer injected via constructor (not hardcoded)
getAdminPortalUrl() delegates to the no-access config repository — no hardcoded URL strings in service
Service disposes cleanly without stream leaks when the provider scope is destroyed
Blocked role set is defined as a private constant (e.g. _kBlockedRoles) within the service, not inline
Unit tests pass: 6 scenarios covering global_admin blocked, coordinator allowed, null role blocked, stream emits on role change, delegation to config repo, and clean disposal

Technical Requirements

frameworks
Flutter
Riverpod
dart:async (StreamController or rxdart BehaviorSubject)
apis
RBAC layer stream/notifier (from task-001 interface)
INoAccessConfigRepository.getAdminPortalUrl() (052-no-access-config-repository)
data models
UserRole enum (global_admin, coordinator, peer_mentor, org_admin)
AccessDenialService interface (IAccessDenialService from task-001)
performance requirements
Stream must not buffer more than the latest value — use BehaviorSubject or StreamController with sync=false
Role resolution must complete synchronously from cached RBAC state (no async await on hot path)
security requirements
Blocked role evaluation must happen server-side confirmed via Supabase Auth JWT claims — client-side check is UX-only gating, not security boundary
global_admin role must be in the blocked set regardless of any feature flags or config overrides
Service must not expose role strings publicly — role comparison done internally against private constant set

Execution Context

Execution Tier
Tier 1

Tier 1 - 540 tasks

Can start after Tier 0 completes

Implementation Notes

Implement using a StreamController or a BehaviorSubject (rxdart) that listens to the injected RBAC role stream and maps each emitted role to a bool via a private _isBlocked(UserRole? role) helper. Define _kBlockedRoles = const {UserRole.globalAdmin} as a file-level or class-level constant. Constructor-inject both the RBAC role source and the INoAccessConfigRepository — do not use service locator patterns; Riverpod handles DI.

Ensure the internal StreamSubscription to the RBAC layer is cancelled in a dispose() method. Avoid using async* generators for the public stream — synchronous transformation via map() on the RBAC stream is cleaner and easier to test. The getAdminPortalUrl() method should be a simple delegation: return _configRepository.getAdminPortalUrl(); — caching is handled in task-003.

Testing Requirements

Unit tests using flutter_test and mocktail. Mock INoAccessConfigRepository and the RBAC role stream. Test scenarios: (1) global_admin role → stream emits true, (2) coordinator role → emits false, (3) peer_mentor role → emits false, (4) null role → emits true, (5) role changes from coordinator to global_admin mid-session → stream emits true after change, (6) dispose called → no further stream emissions or errors. Aim for 100% branch coverage on the blocked-role evaluation logic.

No integration or e2e tests required for this unit.

Component
Access Denial Service
service low
Epic Risks (2)
high impact medium prob technical

If the GoRouter redirect callback evaluates the no-access route itself as a blocked destination, it will trigger an infinite redirect loop, crashing the navigator.

Mitigation & Contingency

Mitigation: Add an explicit guard condition in the redirect callback: return null (no redirect) when the current location is already the no-access route or the logout route. Write a dedicated unit test covering this exact scenario.

Contingency: If the redirect loop is detected in production, deploy a hotfix that adds the null-return guard; the feature can be toggled off via the existing feature-flag infrastructure while the fix is prepared.

medium impact low prob integration

The access-denial-service may read role state before authentication completes (e.g. during app resume), causing a temporary false-positive block that redirects valid peer-mentor users to the no-access screen.

Mitigation & Contingency

Mitigation: Subscribe to the role-state-manager's loading/ready lifecycle and only evaluate role-based access once the RBAC state is confirmed as loaded. Return a 'pending' state that causes the guard to defer rather than redirect.

Contingency: Add a retry mechanism: if a user lands on the no-access screen but their role subsequently resolves as non-blocked, automatically navigate them to the role-based home screen.