Local development environment via Docker Compose.
- Docker Engine 24+ with Compose v2
curl(for init script — OpenFGA setup)
# 1. Copy environment config and set passwords
cp .env.example .env
# Edit .env — at minimum, change POSTGRES_PASSWORD and KEYCLOAK_ADMIN_PASSWORD
# 2. Start all services (--build ensures images reflect latest source)
docker compose up -d --build
# 3. Wait for infrastructure, then initialize
./init-services.sh
# 4. Open GraphQL Playground
open http://localhost:4000/graphql| Service | Port | Description |
|---|---|---|
| api-gateway | 4000 | GraphQL + REST + FHIR |
| ontology-engine | 4001 (internal) | Object lifecycle, validation |
| action-executor | 4002 (internal) | CEL-based action pipeline |
| sync-engine | 4003 (internal) | Overlay mode, CDC |
| security-service | 4004 (internal) | OIDC, OpenFGA authz, audit |
| cel-evaluator | 50051 | gRPC CEL runtime |
| openfga | 8280 | Authorization (ReBAC) |
| postgresql | 5432 | PostgreSQL 17 + Apache AGE |
| redis | 6379 | Rate limiter + cache store |
| redpanda | 19092 | Kafka-compatible streaming |
| debezium | 8083 | Change Data Capture |
| otel-collector | 4317 | OpenTelemetry traces/metrics |
| keycloak | 8180 | Identity provider (OIDC) |
| openfga-migrate | — | One-shot init container: applies the OpenFGA datastore migrations before openfga starts |
init-services.sh performs:
- Waits for PostgreSQL readiness
- Creates the Apache AGE extension and graph
- Waits for OpenFGA readiness
- Creates an OpenFGA store and loads the NHS authorization model
- Creates domain pack registry tables (packs are registered by api-gateway at boot)
The shipped docker-compose.yaml runs every app service with
NODE_ENV=development. Dev mode exists for fast local iteration and
disables all governance enforcement. In dev mode the api-gateway
(packages/api/src/server.ts, where isDev = NODE_ENV !== 'production'):
- OpenFGA → allow-all stub — every
checkreturnstrue,listObjectsreturns the['*']"all authorized" sentinel. - CEL evaluator → allow-all stub — every action precondition evaluates
true. - Security layer → allow-all — and when no
Authorizationheader is present,extractUserreturns a synthetic admin user with every role.
Consequence: a default docker compose up demonstrates none of the
ReBAC / CEL / consent enforcement the platform is built for. To evaluate or
integrate against real governance, you must set NODE_ENV=production on the
app services (and satisfy the production requirements below).
In production the FGA client is only wired when OPENFGA_STORE_ID is non-empty
at boot. init-services.sh creates the store and writes that ID, but a plain
docker compose up -d starts api-gateway before the init script runs — so the
gateway would boot without a store ID.
It fails closed: OPENFGA_STORE_ID is one of the REQUIRED_PROD_VARS
(packages/api/src/config.ts), so with NODE_ENV=production the gateway logs
FATAL: Production mode requires env vars: … and exits rather than starting
without authorization. A production gateway therefore never silently degrades to
the allow-all stub — but it will crash-loop until the store ID is supplied, so the
ordering below still matters.
Correct production order:
# 1. Start dependencies only (not api-gateway)
docker compose up -d postgresql openfga keycloak redis redpanda
# 2. Create the OpenFGA store + write OPENFGA_STORE_ID, provision AGE
./init-services.sh
# 3. Start api-gateway last, now that the store ID is available
docker compose up -d api-gatewayRe-deploying after a domain-pack DDL change against an existing PostgreSQL volume fails hard at boot:
Schema migration: version 1 already applied but DDL checksum differs.
Expected <a>, got <b>.
This is a safety check in storage-postgres applySchema — it refuses to
silently diverge from the persisted schema. Recovery options:
- Bump the schema version in your domain pack (preferred for real migrations).
- Wipe the volume for throwaway/dev data:
docker compose down -v.
Domain-pack boot seeds (seed: entries in pack.yaml) are written under the
tenant in SEED_TENANT, which defaults to system — deliberately isolated
from ordinary request tenants. Every object is created and persisted correctly,
but because reads are tenant-scoped, an API query running under a different
tenant returns totalCount: 0. The boot log says Seed: created N object(s),
so this reads as "the seed worked but the data vanished".
Set it to the tenant your API requests actually use when the seeded reference data is meant to be readable through the API:
SEED_TENANT=default # deploy/.env — the integration stack uses thisThe gateway logs the tenant it seeded under, and warns when SEED_TENANT is
unset, so check the boot log first if seeded data appears to be missing.
Looking for the seeded rows directly? Object data lives in the relational table
public.<snake_case_type>(a column per property). The Apache AGE label table holds id-only vertices ({id, tenant_id}) used for graph traversal — so inspecting the AGE properties map will always look empty, for seeded and runtime-created objects alike.
deploy/docker-compose.prod-test.yaml is an override that runs the api-gateway
in NODE_ENV=production with real OIDC + OpenFGA wired (it encodes the
issuer/JWKS host split and the OPENFGA_STORE_ID requirement above). The
integration suite uses it to exercise enforcement end-to-end without the
allow-all stubs:
tests/integration/src/security-enforcement.test.ts(SECURITY_E2E=1) — boots the prod stack, creates the OpenFGA store, mints real Keycloak tokens, and asserts the full picture: unauthenticated/invalid tokens are rejected (401); an authenticated clinician performs a governed action; a created patient is not visible without aviewertuple (filtered list + 403 direct read); and after an admin grantsward.assignedthrough the governed relationships API, the same read flips 403 → 200.tests/integration/src/capability-gating.test.ts(CAPABILITY_E2E=1) andtests/integration/src/open-consent-vocab.test.ts(CONSENT_VOCAB_E2E=1) — boot the stack with a non-default pack set / consent vocabulary and assert the corresponding gating.
These specs are destructive (they own the Docker lifecycle and reboot the stack
in a non-default mode on the shared ports), so they are env-gated and run in a
dedicated enforcement-e2e CI job rather than the standard integration suite.
Releases publish container images and a versioned Helm chart, so a deployment does not have to build from source:
# Images: ghcr.io/syzygyhack/open-foundry/<service>:<version>
helm install openfoundry \
oci://ghcr.io/syzygyhack/open-foundry/charts/openfoundry --version 0.2.3The chart's version and appVersion always match the platform release it
deploys, and image tags are pinned to that exact version rather than a floating
minor. For immutable deployments, override a service with its digest — every
release attaches image-digests.txt listing the digest for each image:
--set apiGateway.image.repository=ghcr.io/syzygyhack/open-foundry/api-gateway@sha256:<digest>Release assets also include the OpenAPI, GraphQL and AsyncAPI contracts, a
CycloneDX SBOM, and a build provenance attestation verifiable with
gh attestation verify <file> --repo syzygyhack/open-foundry.
Production auth requires OIDC access tokens that satisfy
OidcAuthenticator (packages/security/src/auth/oidc-authenticator.ts). Two
requirements are easy to miss — each fails with an opaque 401 UNAUTHENTICATED:
audmust equal the configured client id. The authenticator setsaudience = clientId(OIDC_CLIENT_ID) and validates it. Keycloak access tokens default toaud: "account"and will be rejected. Add an audience protocol mapper that includes your client in theaudclaim.- A
tenant_idclaim is mandatory (unless a default is opted in).extractUserthrowsMISSING_TENANTwhen the tenant claim is absent and no default tenant is configured. Either add a mapper (hardcoded claim or per-user attribute) that emitstenant_id, or — for a single-tenant deployment — setOIDC_DEFAULT_TENANTto opt into a fallback tenant. Leaving it unset preserves the fail-closed default (claimless tokens are rejected); a realtenant_idclaim always takes precedence when present.
Required token claims:
| Claim | Requirement |
|---|---|
sub |
Subject — used as the actor/user id |
aud |
Must equal OIDC_CLIENT_ID |
tenant_id |
Mandatory unless OIDC_DEFAULT_TENANT opts into a fallback tenant — otherwise the request is rejected |
roles |
Flat top-level array — the authenticator reads claims["roles"] directly (role-mapping.ts resolveRoles, DEFAULT_ROLE_MAPPING.claimName = "roles"). It does not descend into Keycloak's nested realm_access.roles. Add a Keycloak realm-role mapper (oidc-usermodel-realm-role-mapper, claim.name=roles, multivalued) — otherwise the actor has no roles and CEL preconditions like actor.hasRole('clinician') fail with PRECONDITION_FAILED even after OpenFGA passes. |
A token minted from the host (iss=http://localhost:8180/...) cannot be verified
by the in-container gateway unless JWKS is fetched in-network
(http://keycloak:8080/...). The gateway supports this via two separate env vars
— set both when the external issuer URL differs from the in-cluster address:
OIDC_ISSUER=http://localhost:8180/realms/openfoundry # must match token `iss`
OIDC_JWKS_URI=http://keycloak:8080/realms/openfoundry/protocol/openid-connect/certsKeycloak now boots start-dev --import-realm and imports
deploy/keycloak/openfoundry-realm.json on first start: the openfoundry
realm, the openfoundry client, the pilot realm roles, two test users
(dr-test, admin-test, password test-password), and the three protocol
mappers the OIDC authenticator requires — audience (aud == openfoundry),
tenant_id (from a user attribute), and a flat top-level roles array
(realm-role mapper, not nested realm_access.roles). A host-minted token
therefore carries aud/tenant_id/roles and passes token validation.
The realm is persisted in PostgreSQL (
KC_DB=postgres); re-import only happens on a fresh DB volume. To force re-import,docker compose down -v.For the full non-stub end-to-end (a minted token driving a governed action against
NODE_ENV=production), setOIDC_ISSUERto match the token issuer — note the issuer includes the/authrelative path, e.g.http://keycloak:8080/auth/realms/openfoundry.
Action authorization (packages/api/src/config.ts, createSecurityLayer) checks
can_<verb> directly on the target object (e.g. user:<sub> →
can_discharge → patient:<id>). It does not derive contextual tuples from
roles. Per the NHS model, relations like patient.can_discharge resolve from
direct [user] relations on the patient object (clinician, nurse_in_charge).
Footgun (mitigated in v0.2.0 — A1): a freshly created object has no care-team
tuples, so can_admit / can_discharge / can_transfer are checked before any
tuple exists → denied for everyone. v0.2.0 adds a governed tuple-write API
so these no longer require out-of-band writes:
POST /api/v1/relationships { "user", "relation", "objectType", "objectId" }
DELETE /api/v1/relationships { ... } # + GraphQL grant/revokeRelationship
It grants only relations the merged FGA model declares directly-assignable to
user (patient.{clinician,nurse_in_charge,admin}, ward.{assigned,porter});
computed/link relations like can_admit/admitted_to are rejected. The caller
must hold a granter role — the generic platform default is admin only. A
deployment broadens it via RELATIONSHIP_GRANTER_ROLES; consent recording is
gated likewise by CONSENT_RECORDER_ROLES. Both default to admin in the
shipped manifests — NHS deployments add the clinical roles in .env
(admin,nurse_in_charge and admin,nurse_in_charge,clinician; see
.env.example). Every grant/revoke/denial is audited. Example — grant the
acting clinician on admission:
POST /api/v1/relationships { "user":"<sub>", "relation":"clinician", "objectType":"Patient", "objectId":"<id>" }
Link-derived tuples (admitted_to, bed_in_ward) are still minted automatically
by the action pipeline (syncLinkTuple); the API covers the direct [user]
grants only.
Actions are exposed at POST /api/v1/actions/{ActionType}. End-to-end against a
production stack:
# 1. Get a token (client-credentials or password grant; must carry aud + tenant_id)
TOKEN=$(curl -s -X POST \
"http://localhost:8180/realms/openfoundry/protocol/openid-connect/token" \
-d grant_type=password -d client_id=openfoundry \
-d username=<user> -d password=<pass> | jq -r .access_token)
# 2. Call the action (body keys are the action's @param fields)
curl -X POST http://localhost:4000/api/v1/actions/AdmitPatient \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"patient":"<patient-id>","ward":"<ward-id>"}'
# 3. A 200 returns { success, data, warnings? }; a 403 usually means the
# required can_<verb> tuple is missing (see "Authorization Tuples" above).Surfaced driving a real AdmitPatient to success (authorize → consent → CEL →
effects → audit). These bite a production single-trust pilot specifically.
-
Two independent authz layers must both pass. (1) OpenFGA
can_<verb>at the authorize stage (needs the per-object care-team tuple above) → fails asAUTHORIZATION_DENIED. (2) CELactor.hasRole(...)in the manifest preconditions (needs the flatrolesclaim, see OIDC table) → fails asPRECONDITION_FAILED. A clinician can satisfy (1) via a written tuple and still fail (2) for a missing roles claim — two separate root causes for the same "clinician can't act" symptom. -
Consent blocks admission of a not-yet-admitted patient. The legitimate-relationship exemption (
consent-service.tsevaluateDirectCareExemption) is the NHS s251 direct-care case. It is OFF by the platform default and enabled per-deployment viaCONSENT_DIRECT_CARE_EXEMPTION=true(this NHS reference compose sets it). When on, it checks a care relation that defaults toviewer(careRelation ?? "viewer"). Butpatient.viewer = viewer from admitted_to— it derives from ward admission, so a freshly seeded/synced (un-admitted) patient has noviewer, the exemption returns null, andAdmitPatientfailsCONSENT_DENIED. v0.2.0 (A2) adds a consent-record API (POST /api/v1/consent+ GraphQLrecordConsent, role-gated + audited) so consent no longer requires a directconsent.consent_recordsinsert (decision enum is"GRANT"/"DENY"). The recordable purpose vocabulary is the openDataPurposestring type — default is the NHS preset, configurable viaCONSENT_PURPOSES(Epic C); the exemption purpose isCONSENT_EXEMPTION_PURPOSE(defaultDIRECT_CARE). Which object type drives an action's consent check isCONSENT_SUBJECT_TYPES(defaultPatient) — a non-NHS deployment sets e.g.Customerto consent-gate actions on its own subject. This is an in-platform two-step flow (record consent → then admit). -
Link-derived ReBAC tuples are now emitted by the pipeline. On a
createLink/deleteLinkeffect, the action executor writes/deletes the matching OpenFGA tuple(toType:toId, <relation>, fromType:fromId), where<relation> = snake_case(linkType)— soAdmittedTo→(ward:W, admitted_to, patient:P)andBedInWard→(ward:W, bed_in_ward, bed:B). This makes... from <link>rules (e.g.patient.viewer = viewer from admitted_to,bed.can_clean = editor or porter from bed_in_ward) resolve without out-of-band provisioning. The sync set is derived at boot from the merged OpenFGA model (only link types whosesnake(linkType)relation exists are synced), and emission is post-commit + best-effort (a tuple failure never fails the committed action).- Seeded links too: boot-seed links bypass the action executor, so their
tuples are backfilled at startup (after the model loads) using the same
map — so a seeded
BedInWardgets itsbed_in_wardtuple without a script. - Still provisioned out-of-band: role/direct grants that aren't
link-derived — ward
assigned([user]) and patient care-teamclinician/nurse_in_charge([user]). These have no originating ontology link, so the demo'sprovision-authz.shstill seeds them. - ED (un-admitted) patients have no
AdmittedTolink → no ward-scopedviewer, so they aren't visible via ward-derived reads until admitted.
- Seeded links too: boot-seed links bypass the action executor, so their
tuples are backfilled at startup (after the model loads) using the same
map — so a seeded
-
Denied actions ARE audited (authorize + consent). Authorize and consent denials now write an audit record with
result: 'denied',denialReason(andconsentDecision: 'denied'for consent), plus actor/roles/traceId — so the immutable trail carries refusal evidence (IG need-to-know). Note: precondition failures (PRECONDITION_FAILED, business-state) and input validation failures are not audited — only access denials are. -
Boot-seed data lands under tenant
system.server.tsseeds withbootCtx = { tenantId: 'system' }. Object rows are tenant-scoped (_tenant_id) andgetObjectfiltersWHERE _tenant_id = $1, so packseed:data is invisible to requests on any other tenant (symptom: the object resolves far enough to enter the pipeline, then fails a field-dependent CEL precondition withno such key: <field>). Fix direction: make the seed tenant configurable or seed per-tenant; until then, clients must use thesystemtenant to read seeded reference data.
To load domain packs from outside the monorepo:
-
Set
DOMAIN_PACKS_HOST_DIRin.envto the host path of your pack (or a parent directory containing multiple packs):DOMAIN_PACKS_HOST_DIR=../../silmaril-dp-rce DOMAIN_PACKS_EXTRA_DIRS=/external-packs
-
Add the pack name to
DOMAIN_PACKSif you use an explicit pack list:DOMAIN_PACKS=core,nhs-acute,rce
-
Restart the api-gateway:
docker compose up -d --build api-gateway
The host path is mounted read-only at /external-packs inside the container. The schema loader scans it for pack.yaml files using the same discovery logic as the primary domain-packs/ directory.
The FHIR facade (/fhir/*) and the FDP/CDM projection (/api/v1/cdm/*) are
NHS-shaped and only mounted when a loaded pack opts in via capabilities: in
its pack.yaml:
capabilities:
- fhir
- cdmnhs-acute declares both. A deployment that loads only non-NHS packs (e.g.
aml, supply-chain) does not expose these endpoints — the REST routes
return 404 and the GraphQL cdm* queries (cdmMetadata/cdmRecord/
cdmRecords/cdmEncounters) are omitted from the schema entirely (no SDL
fields, no resolvers). Boot logs the resolved capabilities
(Capabilities: cdm=… fhir=…).
For full details (pack.yaml format, Helm config, permissions, connectors, troubleshooting), see docs/external-domain-packs.md.
After pulling source changes, always pass --build to pick up code changes:
docker compose up -d --buildTo stamp the git revision into image labels (visible via docker inspect):
GIT_REVISION=$(git rev-parse HEAD) docker compose up -d --buildWithout --build, Docker Compose reuses locally cached images and will not
reflect source changes. This applies to both TypeScript services and the Go
CEL evaluator.
Domain-pack changes need an api-gateway rebuild too. New or changed actions
(ODL @actionType + manifest), permissions, or seeds are baked into the
api-gateway image. A stale image will 404 a newly added action (e.g. a
POST /api/v1/actions/CleanBed against an image built before the action existed).
After changing a pack, rebuild: docker compose up -d --build api-gateway.
docker compose down # Stop services (keep data)
docker compose down -v # Stop and remove volumes