Roles and access (RBAC)
Ovahol has three role scopes. A user holds exactly one role, and that role decides both what they can do and which tenant context the request runs in.
Roles
| Scope | Role | Typical person |
|---|---|---|
| Global | SUPER_ADMIN | Platform operator. Sees every organization; bypasses role checks. |
| Organization | ORG_ADMIN | Runs the organization: facilities, billing, org reporting. |
| Organization | ORG_FINANCE | Organization-level billing and commercial records. |
| Facility | FACILITY_ADMIN | Runs one facility: members, records, work orders. |
| Facility | ENGINEERING_MANAGER | Engineering operations for a facility. |
| Facility | ENGINEERING_TECHNICIAN | Executes maintenance work. |
| Facility | CLINICAL_USER | Reports faults, views availability, confirms fixes. |
The constants live in backend/internal/common/roles/roles.go. The same codes are validated when a JWT is issued, so an unknown role cannot enter the system through a hand-built token.
A user cannot hold roles across scopes — a FACILITY_ADMIN is not also an ORG_ADMIN. Moving a person up a scope is a new role assignment, not an extra one. The one sanctioned promotion is when an organization is provisioned onto a multi-facility plan: facility admins are promoted to ORG_ADMIN so they can reach the organization workspace.
Enforcing a role
Routes declare the roles they accept:
admin := rg.Group("/billing/admin")
admin.Use(middleware.RequireRoles(roles.RoleSuperAdmin))RequireRoles reads the role from the request context, normalizes it, and lets SUPER_ADMIN through every check. That bypass is deliberate: support work must not require impersonating a customer.
Handler-level checks use the same context helpers (middleware.GetUserRole, HasRole, HasAnyRole) when a route serves several roles with different results.
Facility context
Facility-scoped routes need to know which facility is being acted on. FacilityContextMiddleware resolves it in this order:
- The facility claim in the JWT.
- The
X-Facility-IDheader, when present.
The header is an override, and it is restricted:
| Caller | X-Facility-ID |
|---|---|
SUPER_ADMIN | Any facility |
ORG_ADMIN | Any facility in their organization (verified against core_public.facilities) |
| Facility roles | Rejected with 403 — a facility user cannot move their own context |
That last row matters in practice: a facility admin who sends the header gets a 403 facility override is not permitted rather than silently acting on another facility.
After the facility is resolved, FacilityAccessMiddleware checks the plan allows it. When an organization holds more facilities than its plan covers, writes to the uncovered facilities return 423 FACILITY_READ_ONLY while reads keep working. See facility access lock.
Subscription gate
SubscriptionAccessMiddleware runs before facility resolution on /tenant routes and returns 402 SUBSCRIPTION_ACCESS_REQUIRED when the organization's status forbids tenant access:
| Status | Tenant access | Why |
|---|---|---|
ACTIVE | Yes | Normal. |
GRACE | Yes | Seven days after the term ends. |
EXPIRED | Yes | The plan has reverted to Starter; the organization keeps its data. |
OVERDUE | No | Payment failed inside a running term; the prompt is to pay. |
CANCELLED | No | Bounded by the scheduler, which moves it to GRACE on its next tick. |
PENDING_ACTIVATION | No | No plan has ever been paid for. |
SUPER_ADMIN bypasses the gate. The gate only covers /tenant routes, so an organization can always reach its dashboard and billing page to fix the problem.
Feature gates
Plan entitlements are checked per feature with middleware.RequireFeatures. Feature keys (governance, counterparties, intake, commercial_procurement, donations, vendor_contracts, utilization, spare_parts, asset_lifecycle, retirement_disposal, org_reporting, cross_facility_transfers, email_notifications) resolve from the plan in the JWT claims, with two environment-level adjustments:
FEATURE_RELEASE_PROFILE=betaremoves the features that are not ready for a beta deployment.FEATURE_FLAGS_ENABLE/FEATURE_FLAGS_DISABLEadd or remove individual keys, which is how a gate is turned on for a demo without a deploy.
Feature gates apply to reads as well as writes: 403 FEATURE_DISABLED names the missing key.
Practical rules
- Put the role check on the route, not inside the handler, unless the handler serves different roles differently.
- Never infer the tenant from a request body when the context already carries one.
- Cross-organization operations belong on
/api/v1/core/admin, not/tenant. - When a facility is chosen explicitly (data interchange, cross-facility transfers), authorize the target facility in the service layer — the middleware cannot know the caller's relationship to an arbitrary facility.