Access Control
This document describes the access control system in Rondo Club.
Overview
Section titled “Overview”Rondo Club uses a least-privilege access model. Members can read their own household plus shared team and committee reference data. Specialist roles receive only the custom post type and REST capabilities needed for their work.
Key principles:
- Members are read-only by default - A plain member cannot create, edit, or delete club records
- Todo visibility is scoped - Users only see todos they created or todos assigned to them
- Trashed posts are hidden - Posts in the trash are not accessible via the frontend
- WP Admin is blocked - Non-admin users are redirected away from wp-admin
- Roles map from Sportlink - Sportlink “functies” are mapped to Rondo permission roles via the Functie-Capability Map
Implementation
Section titled “Implementation”The access control system is implemented in includes/class-access-control.php via the AccessControl class.
Controlled Post Types
Section titled “Controlled Post Types”Access control applies to every Rondo post type. Each type has its own primitive capability family, generated by PostTypes::capability_map():
person- Contact recordsteam- Team/company recordsrondo_todo- Todo itemsrondo_feedback- Feedbackrondo_clothing_item/rondo_clothing_txn- Clothingdiscipline_case/rondo_invoice- Fair play and financedienst_type/shift_template/dienst_shift/taakuitleg- Volunteer shifts
Do not grant Rondo roles generic edit_posts, publish_posts, or delete_posts. Those capabilities apply across post types and previously let a plain member forge invoices through the core WordPress REST API.
Hook Points
Section titled “Hook Points”The class intercepts data access at multiple levels:
| Hook | Purpose |
|---|---|
pre_get_posts | Blocks unauthenticated users from seeing any posts |
rest_{post_type}_query | Fails closed without that post type’s read capability and applies record scope |
rest_prepare_{post_type} | Enforces single-record access even for published posts |
Todo Visibility Rule
Section titled “Todo Visibility Rule”For post type rondo_todo, list and single-item access use this rule:
- Creator visibility:
post_author = current_user - Assignee visibility: post meta
assigned_user_id = current_user
This lets a user assign a todo to another user while still keeping the todo visible for themselves.
Access Check Methods
Section titled “Access Check Methods”Check if user can access a specific post:
$access_control = new Rondo\Core\AccessControl();$can_access = $access_control->user_can_access_post( $post_id, $user_id );// Returns false if: user not logged in, post trashed, or post doesn't existGet permission level:
$permission = $access_control->get_user_permission( $post_id, $user_id );// Returns: 'owner' (if user created the post), 'editor' (if logged in but not author), or falseBypassing Access Control
Section titled “Bypassing Access Control”Trusted internal system code can bypass query filtering using suppress_filters, but its caller must have an explicit capability-protected endpoint:
$query = new WP_Query([ 'post_type' => 'person', 'suppress_filters' => true, // Bypasses pre_get_posts]);Person visibility
Section titled “Person visibility”AccessControl::can_view_person( $person_id, $user_id ) is the single authority on who may see
which person. Three tiers:
| Tier | Who | Sees |
|---|---|---|
| Management | A capability in AGE_GROUP_BYPASS_CAPS | Everyone |
| Coordinator | A role with a non-empty rondo_age_group_access entry | Their configured age groups |
| Scoped member | Everyone else | Themselves, plus their children under 18 |
Every enforcement point routes through it — the REST collection filter (rest_person_query), the
single-item filter (rest_prepare_person), the raw-SQL /rondo/v1/people/filtered endpoint, and
user_can_access_post(). Do not re-derive the rule anywhere else. The narrowing itself lives in
one private helper, person_scope(), used by both the REST and pre_get_posts paths.
is_kader
Section titled “is_kader”The current-user endpoint returns is_kader: admin, or any staff capability, or any role
beyond the plain-member baseline (poule roles and admin-created coordinator roles carry no
capability of their own). router.jsx and Layout.jsx both read that one field.
Do not re-derive it in the frontend. When the router counted extra roles and the sidebar did not, a coordinator was sent to the dashboard while every link to it stayed hidden.
Permission callbacks must match the menu
Section titled “Permission callbacks must match the menu”A sidebar entry gated on a capability and an endpoint gated on manage_options produce a menu item
that opens an empty screen. That is exactly what happened to the penningmeester: Financiën is
shown to anyone with financieel, but every contributie endpoint required manage_options, so
GET /rondo/v1/membership-fees/settings returned 403 and the page rendered no seasons and no
categories.
manage_options is not a superset. Use the capability the UI uses:
| Callback | Grants |
|---|---|
check_admin_permission() | manage_options only — genuine site administration |
check_financieel_read_permission() | financieel_read, or financieel which implies it |
check_financieel_permission() | financieel — every finance write |
check_admin_or_financieel_permission() | Endpoints shared with the admin settings screens |
check_admin_or_toegangscontrole_permission() | manage_options or toegangscontrole |
financieel needs no manage_options fallback: UserRoles::register_role() adds fairplay,
vog, financieel, toegangscontrole, manage_clothing, ledenadministratie and vrijwilligers
to the administrator role, so an admin passes a capability-only gate. FeePermissionsTest pins
that down — if the grant ever disappears, admins would silently lose the Financiën section.
Finance: read and write are separate capabilities
Section titled “Finance: read and write are separate capabilities”financieel_read grants inzage; financieel grants inzage plus every mutation. Decide by verb, not
by screen: a GET that only renders data belongs behind check_financieel_read_permission(), and
anything that creates, sends, deletes or marks paid belongs behind check_financieel_permission().
Do not test current_user_can( 'financieel' ) directly outside the REST layer — call
UserRoles::can_view_finances() or UserRoles::can_manage_finances(), which encode the implication.
A read-only user reaches the facturen list, an invoice and its PDF, the contributie overview, the FinanciënKaart on a person page, and invoices in search results. They are deliberately excluded from:
- Editing people.
financieel_readis absent fromcan_edit_people(). It appears inAGE_GROUP_BYPASS_CAPSonly because invoices span the whole club — a view bypass, not an edit grant. - Settings → Financieel.
GET /rondo/v1/finance/settingscarries payment-provider configuration and stays behind the write gate.useFinanceSettings()therefore takes a{ enabled }option; any screen a read-only user can reach must pass{ enabled: false }rather than eat a 403.
The frontend mirrors this with two booleans on the current-user payload: can_access_financieel
(read or write) decides whether a screen renders, can_edit_financieel decides whether its buttons
do. FinancieelRoute guards the former, FinancieelSchrijfRoute the latter.
Adding a capability to BASE_ROLES does not reach installs where the role already exists —
add_role() is a no-op for those. Bump UserRoles::ROLES_VERSION and extend maybe_upgrade_roles(),
which backfills on init. Version 2 gave financieel_read to every role already holding financieel.
The member’s own household
Section titled “The member’s own household”GET /wp/v2/people returns exactly what the caller may see, which for a scoped member is their own
record plus their children under 18, with the ACF payload already reduced to the allowlist.
GET /rondo/v1/people/household always returns that household scope, independent of management
privileges, and is the only data source for “Mijn gegevens”.
A child falls out of their parent’s view at 18. A person with no usable birthdate is treated as an
adult: the check fails closed rather than exposing a record on a missing field. Note that ACF
date_picker stores Ymd, not Y-m-d.
Field scope
Section titled “Field scope”A scoped member reads an allowlisted subset of the ACF payload
(MEMBER_VISIBLE_ACF_FIELDS) — name, contact details, address, membership dates, leeftijdsgroep,
VOG date. Withheld: financiele-blokkade, wacht_op_overschrijving, freescout-id. The list is an
allowlist on purpose, so an ACF field added later is private until someone consciously exposes it.
Writes stay closed: restrict_person_editing() maps edit_post to do_not_allow for anyone
failing can_edit_people(), and members never pass it.
Notes, activities and timeline
Section titled “Notes, activities and timeline”user_can_access_post() backs the permission callback for /rondo/v1/people/{id}/notes,
/activities and /timeline. Until 33.30.0 it returned true for any logged-in user on any
person, so those routes were effectively unguarded. They now obey can_view_person().
Age-group narrowing
Section titled “Age-group narrowing”AccessControl::get_permitted_age_groups() returns one of three things, and the distinction
matters:
| Return | Meaning | Who |
|---|---|---|
null | No restriction — sees everyone | Users with a management capability (manage_options, fairplay, vog, financieel, financieel_read, toegangscontrole, manage_clothing, ledenadministratie) |
[] | Scoped to their own household | Any other user, including plain members |
['Onder 11', …] | Sees only these age groups | A role listed in the rondo_age_group_access option, e.g. a coordinator |
Non-management users default to deny: an unconfigured role reaches no people beyond its own household.
The former suppress_age_group request bypass has been removed. The Kaderlijst now has a dedicated server-side scoped endpoint.
User Roles
Section titled “User Roles”Rondo Club creates a custom user role called “Rondo User” (rondo_user) on theme activation.
The base role carries only read. UserRoles::sync_role_capabilities() derives dedicated CPT capabilities from business capabilities such as financieel, vog, fairplay, manage_clothing, sponsorbeheer, and vrijwilligers. It also removes legacy generic write and upload capabilities from non-administrators.
Sponsorbeheerder
Section titled “Sponsorbeheerder”The built-in rondo_sponsorbeheerder role carries sponsorbeheer. This capability provides the person-CPT primitives required by WordPress REST, while record-level checks keep its mutation scope limited to people with is_sponsor = true:
- create requests must explicitly submit
person_type = contactandis_sponsor = true; - external contact+sponsor records can be managed normally;
- on member+sponsor records, only
company_name,is_sponsor, andsponsor_pass_variantmay be changed; - member+sponsor records cannot be deleted by a sponsor-only manager;
- a sponsor-only manager cannot remove the sponsor role or change the base person type;
- full people managers and administrators keep their existing unrestricted person scope.
The current-user response exposes can_manage_sponsors. The frontend uses it for the Sponsor toevoegen action and enables edit/delete affordances only on sponsor detail pages. sponsorbeheer is an age-group bypass capability because the manager must be able to find sponsor records in the club-wide people list; this does not widen their mutation scope.
Sensitive REST data
Section titled “Sensitive REST data”Club-wide finance reads require financieel_read; VOG bulk actions require vog; volunteer eligibility, obligations, data-quality and relationship-quality endpoints require vrijwilligers. Person lookup by email requires ledenadministratie. Member shift responses expose counts and the caller’s signup state, never other assignees’ person IDs.
Dashboard transients that contain scoped person data must include the current user ID in their cache key.
Private IVA certificates
Section titled “Private IVA certificates”IVA certificates are stored in rondo-private/iva above the WordPress document root. Existing IVA media attachments are migrated once, their public files and generated image sizes are removed, and downloads go through GET /rondo/v1/iva/{person_id}/certificate.
That endpoint permits the linked member, a volunteer manager, an IVA approver, or an administrator. Responses use Cache-Control: private, no-store and X-Content-Type-Options: nosniff. Never return wp_get_attachment_url() for an IVA certificate.
The React client must fetch this endpoint through the configured Axios instance with responseType: 'blob', then open an object URL. Do not link directly to the REST URL: a browser navigation does not send the X-WP-Nonce header, so WordPress cookie authentication treats the request as anonymous.
What Rondo Users cannot do:
- Manage other users
- Access WordPress admin settings
- Install plugins or themes
The role is removed on theme deactivation (users are reassigned to Subscriber).
WP Admin Blocking
Section titled “WP Admin Blocking”Non-admin users are blocked from accessing the WordPress admin panel (/wp-admin/). When a user without manage_options capability navigates to any wp-admin URL, they are immediately redirected to the app home page.
How It Works
Section titled “How It Works”A function hooked to admin_init checks whether the current user has the manage_options capability. If not, the user is redirected via wp_safe_redirect().
Exemptions
Section titled “Exemptions”The following request types are exempt from the redirect:
| Request Type | Detection | Why Exempt |
|---|---|---|
| AJAX | wp_doing_ajax() | admin-ajax.php serves frontend AJAX requests and lives under /wp-admin/ |
| WP-CLI | defined( 'WP_CLI' ) | CLI commands should never be redirected |
| Cron | defined( 'DOING_CRON' ) | Scheduled tasks must run unimpeded |
| Administrators | current_user_can( 'manage_options' ) | Admins need full wp-admin access |
REST API
Section titled “REST API”The WordPress REST API is not affected by admin blocking. REST requests do not go through admin_init (they use rest_api_init instead), so no exemption is needed.
Implementation
Section titled “Implementation”The blocking function is rondo_block_wp_admin() in functions.php, hooked to admin_init.
Functie-to-Role Mapping
Section titled “Functie-to-Role Mapping”Administrators can configure which Sportlink Functies (job titles from work history) automatically grant which Rondo WordPress roles. This configuration is used by Phase 206 (Capability Sync) during rondo-sync runs to grant or revoke roles.
Overview
Section titled “Overview”- Admin navigates to Settings > Beheer > Functies
- A checkbox matrix shows all known Functies as rows and all Rondo roles as columns
- Checking a cell means “this Functie grants this role”
- A Functie can grant multiple roles simultaneously
- Functies are populated automatically from
work_history.job_titledata in the database — admins never type them manually
Data Model
Section titled “Data Model”Stored in WordPress options as a nested associative array:
// Option key: rondo_functie_capability_map[ 'Trainer' => [ 'rondo_user' => true, 'rondo_fairplay' => false, 'rondo_vog' => false, 'rondo_bestuur' => false ], 'Penningmeester' => [ 'rondo_user' => true, 'rondo_fairplay' => false, 'rondo_vog' => false, 'rondo_bestuur' => true ],]Only roles checked true are considered granted — entries with false are ignored by get_roles_for_functie().
REST API
Section titled “REST API”| Method | Endpoint | Auth | Description |
|---|---|---|---|
GET | /rondo/v1/functie-capability-map | Admin | Returns { map, roles } — current mapping and all Rondo role definitions |
POST | /rondo/v1/functie-capability-map | Admin | Accepts { map: {...} }, persists and returns updated { map, roles } |
GET response example:
{ "map": {}, "roles": [ { "slug": "rondo_user", "label": "Rondo User" }, { "slug": "rondo_fairplay", "label": "Rondo FairPlay" }, { "slug": "rondo_vog", "label": "Rondo VOG" }, { "slug": "rondo_bestuur", "label": "Rondo Bestuur" } ]}PHP Usage
Section titled “PHP Usage”The FunctieCapabilityMap class lives in includes/class-functie-capability-map.php under the Rondo\Config namespace.
// Get the full mapping$map = \Rondo\Config\FunctieCapabilityMap::get_map();
// Get role slugs granted by a specific Functie$roles = \Rondo\Config\FunctieCapabilityMap::get_roles_for_functie('Trainer');// Returns e.g. ['rondo_user', 'rondo_fairplay'] — only truthy entries
// Persist an updated mapping\Rondo\Config\FunctieCapabilityMap::update_map($map);This is the primary integration point for Phase 206 (Capability Sync): for each user’s active Functies, call get_roles_for_functie() to determine which roles they should have.
UI: Settings > Beheer > Functies
Section titled “UI: Settings > Beheer > Functies”The FunctiesTab component in src/pages/Settings/Settings.jsx renders the checkbox matrix:
- Rows: Union of Functies from
/rondo/v1/werkfuncties/availableand keys already in the saved map, sorted alphabetically - Columns: All Rondo roles from
/rondo/v1/functie-capability-mapresponse - Stale Functies: If a Functie exists in the saved map but is no longer returned by the available endpoint, the row still appears with the label
(niet meer actief)in gray italic
Stale Functies
Section titled “Stale Functies”When a Functie is removed from Sportlink (and no longer appears in work history), its row remains visible in the matrix with a (niet meer actief) label. This allows admins to review and clean up stale mappings. The mapping itself is preserved until the admin explicitly unchecks and saves.
Finance Settings Access
Section titled “Finance Settings Access”Users with the financieel capability can access Financien > Instellingen (financial settings). Previously this was restricted to administrators only. financieel_read does not reach this screen — it exposes payment-provider configuration.
Security Considerations
Section titled “Security Considerations”- All access control is enforced server-side - Never trust client-side checks
- REST API is protected - Unauthenticated users receive 403 errors
- WP Admin is blocked - Non-admin users cannot access the WordPress dashboard
Related Documentation
Section titled “Related Documentation”- Multi-User System - User management and provisioning
- User Provisioning - Creating WordPress accounts for members
- Data Model - Post types and field definitions
- REST API - API endpoints