Required Plugins
Superpowers plugin: MUST be active for all work using this skill. Use throughout the entire build pipeline — design decisions, code generation, debugging, quality checks, and any task where it offers enhanced capabilities. If superpowers provides a better way to accomplish something, prefer it over the default approach.
Mobile RBAC - Android Permission System
Architecture Overview
Mobile RBAC uses a hybrid client+server approach:
- Backend enforces - Every API call checked by PermissionMiddleware (returns 403 if denied)
- Client gates UI - Cached permissions control button/tab/screen visibility for UX
- Fail-secure - If permissions unknown, deny access (never grant)
- Offline-capable - Cached permissions work without network
Backend Environments: Dev (Windows/MySQL 8.4.7), Staging (Ubuntu/MySQL 8.x), Production (Debian/MySQL 8.x). Permission APIs must behave identically across all environments. Use Gradle build flavors for environment-specific base URLs.
Login → Fetch Permissions → Cache in EncryptedSharedPreferences → UI Gates
↕ (refresh) ↕ (403 fallback)
Backend always enforces ←──────────────────────────────────────┘
Quick Reference
| Topic | Reference File | When to Use |
|---|---|---|
| Architecture & Caching | This file | Permission flow, caching strategy, refresh triggers |
| Implementation Patterns | references/implementation-patterns.md | Code templates for PermissionManager, PermissionGate, etc. |
| Permission Map | references/permission-map.md | What permission controls what feature |
Core Principles
1. Two-Layer Gating
| Layer | What It Controls | When Hidden/Disabled |
|---|---|---|
| Module Gate | Bottom nav tabs | Franchise hasn't subscribed to module |
| Permission Gate | Screens, buttons, actions | User's role lacks the permission |
Rule: Modules HIDE tabs entirely. Permissions DISABLE or HIDE individual actions.
2. Permission Resolution (Backend)
The backend resolves permissions using 5-tier priority:
1. User Denial (explicit deny) → ALWAYS DENIED
2. User Grant (explicit grant) → ALWAYS GRANTED
3. Franchise Override → Tenant customization
4. Role Permission → Default from role
5. Super Admin / Owner → ALL permissions
The mobile client never resolves permissions locally. It receives the resolved
set from the backend via GET /user/permissions and uses it as-is.
3. Storage: EncryptedSharedPreferences
Permissions are a flat set of ~20-50 string codes. Too lightweight for Room.
"user_permissions" → Set<String> {"POS_CREATE_SALE", "DASHBOARD_VIEW", ...}
"user_modules" → Set<String> {"POS", "INVENTORY", ...}
"user_roles" → Set<String> {"CASHIER", ...}
"user_type" → String "staff"
"permissions_updated" → Long (epoch millis)
4. Refresh Strategy
| Trigger | Action |
|---|---|
| After login | Fetch immediately |
| App startup (cold) | Fetch if > 15 min stale |
| App resume (warm) | Fetch if > 15 min stale |
| 403 from backend | Fetch immediately, then retry |
| Pull-to-refresh | Fetch immediately |
5. Offline Behavior
- Use cached permissions (last known good)
- If no cache exists (fresh install), deny all
- Never allow more access offline than last sync granted
PermissionManager (Singleton)
The central permission store, injected via Hilt:
@Singleton
class PermissionManager @Inject constructor(
@ApplicationContext context: Context
) {
// StateFlow for Compose reactivity
val permissionsFlow: StateFlow<Set<String>>
val modulesFlow: StateFlow<Set<String>>
// Checks
fun hasPermission(code: String): Boolean
fun hasAnyPermission(codes: Collection<String>): Boolean
fun hasAllPermissions(codes: Collection<String>): Boolean
fun hasModule(code: String): Boolean
fun isOwner(): Boolean
fun isSuperAdmin(): Boolean
fun isStale(): Boolean
// Storage
fun savePermissions(permissions: Set<String>)
fun saveModules(modules: Set<String>)
fun clear() // Call on logout
}
Owner and Super Admin bypass all permission checks. Check user_type first.
UI Patterns
Pattern 1: PermissionGate (Show/Hide)
@Composable
fun PermissionGate(
permissionManager: PermissionManager,
permission: String,
hide: Boolean = true, // true = render nothing when denied
deniedContent: @Composable (() -> Unit)? = null,
content: @Composable () -> Unit
)
Use for: FABs, action buttons, cards, sections that should be completely hidden if the user lacks permission.
Icon Policy: Use custom PNG icons only; follow android-custom-icons and update PROJECT_ICONS.md.
Report Table Policy: If permissioned screens include reports that can exceed 25 rows, use table layouts (see android-report-tables).
// Hide "Create PO" FAB if user can't create POs
PermissionGate(permissionManager, Permission.INVENTORY_PO_CREATE) {
FloatingActionButton(onClick = onCreatePO) {
Icon(painterResource(R.drawable.add), "Create PO")
}
}
Pattern 2: PermissionButton (Disable with Message)
@Composable
fun PermissionButton(
permissionManager: PermissionManager,
permission: String,
onClick: () -> Unit,
text: String,
deniedMessage: String = "You don't have permission"
)
Use for: Primary actions that users should SEE but can't perform (approve, dispatch, receive, charge).
// "Approve" button - visible but disabled if no permission
PermissionButton(
permissionManager = permissionManager,
permission = Permission.INVENTORY_PO_APPROVE,
onClick = { viewModel.approve() },
text = "Approve",
deniedMessage = "Approval restricted"
)
Pattern 3: ModuleGate (Tab Visibility)
@Composable
fun ModuleGate(
permissionManager: PermissionManager,
module: String,
content: @Composable () -> Unit
)
Use for: Bottom navigation tabs, entire feature sections.
// Filter bottom nav items by module access
val items = buildList {
add(BottomNavItem.Dashboard) // Always visible
if (permissionManager.hasModule(Module.POS)) add(BottomNavItem.POS)
if (permissionManager.hasModule(Module.INVENTORY)) add(BottomNavItem.Inventory)
add(BottomNavItem.Settings) // Always visible
}
Pattern 4: Navigation Guard
// In NavHost: guard sensitive routes
composable("create_purchase_order") {
if (permissionManager.hasPermission(Permission.INVENTORY_PO_CREATE)) {
CreatePurchaseOrderScreen(...)
} else {
PermissionDeniedScreen(
permission = "Create Purchase Orders",
onBack = { navController.popBackStack() }
)
}
}
Pattern 5: PermissionDeniedScreen
Full-screen blocker for navigation guards:
@Composable
fun PermissionDeniedScreen(
permission: String, // Human-readable name
onBack: () -> Unit
)
// Shows: Lock icon + "Access Restricted" + explanation + "Go Back" button
UX Guidelines
| Scenario | UX Pattern | Why |
|---|---|---|
| Tab the user can't access | Hide tab | Clean nav, no confusion |
| Button the user can't use | Disable + grey + message | User knows feature exists |
| Card/section user can't see | Hide | Clean layout |
| Screen user navigates to via deep link | PermissionDeniedScreen | Graceful block |
| 403 from server (stale cache) | Auto-refresh perms, show toast | Transparent recovery |
| Offline with cached perms | Use cached perms normally | Seamless offline |
| Offline with no cached perms | Deny all, show offline banner | Fail-secure |
Backend Integration
API Endpoint: GET /user/permissions
{
"success": true,
"data": {
"user_id": 10014,
"franchise_id": 3,
"user_type": "staff",
"roles": [{"code": "CASHIER", "name": "Cashier"}],
"permissions": ["DASHBOARD_VIEW", "POS_CREATE_SALE", ...],
"modules": [
{"code": "POS", "name": "Point of Sale", "is_enabled": true},
{"code": "INVENTORY", "name": "Inventory", "is_enabled": false}
]
}
}
403 Response Handling
{
"success": false,
"message": "You do not have permission to perform this action",
"error": {
"code": "PERMISSION_DENIED",
"required_permission": "INVENTORY_PO_APPROVE"
}
}
Client response:
- Parse
required_permissionfrom error - Auto-refresh permissions via
/user/permissions - Show friendly message: "Your permissions have been updated"
CompositionLocal (Convenience)
val LocalPermissionManager = staticCompositionLocalOf<PermissionManager> {
error("No PermissionManager provided")
}
// In MainScaffold:
CompositionLocalProvider(LocalPermissionManager provides permissionManager) {
// All child composables access via LocalPermissionManager.current
}
Security Rules
- Never trust client-only checks - Backend ALWAYS validates permissions
- Encrypted storage - Use EncryptedSharedPreferences, never plain SharedPrefs
- Clear on logout -
permissionManager.clear()in logout flow - Franchise isolation - Permissions scoped to franchise_id in JWT
- No permission codes in logs - Don't log full permission sets
Integration with Other Skills
dual-auth-rbac (backend) → Defines permission tables, resolution logic, middleware
↓
mobile-rbac (THIS SKILL) → Android-specific permission caching, UI gates, offline
↓
jetpack-compose-ui → PermissionGate composables follow Material 3 patterns
↓
android-development → Hilt DI, MVVM, Clean Architecture integration
Anti-Patterns
| Don't | Do Instead |
|---|---|
| Resolve permissions locally from roles | Fetch resolved set from backend |
| Store permissions in plain SharedPrefs | Use EncryptedSharedPreferences |
| Check permissions only on client | Backend MUST enforce (defense in depth) |
| Grant access when offline with no cache | Deny all (fail-secure) |
Hardcode role names (if role == "ADMIN") | Check permission codes |
| Create separate permission check per screen | Use reusable PermissionGate composable |
| Hide buttons without explanation | Show disabled state with message |
| Skip permission refresh after 403 | Auto-refresh and re-evaluate |