ACLS(7) RSpade Developer Manual ACLS(7) NAME acls - Role-based access control with supplementary permissions SYNOPSIS PHP (server-side): Permission::has_permission(User_Model::PERM_MANAGE_SITE_USERS) Permission::has_role(User_Model::ROLE_SITE_ADMIN) $user->has_permission(User_Model::PERM_EDIT_DATA) $user->get_resolved_permissions() // All permissions as array $user->can_admin_role($target_user->role_id) JavaScript (client-side): Permission.has_permission(User_Model.PERM_EDIT_DATA) Permission.has_any_permission([User_Model.PERM_EDIT_DATA, User_Model.PERM_VIEW_DATA]) Permission.has_all_permissions([...]) Permission.has_role(User_Model.ROLE_MANAGER) Permission.can_admin_role(User_Model.ROLE_USER) DESCRIPTION RSpade provides a role-based access control (RBAC) system where: 1. Users have a primary role on their site membership (users.role_id) 2. Roles grant a predefined set of permissions 3. Supplementary permissions can GRANT or DENY specific permissions per-user 4. Permission checks resolve: role grants → DENY override → GRANT override Key design principles: Identity vs Membership login_users = identity (email, password, one per person) users = site membership (role, permissions, many per login_user) Roles and permissions attach to site memberships (users table), not login identities. One person can have different roles on different sites. Integer Constants All roles and permissions are integer constants, not strings. This provides type safety, IDE autocompletion, and works with the RSX enum system for magic properties. Hierarchical Roles Roles are hierarchical. Higher roles inherit all permissions from lower roles. Role IDs are ordered by privilege level (lower ID = more privilege). Supplementary Permissions Individual users can have permissions granted or denied beyond their role defaults. DENY always wins over role grants. GRANT adds permissions the role doesn't provide. ARCHITECTURE Database Tables users (site membership) role_id Primary role for this site membership ... Other membership fields user_permissions (supplementary) user_id FK to users permission_id Which permission constant is_grant 1 = GRANT, 0 = DENY Permission Resolution Order 1. Check if user role is DISABLED → deny all 2. Check user_permissions for explicit DENY → deny if found 3. Check user_permissions for explicit GRANT → allow if found 4. Check role's default permissions → allow if included 5. Deny (permission not granted) Role Hierarchy ID Constant Label Can Admin Roles --- -------- ----- --------------- 100 ROLE_DEVELOPER Developer 200-800 (system only) 200 ROLE_ROOT_ADMIN Root Admin 300-800 (system only) 300 ROLE_SITE_OWNER Site Owner 400-800 400 ROLE_SITE_ADMIN Site Admin 500-800 500 ROLE_MANAGER Manager 600-800 600 ROLE_USER User (none) 700 ROLE_VIEWER Viewer (none) 800 ROLE_DISABLED Disabled (none) IDs are 100-based for future expansion. Lower ID = higher privilege. "Can Admin Roles" prevents privilege escalation (Site Admin can't create Site Owner). Developer and Root Admin are system-assigned only. PERMISSIONS Core Permissions (granted by role) ID Constant Granted By Default To -- -------- --------------------- 1 PERM_MANAGE_SITES_ROOT Developer, Root Admin only 2 PERM_MANAGE_SITE_BILLING Site Owner+ 3 PERM_MANAGE_SITE_SETTINGS Site Admin+ 4 PERM_MANAGE_SITE_USERS Site Admin+ 5 PERM_VIEW_USER_ACTIVITY Manager+ 6 PERM_EDIT_DATA User+ 7 PERM_VIEW_DATA Viewer+ Supplementary Permissions (not granted by any role by default) ID Constant Purpose -- -------- ------- 8 PERM_API_ACCESS Allow API key creation/usage 9 PERM_DATA_EXPORT Allow bulk data export Role-Permission Matrix Permission Dev Root Owner Admin Mgr User View Dis ---------- --- ---- ----- ----- --- ---- ---- --- MANAGE_SITES_ROOT X X MANAGE_SITE_BILLING X X X MANAGE_SITE_SETTINGS X X X X MANAGE_SITE_USERS X X X X VIEW_USER_ACTIVITY X X X X X EDIT_DATA X X X X X X VIEW_DATA X X X X X X X API_ACCESS - - - - - - - DATA_EXPORT - - - - - - - Legend: X = granted by role, - = must be granted individually MODEL IMPLEMENTATION User_Model Definition class User_Model extends Rsx_Model_Abstract { // Role constants (100-based, lower = higher privilege) const ROLE_DEVELOPER = 100; const ROLE_ROOT_ADMIN = 200; const ROLE_SITE_OWNER = 300; const ROLE_SITE_ADMIN = 400; const ROLE_MANAGER = 500; const ROLE_USER = 600; const ROLE_VIEWER = 700; const ROLE_DISABLED = 800; // Permission constants const PERM_MANAGE_SITES_ROOT = 1; const PERM_MANAGE_SITE_BILLING = 2; const PERM_MANAGE_SITE_SETTINGS = 3; const PERM_MANAGE_SITE_USERS = 4; const PERM_VIEW_USER_ACTIVITY = 5; const PERM_EDIT_DATA = 6; const PERM_VIEW_DATA = 7; const PERM_API_ACCESS = 8; const PERM_DATA_EXPORT = 9; public static $enums = [ 'role_id' => [ 300 => [ 'constant' => 'ROLE_SITE_OWNER', 'label' => 'Site Owner', 'permissions' => [2, 3, 4, 5, 6, 7], 'can_admin_roles' => [400, 500, 600, 700, 800], ], // ... additional roles ], ]; // Get all resolved permissions (role + supplementary applied) public function get_resolved_permissions(): array { if ($this->role_id === self::ROLE_DISABLED) { return []; } $permissions = $this->role_id__permissions ?? []; // Add supplementary GRANTs, remove supplementary DENYs // ... (see User_Model for full implementation) return $permissions; } public function has_permission(int $permission): bool { return in_array($permission, $this->get_resolved_permissions(), true); } public function can_admin_role(int $role_id): bool { return in_array($role_id, $this->role_id__can_admin_roles ?? [], true); } } Magic Properties (via enum system, BEM-style double underscore) $user->role_id__label // "Site Admin" $user->role_id__permissions // [3,4,5,6,7] $user->role_id__can_admin_roles // [400,500,600,700,800] PERMISSION CLASS API Static Methods (use current session user) Permission::has_permission(int $permission): bool Check if current logged-in user has permission. Returns false if not logged in or no site selected. if (Permission::has_permission(User_Model::PERM_MANAGE_SITE_USERS)) { // Show user management UI } Permission::has_role(int $role_id): bool Check if current user has at least the specified role. "At least" means same or higher privilege (lower role_id). if (Permission::has_role(User_Model::ROLE_SITE_ADMIN)) { // User is Site Admin or higher (Owner, Root) } Permission::get_user(): ?User_Model Get current user's site membership record. Returns null if not logged in or no site selected. $user = Permission::get_user(); if ($user && $user->has_permission(User_Model::PERM_EDIT_DATA)) { // ... } Instance Methods (on User_Model) $user->has_permission(int $permission): bool Check if this specific user has permission. $user->get_resolved_permissions(): array Get all resolved permission IDs for this user. Applies role defaults + grants - denies. $user->can_admin_role(int $role_id): bool Check if user can create/edit users with given role. $user->has_supplementary_grant(int $permission): bool Check if user has explicit GRANT for permission. $user->has_supplementary_deny(int $permission): bool Check if user has explicit DENY for permission. JAVASCRIPT PERMISSION CLASS The Permission class provides client-side permission checking using pre-resolved permissions from window.rsxapp.resolved_permissions. This array is computed server-side and includes role defaults with supplementary grants added and denies removed, ensuring JS checks match PHP exactly. Static Methods Permission.is_logged_in(): boolean Check if user is authenticated. if (Permission.is_logged_in()) { // Show authenticated UI } Permission.get_user(): Object|null Get current user object from rsxapp. Permission.has_permission(permission): boolean Check if user has specific permission. if (Permission.has_permission(User_Model.PERM_EDIT_DATA)) { // Show edit button } Permission.has_any_permission(permissions): boolean Check if user has ANY of the listed permissions. if (Permission.has_any_permission([ User_Model.PERM_EDIT_DATA, User_Model.PERM_VIEW_DATA ])) { // User can view or edit } Permission.has_all_permissions(permissions): boolean Check if user has ALL of the listed permissions. if (Permission.has_all_permissions([ User_Model.PERM_MANAGE_SITE_USERS, User_Model.PERM_VIEW_USER_ACTIVITY ])) { // User can manage users AND view activity } Permission.has_role(role_id): boolean Check if user has at least the specified role level. Lower role_id = higher privilege. if (Permission.has_role(User_Model.ROLE_MANAGER)) { // User is Manager or higher (Admin, Owner, etc.) } Permission.can_admin_role(role_id): boolean Check if user can administer users with given role. if (Permission.can_admin_role(User_Model.ROLE_USER)) { // Show role assignment dropdown including User role } Permission.get_resolved_permissions(): number[] Get array of all permission IDs the user has. Data Source The Permission class reads from window.rsxapp.user.resolved_permissions, which is populated via User_Model::toArray() from get_resolved_permissions(). Returns empty array if user is not authenticated. ROUTE PROTECTION Using #[Auth] Attribute Standard permission checks work with #[Auth]: #[Route('/settings/users')] #[Auth('Permission::authenticated()')] public static function users(Request $request, array $params = []) { // Manual permission check inside route if (!Permission::has_permission(User_Model::PERM_MANAGE_SITE_USERS)) { return response_error(Ajax::ERROR_UNAUTHORIZED); } // ... } Custom Permission Methods Define reusable permission checks in rsx/permission.php: class Permission { public static function can_manage_users(): bool { return self::has_permission(User_Model::PERM_MANAGE_SITE_USERS); } public static function can_edit(): bool { return self::has_permission(User_Model::PERM_EDIT_DATA); } } Usage in routes: #[Route('/users/create')] #[Auth('Permission::can_manage_users()')] public static function create(Request $request, array $params = []) { // Guaranteed to have PERM_MANAGE_SITE_USERS } SUPPLEMENTARY PERMISSIONS Purpose Supplementary permissions allow per-user exceptions to role defaults: - GRANT: Give a permission the role doesn't include - DENY: Remove a permission the role normally includes Common use cases: - Grant API access to specific users regardless of role - Deny export access to a user who normally has it - Temporary elevated permissions during onboarding Database Table user_permissions id BIGINT PRIMARY KEY user_id BIGINT NOT NULL (FK to users) permission_id INT NOT NULL is_grant TINYINT(1) NOT NULL (1=GRANT, 0=DENY) created_at TIMESTAMP updated_at TIMESTAMP UNIQUE KEY (user_id, permission_id) Management API // Grant a permission User_Permission_Model::grant($user_id, User_Model::PERM_API_ACCESS); // Deny a permission User_Permission_Model::deny($user_id, User_Model::PERM_DATA_EXPORT); // Remove supplementary (revert to role default) User_Permission_Model::remove($user_id, User_Model::PERM_API_ACCESS); // Get all supplementary permissions for user $supplementary = User_Permission_Model::for_user($user_id); Resolution Priority DENY always wins. Order of precedence: 1. Explicit DENY → permission denied 2. Explicit GRANT → permission granted 3. Role default → permission granted if in role 4. Not granted → permission denied Example: User is Site Admin (has PERM_MANAGE_SITE_USERS by role) - No supplementary → has permission (from role) - GRANT added → has permission (redundant but harmless) - DENY added → NO permission (DENY overrides role) - Both GRANT and DENY → NO permission (DENY wins) EXAMPLES Example 1: Check Permission in Controller #[Ajax_Endpoint] #[Auth('Permission::authenticated()')] public static function delete_user(Request $request, array $params = []) { if (!Permission::has_permission(User_Model::PERM_MANAGE_SITE_USERS)) { return response_error(Ajax::ERROR_UNAUTHORIZED, 'Cannot manage users'); } $target_id = $params['user_id']; $target = User_Model::find($target_id); // Check can admin this user's role $current = Permission::get_user(); if (!$current->can_admin_role($target->role_id)) { return response_error(Ajax::ERROR_UNAUTHORIZED, 'Cannot modify this user'); } $target->delete(); return ['success' => true]; } Example 2: Conditional UI Based on Permissions // In controller, pass permissions to view return rsx_view('Settings_Users', [ 'can_create' => Permission::has_permission(User_Model::PERM_MANAGE_SITE_USERS), 'can_export' => Permission::has_permission(User_Model::PERM_DATA_EXPORT), ]); // In jqhtml template <% if (this.args.can_create) { %> <% } %> Example 3: Role Assignment Validation #[Ajax_Endpoint] public static function update_user_role(Request $request, array $params = []) { $target = User_Model::find($params['user_id']); $new_role_id = $params['role_id']; $current = Permission::get_user(); // Can't change own role if ($target->id === $current->id) { return response_error(Ajax::ERROR_VALIDATION, 'Cannot change own role'); } // Must be able to admin both current and new role if (!$current->can_admin_role($target->role_id)) { return response_error(Ajax::ERROR_UNAUTHORIZED, 'Cannot modify this user'); } if (!$current->can_admin_role($new_role_id)) { return response_error(Ajax::ERROR_UNAUTHORIZED, 'Cannot assign this role'); } $target->role_id = $new_role_id; $target->save(); return ['success' => true]; } Example 4: Grant Supplementary Permission // Admin grants API access to a regular user $user = User_Model::find($user_id); // Verify current user can admin this user if (!Permission::get_user()->can_admin_role($user->role_id)) { throw new Exception('Unauthorized'); } User_Permission_Model::grant($user->id, User_Model::PERM_API_ACCESS); // User now has API access despite being a regular User role Example 5: Check Multiple Permissions // User needs EITHER permission $can_view = Permission::has_permission(User_Model::PERM_VIEW_DATA) || Permission::has_permission(User_Model::PERM_EDIT_DATA); // User needs BOTH permissions $can_admin = Permission::has_permission(User_Model::PERM_MANAGE_SITE_SETTINGS) && Permission::has_permission(User_Model::PERM_MANAGE_SITE_USERS); ADDING NEW PERMISSIONS 1. Add constant to User_Model: const PERM_NEW_FEATURE = 10; 2. Add to role definitions in $enums if role should grant it: 400 => [ // ROLE_SITE_ADMIN 'permissions' => [ // ... existing self::PERM_NEW_FEATURE, ], ], 3. Run rsx:migrate:document_models to regenerate stubs 4. Use in code: if (Permission::has_permission(User_Model::PERM_NEW_FEATURE)) { // ... } ADDING NEW ROLES 1. Add constant (maintain hierarchy order, 100-based): const ROLE_SUPERVISOR = 450; // Between Admin (400) and Manager (500) 2. Add to $enums with permissions and can_admin_roles: 450 => [ 'constant' => 'ROLE_SUPERVISOR', 'label' => 'Supervisor', 'permissions' => [ self::PERM_VIEW_USER_ACTIVITY, self::PERM_EDIT_DATA, self::PERM_VIEW_DATA, ], 'can_admin_roles' => [500, 600, 700, 800], ], 3. Update can_admin_roles for roles above: 400 => [ // ROLE_SITE_ADMIN 'can_admin_roles' => [450, 500, 600, 700, 800], // Add new role ID ], 4. Run migration if role_id column needs updating 5. Run rsx:migrate:document_models FRAMEWORK IMPLEMENTATION DETAILS This section is for framework developers modifying the ACL system. Core Files rsx/models/user_model.php Role and permission constants $enums definition with role metadata has_permission(), can_admin_role() methods Supplementary permission lookup methods rsx/permission.php Permission class with static helper methods has_permission(), has_role(), get_user() Custom permission methods for #[Auth] rsx/models/user_permission_model.php Supplementary permissions CRUD grant(), deny(), remove() static methods Session Integration Permission::get_user() retrieves current site membership: 1. Get login_user_id from Session::get_user_id() 2. Get site_id from Session::get_site_id() 3. Query users WHERE login_user_id AND site_id 4. Cache result for request duration Caching Strategy Supplementary permissions are loaded once per request: 1. First has_permission() call loads all user_permissions 2. Stored in User_Model instance property 3. Subsequent checks use cached data 4. No cache invalidation needed (request-scoped) Enum Integration The $enums system provides magic properties: $user->role_permissions // Array from enum definition $user->role_can_admin_roles // Array from enum definition $user->role_label // String label These are resolved via Rsx_Model_Abstract::__get() FUTURE ENHANCEMENTS Attribute-Based Permission Checks Future goal: Declare permissions directly in route attributes. #[Route('/settings/users')] #[Auth('Permission::authenticated()')] #[RequiresPermission(User_Model::PERM_MANAGE_SITE_USERS)] public static function users(...) This section will be replaced with implementation details when attribute-based permission checking is complete. Custom Filters Future goal: Allow permission modifications based on context. Use cases: - Site-level feature toggles (site disables user management) - Subscription limits (free plan removes export permission) - Time-based access (permission valid only during hours) - Feature flags (A/B testing permission-gated features) Architecture concept: Role Permissions ↓ Custom Filters (modify permission set) ↓ Supplementary GRANT/DENY ↓ Final Permission Decision Filters would be registered callbacks that receive the permission set and context, returning modified permissions. This allows dynamic permission modification without changing role definitions. This section will be replaced with implementation details when custom filters are implemented. MIGRATION FROM LEGACY If upgrading from a system without ACLs: 1. Add role_id column to users table (default ROLE_USER) 2. Create user_permissions table 3. Assign appropriate roles to existing users 4. Add Permission checks to sensitive routes 5. Test with rsx:debug --user_id= to verify SEE ALSO auth - Authentication system (login, sessions, invitations) enums - Enum system for role/permission metadata routing - Route protection with #[Auth] attribute session - Session management and user context rsxapp - Global JS object containing resolved_permissions RSpade 1.0 January 2026 ACLS(7)