Permission Model
middag-account implements a multi-tenant permission system based on organization membership, role hierarchy, and scoped permissions. Every data access is bounded by the X-Middag-Organization header.
Organization Boundary
All queries filter by organization_id. Data from one organization never leaks to another. The boundary is enforced at multiple layers:
- OrganizationMiddleware -- Validates the
X-Middag-Organizationheader on every org-scoped request - PermissionsMiddleware -- Verifies the user is a collaborator of the target organization
- Repository layer -- Every query method requires
organization_idas a parameter
HTTP Request
-> AuthMiddleware (validate JWT, set WP_User)
-> PermissionsMiddleware (check role + scopes)
-> OrganizationMiddleware (validate org membership)
-> Controller (execute with org-scoped context)X-Middag-Organization Header
Required on all endpoints that operate on organization-scoped data. The value is the integer ID of the organization.
GET /wp-json/middag-account/v1/entitlements
Authorization: Bearer eyJ...
X-Middag-Organization: 42Validation steps:
- Header present -- else
400 Bad Request - Organization exists -- else
404 Not Found - User is a collaborator of the organization -- else
403 Forbidden - Organization context is set in the request for downstream use
Role Hierarchy
Roles determine the base access level within an organization. Defined in RoleHierarchy enum:
| Role | Weight | Can Manage Members | Can Alter Org | Can Delete Org |
|---|---|---|---|---|
owner | 100 | Yes (all) | Yes | Yes |
admin | 80 | Yes (member/guest) | Yes (scoped) | No |
member | 50 | No | No | No |
guest | 20 | No | No | No |
pending | 0 | No | No | No |
A role outranks another if its weight is higher. The outranks() method compares two roles:
RoleHierarchy::Owner->outranks(RoleHierarchy::Admin); // true
RoleHierarchy::Member->outranks(RoleHierarchy::Admin); // falsePermission Scopes
Fine-grained permissions are controlled by scopes assigned to each collaborator. Defined in PermissionScope enum:
| Scope | Controls Access To |
|---|---|
organization | Organization settings, profile |
finances | Invoices, tax invoices, billing |
orders | WooCommerce orders, refunds |
licenses | Software license management |
tickets | Support tickets, service requests |
quotes | Quote viewing, accept/reject |
contracts | Contract viewing, PDF download |
documents | Organization documents |
downloads | Product downloads |
Each scope maps to a boolean field on the CollaboratorEntity:
$collaborator->canManageFinances; // true/false
$collaborator->canManageOrders; // true/falseThe owner role has implicit access to all scopes. Admin and member roles require explicit scope assignment.
Route Protection
Every REST route declares its required role and scopes. The PermissionsMiddleware enforces these at request time:
Route: GET /invoices
Required scope: finances
Required role: member (minimum)
Check:
1. Load collaborator for (user_id, organization_id)
2. Verify role >= member (weight >= 50)
3. Verify collaborator has "finances" scope
4. If scope missing -> 403 "Scope not authorized: finances"Custom WordPress Capabilities
The plugin registers custom capabilities via user_has_cap filter. These map to the collaborator's role and scopes within the organization context, bridging the middag permission model with WordPress's native capability system.
JWT Scopes
The JWT payload includes the user's scopes for the default organization:
{
"sub": 42,
"org": 15,
"roles": [
"admin"
],
"scopes": [
"organization",
"finances",
"orders",
"licenses"
]
}The scopes array in the JWT represents the collaborator's permissions. A wildcard "*" grants access to all scopes.
X-Middag-Company Header
The optional X-Middag-Company header (middag_br or middag_global) routes requests to the correct legal entity for dual-entity operations (Stripe accounts, tax systems). It does not affect permission checks.
Related
- REST API Overview -- Middleware pipeline
- Extension Points -- Hooks for custom permission logic
- Status Labels -- Collaborator roles