Skip to content

Security Model

Ankit Upadhyay edited this page Aug 13, 2026 · 1 revision

Security model

This page describes how openrunic decides who a caller is and what they may do, which parts are real today and which are stubbed, and where each control is enforced. It is for anyone adding a route or reviewing access behaviour.

For the automated scanners that gate pull requests, see Security and supply chain. For reporting a vulnerability, see Security policy.

Where enforcement lives

flowchart TD
    A[Bearer token] --> B["authn<br/>token to Principal"]
    B --> C["tenant-scope<br/>tenant from principal only"]
    C --> D["policy<br/>build PolicyContext from roles"]
    D --> E["route guard<br/>requirePermission / assertFacilityAccess"]
    E --> F["repository<br/>bound to one tenant, no tenant parameter"]
    F --> G["tenant client extension<br/>ANDs tenantId, blocks raw SQL"]
    G --> H["Postgres row-level security<br/>designed, not enabled"]
    style H stroke-dasharray: 5 5
Loading

Each layer is independent. A bug in a route guard is still caught by the repository binding; a bug in the repository binding would still be caught by row-level security once it is enabled.

Authentication: stubbed, behind a clean seam

apps/api/src/auth/principal.ts defines the contract. A Principal carries subject, tenantId, actorType, roles, facilityIds, purposeOfUse, an optional displayName, and an optional breakglass flag. A PrincipalResolver has one method, resolve(token), returning a principal or null.

The only implementation shipping today is createStaticPrincipalResolver, a Map lookup over a table of demo tokens. It performs no signature verification, no expiry check, no JWKS fetch, and no constant-time comparison. It is a development fixture, and it is named so that nobody mistakes it for otherwise.

The seam is the point. A real embedded OIDC verifier drops into the same interface without touching a single route.

Two guards keep the fixture from reaching an environment where it would matter:

  • assertProductionWiring throws at application construction when NODE_ENV is production and the repositories, principal resolver, or audit sink were not supplied explicitly. apps/api/src/index.ts constructs the app with no options, so starting the built server in production mode fails loudly by design.
  • The authn middleware treats a missing token and an unresolvable token identically, so token probing tells an attacker nothing.

Public paths are matched exactly, never by prefix: /healthz, /fhir/metadata, and /openapi.json.

Bearer parsing is strict, matching Bearer followed by whitespace and one non-whitespace token.

Authorization: real, in process

There are no OAuth or SMART scope strings today. Authorization is a permission catalogue, in apps/api/src/policy/permissions.ts.

Nineteen permissions, paired read and write across each aggregate:

patient.read      patient.write
appointment.read  appointment.write
encounter.read    encounter.write
order.read        order.write
result.read       result.write
claim.read        claim.write
payment.read      payment.write
task.read         task.write
form.read         form.write
facility.all

Five system roles bundle them: admin, clinician, front-desk, biller, and read-only. read-only is derived rather than hand-listed, so a new aggregate's read permission joins it automatically. Only admin holds facility.all.

buildPolicyContext(principal) produces a PolicyContext exposing can(permission) and canAccessFacility(facilityId). An unknown role contributes nothing rather than throwing, so a stale role name degrades to less access, not to an error or to more.

canAccessFacility returns true when the principal holds facility.all or when the id appears in the grant list. An empty grant list denies. That is the correct default for a facility-scoped system: no grants means no facilities, not all of them.

Note that the database model is richer than the running catalogue. Role, Permission, RolePermission, and RoleAssignment exist as tables so the catalogue can become per-tenant data that a plugin extends. The API currently uses the hard-coded bundles.

Route guards

Two guards, both in apps/api/src/middleware/policy.ts.

requirePermission(permission) returns 401 when there is no principal. When there is a principal but the permission is missing, it writes an audit event and then returns 403. The event carries action authorisation.denied, target type Route, the request path as the target, and the permission and roles in metadata.

assertFacilityAccess(policy, facilityId) throws 403 when the principal has no grant for that facility. Appointment routes call it before a write, not after, so an unauthorised booking never reaches the database.

The ordering of these two matters and is asserted in the stub-route tests: an anonymous caller gets 401, a wrongly-roled caller gets 403 plus an audit record, and only a caller who would otherwise have been allowed sees the 501 for an unimplemented aggregate. The status code never leaks whether a feature exists to someone who could not have used it.

Tenant isolation

Covered in full on Multi-tenancy and isolation. The three properties that matter for security:

  • The tenant id comes from the authenticated principal and from nowhere else. A disagreeing header is a 403, never a fallback.
  • Repository interfaces take no tenant parameter, so a handler has no way to name another organisation.
  • The tenant client extension ANDs its predicate rather than merging, and throws on all four raw-SQL methods.

A cross-tenant read returns 404 rather than 403, because 403 would confirm that the id exists somewhere.

Classes of vulnerability the stack removes structurally

This is worth stating explicitly, because it is the reasoning behind several otherwise-odd constraints.

String-built SQL. There is no string-SQL path through a tenant-scoped client. The raw methods throw. This is a structural property, not a code-review convention.

Missing per-object authorization. The tenant predicate is applied by the client extension on every operation on every model, not by each endpoint remembering to filter. An endpoint cannot skip it, because it is not the endpoint's job.

Opt-in enforcement. Permission checks are guards on the route rather than checks inside handlers, and the middleware chain is asserted as an ordered list rather than assembled ad hoc.

Inconsistent identifiers. One id scheme, UUIDv7, minted in application code, with no serial columns and no separate public-id overlay. There is no id-translation layer to get wrong.

Transport and headers

Hono's secureHeaders() is applied to every response. The API sets no CORS headers, which means a browser on another origin cannot call it. That is currently correct, because nothing is deployed and the web app proxies through its own origin, but it is a decision a deployment must revisit.

The API terminates no TLS of its own. Transport security is the deployment's responsibility. docs/compliance.md states the expectation directly: TLS in transit and encryption at rest, documented rather than assumed.

Data-handling rules that are part of the security model

  • Card data is never stored. Payment.adapterRef holds an opaque gateway reference.
  • Binary content is never stored in Postgres. Document carries a storage key, content type, SHA-256, and size.
  • Partner references are opaque and never contain patient data. The schema says so on MedicationRequest.erxRef and ServiceRequest.labRef.
  • Statement pay-link tokens are single use and rotate on every regeneration.
  • Error bodies never carry internals. An unhandled exception becomes a fixed message asking the caller to quote the request id; the original is logged server-side only.
  • Environment validation names variables, never values. parseEnv reports which variables are invalid without echoing what they contain.

What is not built yet

Being precise here matters more than being reassuring.

  • No real token verification. See above.
  • No OIDC or SMART on FHIR. docs/compliance.md states the intended sequence: plain OIDC with role-based access first, SMART App Launch with granular scopes and SMART Backend Services when third-party apps and bulk export arrive. The data-access layer is designed to enforce scope-to-filter rules from the start so that layering is additive.
  • No row-level security in Postgres.
  • No rate limiting, CORS, CSRF protection, body-size limit, or request timeout in the API.
  • No session management, password handling, or multi-factor support anywhere in the repository.
  • The web app's getToken returns null, so the browser sends no credentials at all today.

Related pages

Clone this wiki locally