Story CP-014: Permission Resolution + Module Gating¶
Module: control-plane Slice: Slice 3 from architecture (split from CP-003) Brand context: [BOTH] Papillon HR Suite + ALTARYS ENTERPRISE HR Suite Priority: 3 Depends on: CP-003 Estimated complexity: M
Objective¶
Implement the permission resolution engine: resolve a user's effective permissions by intersecting their role-based permissions with active tenant modules, and filtering out write permissions for suspended tenants. This is the backend-only "brain" that gates every API call. Provides the GET /api/v1/auth/my-permissions endpoint and the PermissionService + PermissionCacheService used by all authorization checks.
Backend Scope¶
Entities & Records¶
This story consumes entities defined in CP-003 (User, UserTenantMembership, MembershipRole, Role, Permission, RolePermission). No new entities are created.
Permission entity (defined in CP-003, reference):
| Field | Type | Constraints |
|---|---|---|
| id | UUID v7 | PK |
| code | String | NOT NULL, unique (e.g., "cp.employee.create") |
| module_code | String | NOT NULL (e.g., "CP", "ABSMGT") |
| description | String | NOT NULL |
Permission format: {module_code}.{entity}.{action} (e.g., cp.employee.create, absence.leave_request.approve)
DTOs:
public record MyPermissionsResponse(List<String> permissions) {}
// e.g., ["cp.employee.create", "cp.employee.read", "absence.leave_request.create"]
Repository Layer¶
Uses repositories from CP-003:
- PermissionRepository.java — findByRoleIds(Set<UUID>) → List<Permission>
- RoleRepository.java — findByTenantIdAndCode(UUID, String) → Optional<Role>
- UserTenantMembershipRepository.java — findByUserIdAndTenantId(UUID, UUID) → Optional<UserTenantMembership>
Service Layer¶
PermissionService.java:
- getPermissionsForUser(UUID userId, UUID tenantId) → List<String> — Resolves permissions:
1. Get user's roles for this tenant (from membership)
2. Get all permissions for those roles
3. Filter: only include permissions where the corresponding module is ACTIVE for the tenant (module gating — depends on CP-007, stub initially)
4. Filter: if tenant is SUSPENDED, remove all write permissions
PermissionCacheService.java:
- Caffeine cache: key = userId:tenantId, value = List<String> permissions
- TTL: 5 minutes
- Invalidated on: role change, module activation/deactivation, tenant status change
- Cache invalidation via Spring Application Events
API Endpoints¶
| Method | Path | Request Body | Response | Auth |
|---|---|---|---|---|
| GET | /api/v1/auth/my-permissions | — | MyPermissionsResponse | Any authenticated |
Validation Rules¶
- User must have an active membership in the current tenant
- Module gating: permissions for inactive modules are silently excluded (no error, just absent)
- SUSPENDED tenant: write permissions (.create, .update, .delete) removed; read permissions (.read, *.list) retained
Multi-Tenant Considerations¶
- Permission resolution always happens in the context of a specific tenant (from TenantContext)
- A user may have different roles (and thus different permissions) in different tenants
- Module activation status is per-tenant (from CP-007 — stub with all-active until CP-007 is implemented)
Frontend Scope¶
None — this story is backend-only. Frontend consumes my-permissions via the auth context established in CP-001.
Acceptance Criteria¶
AC-020: Get my permissions (module-gated)
Given I have role EMPLOYE and my tenant has modules ABSMGT=ACTIVE and PAYROL=INACTIVE
When I call GET /api/v1/auth/my-permissions
Then I receive permissions for ABSMGT (e.g., "absence.leave_request.create")
And I do NOT receive permissions for PAYROL
AC-021: Permission denied for suspended tenant writes
Given my tenant is SUSPENDED
When I call GET /api/v1/auth/my-permissions
Then all write permissions are removed
And read permissions remain
AC-022: Assign role with inactive module permissions
Given I assign role RH_MANAGER which includes permissions for PAYROL (inactive module)
When the role is saved
Then the PAYROL permissions are silently ignored (not shown in my-permissions, 403 on API calls)
OHADA & Regulatory Rules¶
- Loi 2013-450 (Art. 36): Permission data determines access to personal data. The permission resolution engine enforces the principle of least privilege — users only see data for modules their tenant has activated.
Standards & Conventions¶
docs/standards/java-spring-guidelines.md— §Service layer, §Caching, §Application Eventsdocs/standards/api-guidelines.md— §Authentication endpoints, §Standard envelope
Testing Requirements¶
Unit Tests¶
- PermissionService: role → permissions resolution with module gating
- PermissionService: SUSPENDED tenant → write permissions removed, read retained
- PermissionService: multiple roles → permissions merged (union)
- PermissionCacheService: cache hit returns cached value
- PermissionCacheService: cache miss triggers resolution
- PermissionCacheService: invalidation on role change event
- PermissionCacheService: invalidation on module activation/deactivation event
- PermissionCacheService: invalidation on tenant status change event
Integration Tests¶
- GET /api/v1/auth/my-permissions — with EMPLOYE role and mixed module states
- GET /api/v1/auth/my-permissions — with SUSPENDED tenant
- GET /api/v1/auth/my-permissions — user with no membership → 403
- Cache behavior: second call within TTL returns cached result
What QA Will Validate¶
- Permission endpoint returns correct permissions for various role/module combinations
- Suspended tenant correctly strips write permissions
- Inactive module permissions are silently excluded
Out of Scope¶
- Custom roles — V2. MVP has fixed 5 default roles only.
- Permission UI (viewing/editing permissions in admin) — V2
- Module activation/deactivation — CP-007 (stubbed here)
- Tenant switching — CP-011
Definition of Done¶
- [ ] PermissionService resolves permissions correctly with module gating
- [ ] PermissionCacheService caches and invalidates correctly
- [ ] GET /api/v1/auth/my-permissions endpoint returns correct data
- [ ] All acceptance criteria pass manually
- [ ] Unit tests written and passing
- [ ] Integration tests written and passing
- [ ] Module gating stubbed (all modules ACTIVE) until CP-007
- [ ] No Java raw types — all generics explicit
- [ ] API responses follow standard envelope format