Skip to content

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:

  1. OrganizationMiddleware -- Validates the X-Middag-Organization header on every org-scoped request
  2. PermissionsMiddleware -- Verifies the user is a collaborator of the target organization
  3. Repository layer -- Every query method requires organization_id as 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: 42

Validation steps:

  1. Header present -- else 400 Bad Request
  2. Organization exists -- else 404 Not Found
  3. User is a collaborator of the organization -- else 403 Forbidden
  4. 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:

RoleWeightCan Manage MembersCan Alter OrgCan Delete Org
owner100Yes (all)YesYes
admin80Yes (member/guest)Yes (scoped)No
member50NoNoNo
guest20NoNoNo
pending0NoNoNo

A role outranks another if its weight is higher. The outranks() method compares two roles:

php
RoleHierarchy::Owner->outranks(RoleHierarchy::Admin); // true
RoleHierarchy::Member->outranks(RoleHierarchy::Admin); // false

Permission Scopes

Fine-grained permissions are controlled by scopes assigned to each collaborator. Defined in PermissionScope enum:

ScopeControls Access To
organizationOrganization settings, profile
financesInvoices, tax invoices, billing
ordersWooCommerce orders, refunds
licensesSoftware license management
ticketsSupport tickets, service requests
quotesQuote viewing, accept/reject
contractsContract viewing, PDF download
documentsOrganization documents
downloadsProduct downloads

Each scope maps to a boolean field on the CollaboratorEntity:

php
$collaborator->canManageFinances;  // true/false
$collaborator->canManageOrders;    // true/false

The 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:

json
{
    "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.