Skip to content

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 ​

ScopeRoleTypical person
GlobalSUPER_ADMINPlatform operator. Sees every organization; bypasses role checks.
OrganizationORG_ADMINRuns the organization: facilities, billing, org reporting.
OrganizationORG_FINANCEOrganization-level billing and commercial records.
FacilityFACILITY_ADMINRuns one facility: members, records, work orders.
FacilityENGINEERING_MANAGEREngineering operations for a facility.
FacilityENGINEERING_TECHNICIANExecutes maintenance work.
FacilityCLINICAL_USERReports 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:

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

  1. The facility claim in the JWT.
  2. The X-Facility-ID header, when present.

The header is an override, and it is restricted:

CallerX-Facility-ID
SUPER_ADMINAny facility
ORG_ADMINAny facility in their organization (verified against core_public.facilities)
Facility rolesRejected 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:

StatusTenant accessWhy
ACTIVEYesNormal.
GRACEYesSeven days after the term ends.
EXPIREDYesThe plan has reverted to Starter; the organization keeps its data.
OVERDUENoPayment failed inside a running term; the prompt is to pay.
CANCELLEDNoBounded by the scheduler, which moves it to GRACE on its next tick.
PENDING_ACTIVATIONNoNo 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=beta removes the features that are not ready for a beta deployment.
  • FEATURE_FLAGS_ENABLE / FEATURE_FLAGS_DISABLE add 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.

Ovahol developer documentation