03.02 The Permission Model
SimpleRisk's permissions, roles, teams, and the admin flag — what the data model looks like, how check_permission() runs at request time via session-cached values, and the relationship between permission grants, role assignments, and team membership.
Why this matters
Permissions are how SimpleRisk decides which user can do what. Every action — submitting a risk, approving a mitigation, viewing a control test, deactivating an Extra — runs through a permission check before it commits. Understanding the model isn't optional; the operator who manages user access has to know which knob produces which behavior, and the model has more than one knob.
The other thing worth knowing: the model has layers. There's the catalog of available permissions, the assignment of permissions to users (directly or through roles), the team-membership filter that scopes which records a user can see, the global admin flag that overrides everything, and the per-Extra optional gates (Separation Extra, Organizational Hierarchy Extra) that add finer-grained constraints on top. An operator debugging "why can this user see this thing?" needs to know which layer to look at.
The third thing: SimpleRisk's permission system is session-cached. At login time, set_user_permissions() loads the user's full permission set into $_SESSION keyed by permission name. Subsequent runtime check_permission() calls just check the session — they don't re-query the database per request. This makes the model fast at runtime but means permission changes don't take effect for already-logged-in users until they log out and back in. Operators who change a user's permissions while the user is logged in shouldn't be surprised that the user keeps seeing the old behavior until their next login.
The fourth thing: the model has an admin override. A user with user.admin = 1 bypasses all permission checks. This is the right shape for the operator account, but it means the operator's view of "what this user can see" is not representative of what a non-admin user sees. To verify a permission configuration, log in as a test non-admin user and see what they actually see, not just what the admin role would predict.
How frameworks describe this
Permission models — also called "access control models" — are a well-studied area of information security. The major frameworks all have something to say.
- NIST SP 800-53 AC-2 (Account Management), AC-3 (Access Enforcement), and AC-6 (Least Privilege) are the federal canon. AC-3 mandates "the information system enforces approved authorizations for logical access to information and system resources in accordance with applicable access control policies." AC-6 mandates the principle of least privilege — grant the minimum access necessary, no more.
- NIST CSF v2.0 under Protect.AC (Identity Management, Authentication, and Access Control) covers the same ground at higher altitude: identities are managed, authentication is required, access is granted based on risk and need.
- ISO/IEC 27001 Annex A
A.5.15(Access control) andA.5.18(Access rights) cover the policy and the operational mechanics. The standard expects documented access policies and periodic access reviews. - PCI DSS v4.0 Requirements 7 and 8 cover access control specifically — Requirement 7 mandates restricting access by need-to-know; Requirement 8 covers user identification and authentication.
The takeaway across all four: permission models should default to least privilege, track who has what access (through the data model and through audit logs), and be reviewable on cadence.
How SimpleRisk implements this
The data model
SimpleRisk's permission system uses several related tables in the SimpleRisk database:
permissions— the catalog of available permission keys. Each row has anid,key(the programmatic identifier likesubmit_risksorgovernance),name(the human-readable display label like "Able to Submit New Risks"),description, andorder(display ordering).permission_to_user— the direct user-to-permission grant table. Composite primary key on(permission_id, user_id). A row in this table means the user has that permission directly granted.role— the catalog of named roles. Each row has avalue(the role's identifier),name,adminflag, anddefaultflag (which role new users get by default).role_responsibilities— the role-to-permission mapping. Composite primary key on(role_id, permission_id). A row in this table means users with that role have that permission via the role.user— the user table itself, withrole_id(the user's assigned role) andadmin(the global admin flag) plus the standard user fields (username, email, etc.).permission_groupsandpermission_to_permission_group— UI organization for the permissions, grouping them into named bundles for the user-management interface. Doesn't affect runtime checks.
A user's effective permission set is the union of:
- Permissions granted directly via
permission_to_user. - Permissions granted via the user's role through
role_responsibilities. - (If
user.admin = 1) every permission in the system.
The runtime check
The permission check runs at session-load time, not per-request:
- User logs in. The authentication code calls
set_user_permissions($user_id)(insimplerisk/includes/permissions.php). set_user_permissions()queries the user's effective permission set (the union above) and writes each permission's key into$_SESSIONwith value1. So$_SESSION['submit_risks'] = 1if the user hassubmit_risks.- At runtime, code that needs to check a permission calls
check_permission('submit_risks')(orenforce_permission('submit_risks')for a hard gate that redirects if denied). The check is justisset($_SESSION['submit_risks']) && $_SESSION['submit_risks'] == 1. - The session lives until the user logs out, the session times out, or the operator force-clears the session.
Implications:
- Permission changes don't take effect until next login. Granting or revoking a permission while the user is logged in won't change what they can see in the current session. The user has to log out and back in.
- Session-cached permissions can drift from the database. If the database changes and the user doesn't re-login, the database is the source of truth but the session is what the runtime checks read. For situations requiring immediate effect (revoking access for a terminated employee), force the user's session to invalidate (the standard pattern is to delete their session record from the database).
- The runtime check is fast. No database round-trip per permission check; just a session-array lookup.
The admin flag
The user.admin column on the user record is a global override. When set to 1, the user's permission checks all return true regardless of what's in permission_to_user or role_responsibilities. This is appropriate for operator accounts and for break-glass scenarios; it shouldn't be the default for routine user accounts.
In code, the admin check is built into check_permission() — admins always pass.
How permissions are granted to users
Three patterns:
- Direct grant: write a row to
permission_to_userfor the user and the specific permission. Surface: the per-user permission editor in the user-management UI. - Role-based grant: assign the user a
role_idwhose role has the permission viarole_responsibilities. Surface: the role picker on the user form, plus the role-management UI for editing what each role grants. - Admin override: set
user.admin = 1. The user has all permissions globally.
For the rationale on which pattern to use for which kind of user, see Managing Users, Teams, and Roles.
Teams as a separate concept
Teams aren't permissions. Team membership controls which records a user can see (team-based segregation, see Team-Based Segregation) but doesn't grant or deny actions per se. A user with submit_risks permission can submit risks regardless of team membership; team membership determines which existing risks are visible to them.
The team data model:
team— the catalog of teams. Each row has avalue(id) andname.user_to_team— the user-to-team join. Composite primary key on(user_id, team_id). A row means the user is a member of that team.risk_to_team— the risk-to-team join. A row means the risk is assigned to that team.- Similar
tables for assets, mitigations, and other team-scoped entities._to_team
A user with submit_risks plus team membership of "Engineering" can submit risks (the permission grants the action) and will see risks assigned to "Engineering" teams (the team membership filters the visible-records set).
How Extras add to the model
Several Extras add additional gating layers on top of the Core permission model:
- The Separation Extra (sometimes called "Team-Based Separation") adds fine-grained role-based visibility — whether risk owners, owner's managers, submitters, additional stakeholders, team members, etc. can see each other's risk details. The
team_separationsetting toggles this on as an all-or-nothing mode; once on, ~15 finer-grained settings control the specifics. See Separation of Duties. - The Organizational Hierarchy Extra adds business-unit segregation on top of teams. Users can be scoped to specific business units; reporting can roll up by business unit. See Organizational Hierarchy.
- Extra-specific permissions: many Extras add their own permissions (e.g., the Incident Management Extra adds 15
im_*permissions, the Vulnerability Management Extra adds thevm_*permissions). These follow the samepermissions/permission_to_user/role_responsibilitiespattern as Core permissions.
The full catalog of permissions across Core and the Extras is in Permission Reference.
Common pitfalls
A handful of patterns recur with the permission model.
-
Granting admin too liberally. The
user.admin = 1flag is a global override; users with the flag bypass every permission check. It's appropriate for operator accounts; it's wrong for routine user accounts. Audit the admin-flagged users periodically; revoke the flag for anyone who doesn't actively need it. -
Changing permissions and being surprised they don't take effect immediately. The session cache means the user has to log out and back in for permission changes to apply. Document this in the user-management workflow; for urgent revocations, force the user's session to invalidate.
-
Confusing teams with permissions. Team membership controls which records a user can see; permissions control which actions the user can take. A user can have all the permissions in the world and still see only their team's data; a user can see all data and still be unable to act on it. Reason about the two layers separately.
-
Using direct per-user grants when role-based grants would scale. Direct grants via
permission_to_userare the right pattern for occasional per-user exceptions; for the standard role-based grants, define the roles inrole+role_responsibilitiesand assign users viarole_id. Using direct grants for everything produces a permission set that's hard to audit and harder to bulk-update. -
Letting the permissions catalog drift from the running install's understanding. New SimpleRisk releases sometimes add new permissions; admin users automatically get them, but role-based grants may need updating to include the new permissions for the right roles. Review role-to-permission mappings after Core upgrades.
-
Testing permission configuration only as the admin user. The admin's view of "what this user can see" doesn't reflect what the user actually sees, because admin bypasses permission checks. Use a test non-admin account to validate permission configurations.
-
Forgetting that the Extras add their own permissions. Activating an Extra usually adds its permissions to the catalog but doesn't grant them to existing roles. After activating an Extra, walk the affected roles and grant the new permissions explicitly.
-
Treating the permissions catalog as immutable. The catalog grows with releases; new permissions appear as new features ship. Track the changes via the release notes, not via the code. Updating role configurations after Core upgrades is part of the upgrade workflow.
-
Using direct grants to bypass role design. When a specific user needs an unusual access pattern, the temptation is to grant them the direct permission. Done occasionally, that's fine. Done routinely, it bypasses the role-design discipline and produces a permission landscape nobody can explain. If the same direct-grant pattern keeps recurring, it's signaling that a new role should exist to capture it.