mobile-rbac/SKILL.md
Role-Based Access Control for Android mobile apps integrating with a multi-tenant SaaS backend. Covers permission fetching, caching in EncryptedSharedPreferences, Jetpack Compose permission gates (PermissionGate, ModuleGate, PermissionButton)...
npx skillsauth add peterbamuhigire/skills-web-dev mobile-rbacInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
mobile-rbac or would be better handled by a more specific companion skill.references only as needed.SKILL.md first, then load only the referenced deep-dive files that are necessary for the task.references/ directory for deep detail after reading the core workflow below.Mobile RBAC uses a hybrid client+server approach:
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 ←──────────────────────────────────────┘
| 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 |
| 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.
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.
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)
| 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 |
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.
@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")
}
}
@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"
)
@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
}
// 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() }
)
}
}
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
| 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 |
{
"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}
]
}
}
{
"success": false,
"message": "You do not have permission to perform this action",
"error": {
"code": "PERMISSION_DENIED",
"required_permission": "INVENTORY_PO_APPROVE"
}
}
Client response:
required_permission from error/user/permissionsval LocalPermissionManager = staticCompositionLocalOf<PermissionManager> {
error("No PermissionManager provided")
}
// In MainScaffold:
CompositionLocalProvider(LocalPermissionManager provides permissionManager) {
// All child composables access via LocalPermissionManager.current
}
permissionManager.clear() in logout flowdual-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
| 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 |
data-ai
Use when adding AI-powered analytics to a SaaS platform — semantic search over business data, natural language queries, trend detection, anomaly alerts, and AI-generated insights for dashboards. Covers embeddings, NL2SQL, and per-tenant analytics...
data-ai
Design AI-powered analytics dashboards — what metrics to show, how to display AI predictions and confidence, drill-down patterns, KPI cards, trend visualisation, AI Insights panels, export design, and role-based dashboard variants. Invoke when...
development
Use when designing, building, reviewing, or upgrading production software systems that must be secure, performant, maintainable, scalable, and user-centered. Apply before writing specs, code, architecture, APIs, databases, mobile apps, SaaS platforms, or ERP systems.
development
Professional web app UI using commercial templates (Tabler/Bootstrap 5) with strong frontend design direction when needed. Use for CRUD interfaces, dashboards, admin panels with SweetAlert2, DataTables, Flatpickr. Clone seeder-page.php, use...