Roles & Permissions¶
Last reviewed: 2026-08-04
This page describes the authorisation model as it exists in code. It was written
by reading app/Support/Authorization/AdminTier.php, the app/Http/Middleware/EnsureIs*.php
gates, routes/api.php, and the users / RBAC tables in
database/schema/mysql-schema.sql — not from prior documentation. The source code
remains authoritative; where behaviour and this page disagree, the code is right
and this page is a bug.
Timebanking vocabulary varies between networks. Project NEXUS deliberately uses generic role names so it can serve communities in any country; the first table below maps the common UK terms onto them.
The five tiers¶
| Common term | NEXUS role / flag | Gate |
|---|---|---|
| member | role = 'member' |
— (default) |
| broker / coordinator | role = 'broker', role = 'coordinator' |
app/Http/Middleware/EnsureIsBrokerOrAdmin.php |
| administrator | role = 'admin', role = 'tenant_admin', is_admin |
app/Http/Middleware/EnsureIsAdmin.php |
| network administrator | is_tenant_super_admin (+ Hub/sub-tenant hierarchy) |
EnsureIsAdmin, scoped by app/Services/TenantHierarchyService.php |
| platform administrator | is_super_admin, role = 'god' |
app/Http/Middleware/EnsureIsSuperAdmin.php |
A broker is not a junior administrator¶
This is the most commonly misread part of the model. AdminTier::ROLES contains
only admin, tenant_admin, super_admin, god. AdminTier::OPERATIONAL_ROLES
contains broker and coordinator, and AdminTier::allows() explicitly returns
false for them before it checks anything else — so a stray legacy admin flag on
a broker's row does not promote them.
Brokers are deliberately refused the generic /v2/admin/* surface (tenant
settings, federation controls, tenant CRUD). They get their own routes and their
own application.
What each tier adds¶
- Member — their own content, their own exchanges, their own wallet.
- Broker / coordinator — the community-operations role: approve members, moderate listings and content, approve exchanges that need broker sign-off, manage safeguarding assignments and vetting attestations, adjust a member's balance. Scoped to one tenant.
- Administrator — everything a broker can do, plus tenant configuration: module and feature flags, categories, legal documents, registration policy, pages and branding. Scoped to one tenant. Cannot move users between tenants or grant platform privileges.
- Network administrator (
is_tenant_super_admin) — an administrator whose scope extends to their tenant and its sub-tenants. Only grantable where the parent tenant hasallows_subtenants. Accepted byEnsureIsAdminand, since 2026-08-05, by the subtree tier of the super panel (EnsureSuperPanelAccess). Still not accepted byEnsureIsSuperAdmin— a compromised network admin cannot become a platform compromise. See "The panel has two tiers" below. - Platform administrator — cross-tenant: tenant CRUD, moving users between
tenants, platform-wide federation controls, the super-admin panel.
godis the break-glass tier and is the only one with an unconditional allow.
Hierarchy scoping in the super-admin panel¶
The super-admin panel is not all-or-nothing. app/Core/SuperPanelAccess.php
resolves an access level and every cross-tenant action is checked against it:
| Who | Level | Scope |
|---|---|---|
is_super_admin / is_god / role = 'super_admin'|'god' |
master |
Platform-global, wherever their account sits |
is_tenant_super_admin on the master tenant (id 1) |
master |
Platform-global |
is_tenant_super_admin on a hub tenant (allows_subtenants = 1) |
regional |
That tenant and its descendants only |
| anyone else | none |
No panel |
SuperPanelAccess::canAccessTenant() implements the subtree test as a
materialised-path prefix match (str_starts_with($target->path, $access['tenant_path'])),
and getScopeClause() gives the equivalent path LIKE ? predicate for list
queries.
🔴
tenants.pathis nullable, and an empty prefix means EVERYTHING, not nothing.str_starts_with($x, '')istrueandLIKE '%'matches every row. Aregionalgrant therefore REQUIRES a usable path:getAccess()refuses otherwise,canAccessTenant()andgetScopeClause()fail closed, andsubtreeFilter()exists so the six list call-sites cannot repeat the old fail-open idiom (if (regional && !empty(path)) { filter }applied no filter when the path was empty). Do not make the columnNOT NULL— the path is set in a second UPDATE after insert, because it is built from the row's own id. Tests:tests/Laravel/Feature/SuperAdmin/SubtreeBoundaryEmptyPathTest.php.
The panel has two tiers (2026-08-05)¶
Endpoints are split by what the power reaches, not by convenience:
| Tier | Gate | Contents |
|---|---|---|
| A — subtree | super-panel (EnsureSuperPanelAccess) |
Tenant/user lists, hierarchy, dashboard, audit, and the tenant/user mutations — all of which confine themselves via canAccessTenant() or subtreeFilter(). Admits master and regional. |
| B — platform only | super-admin (EnsureIsSuperAdmin) |
Platform revenue/pricing, external-federation kill switches, platform capabilities, provisioning queue, granting PLATFORM super-admin, and tenant delete/purge. master only. |
🔴 Two reasons something is in tier B, and both matter:
- The power is platform-wide. Granting PLATFORM super-admin especially — that is the escape hatch out of one's own branch.
- It is not yet safe for a regional caller. The billing endpoints read
tenant_idfrom the request body and check onlyrequireSuperAdmin(), with nocanAccessTenant(). Add that check before ever moving one to tier A.
Tenant delete/purge stay in tier B by choice despite scoping correctly: they are irreversible, and building a network needs create/move, not destroy.
Frontend. GET /v2/users/me returns super_panel_level
(master/regional/none), resolved server-side — the UI must not infer it from
flags, because eligibility also depends on allows_subtenants and the path.
SuperAdminRoute admits both tiers; PlatformOnlyRoute guards the tier-B screens
so a bookmarked URL refuses cleanly instead of rendering a page whose every request
403s; and SuperAdminSidebar hides the tier-B sections. Isolation is proven by
tests/Laravel/Feature/SuperAdmin/RegionalPanelIsolationTest.php.
This is what keeps a network administrator inside their own network. A hub
tenant's super admin sees and acts on their own tenant and the children beneath
it, and nothing else. Moving a user is checked at both ends — source tenant
and destination tenant — in AdminSuperController::userMoveTenant(), so a
network administrator cannot move a member out of, or into, someone else's
hierarchy. The platform (god/master) tier is unrestricted by design.
⚠️ The authorisation on moving a user is correct. What the move does to the member's data is not.
User::moveTenant()updatesusers.tenant_idand nothing else, so the member's balance travels with them while their transaction history, listings, group memberships and messages stay behind in the old tenant. See DATABASE.md and the caveat below before using this on real data.
Self-dealing guards¶
Broker-tier actions carry conflict-of-interest checks in the controller, not just
the middleware: a broker cannot moderate content they are a party to, cannot
resolve a report they filed, and cannot approve a match they submitted. A broker
also cannot adjust their own balance. Regression tests live in
tests/Laravel/Feature/Controllers/BrokerModerationAuthorizationTest.php and
BrokerMatchApprovalAuthorizationTest.php.
The broker application¶
Brokers have a dedicated interface at react-frontend/src/broker/, separate from
the admin panel. It covers: Dashboard, Members, Onboarding, Exchanges, Match
Approvals, Messages / Message Review, Content / Comment / Feed / Review
moderation, Risk Tags, Safeguarding, Vetting, Insurance Certificates, User
Monitoring, Reports and Archive.
Exchange sign-off is part of the exchange state machine rather than bolted on:
exchange_requests carries a pending_broker status alongside
broker_approved_at, broker_notes and broker_conditions.
Two authorisation systems coexist¶
1. Role string plus boolean flags on users — the live system for
tenant/platform tiers. users.role plus is_admin, is_super_admin,
is_tenant_super_admin, is_god, is_approved. AdminTier is the canonical
predicate; prefer it over reading columns directly.
2. A permission/RBAC schema — roles, permissions, role_permissions,
user_roles, user_permissions. user_roles.scope_organization_id supports
per-organisation scoped roles. This system is used narrowly today, for a specific
set of permissions rather than as a general replacement for tier 1.
Permission slugs that are genuinely enforced in code today:
| Slug | Enforced in |
|---|---|
safeguarding.manage, safeguarding.view |
AdminSafeguardingController, CaringCommunity\SafeguardingService |
volunteering.hours.review |
VolunteerService |
national.kiss_dashboard.view |
Admin/NationalKissDashboardController |
verein.members.import |
EnsureIsAdmin (the one permission-based bypass of the admin gate), AdminCaringCommunityController |
verein.dues.manage, verein.members.manage |
Verein/VereinDuesAdminController |
Role presets for hierarchical deployments are installed by
app/Services/CaringCommunityRolePresetService.php (national_admin,
canton_admin, municipality_admin, cooperative_coordinator,
organisation_coordinator, trusted_reviewer).
Known caveats¶
These are real and worth knowing before you write an authorisation check.
- Some role strings are never written. The admin API only ever assigns
member,adminorbrokertousers.role.super_admin,god,tenant_adminandcoordinatorare checked in code but are expressed through boolean flags (or not granted at all). A gate that tests only the role string will under-authorise a real platform administrator. Always go throughAdminTier, or check the flags alongside the string. - Most declared RBAC permission slugs are not enforced. The permission
catalogue offered in the role editor is much larger than the enforced set listed
above. A slug being grantable does not mean anything checks it — verify before
relying on one.
members.assisted_onboardingis a notable example: it is granted by four role presets and checked nowhere. is_adminis legacy. The column is still honoured byAdminTier, but it is marked deprecated in the schema and has no granting endpoint. Prefer roles.- Acting on behalf of another member is a separate subject with its own mechanisms and its own gaps — see SAFEGUARDING-AND-CONSENT.md.
- Moving a user between tenants does not move their data.
User::moveTenant()changesusers.tenant_id, revokes sessions and deletes passkeys (which are RP-ID-scoped, so they cannot survive the move) — and touches nothing else. Every other tenant-scoped table keeps the oldtenant_id. Becauseusers.balanceis a column onusers, the member arrives in the destination tenant with their balance intact but no transaction history behind it, while the origin tenant retains that history. There is no repair tooling. The API response fieldsrecords_movedandtables_faileddescribe the single-row update and a passkey precondition respectively — they do not report a multi-table migration, because none happens. Treat the feature as "reassign an account", not "transfer a member".
Adding an authorisation check¶
- Use the existing middleware where you can:
admin,broker-or-admin, or the super-admin group. Route-level gating is the norm inroutes/api.php. - In a controller, use the
BaseApiControllerhelpers (requireAdmin(),requireBrokerOrAdmin()) rather than readingusers.roleyourself. - If the action can create a conflict of interest, add a self-dealing guard and a test for it.
- If you add a genuinely new capability, prefer extending the tier model over adding an unenforced permission slug.