A standalone backend service providing identity-aware, AWS-IAM-style
authorization for a ModelForge Medical enterprise deployment: organizations,
users, groups, and JSON policy documents, evaluated through a single
/authz/check primitive (and, now, real patient-case CRUD endpoints that
call the same evaluator in-process before every read/write) other services
and an admin console are meant to build on.
Relationship to the wider plan: this implements
docs/ENTERPRISE_MULTIUSER_ARCHITECTURE.md
§4 (auth — generic OIDC relying party, now with a working Electron Settings
UI too — see "Electron client" below), §5 (admin console — IAM-style RBAC
via real JSON policy documents, not a fixed role enum), and §1/§2 (a real
backend service, with both in-memory and Postgres implementations for
both IamStore and CaseStore — see "Persistence" below). It does
not implement §3 (real-time sync/CRDT), §6 (deployment topology/infra),
or §7 (encryption — case data here is stored as plaintext server-side, see
"Known gaps").
- Not an identity provider. This service authenticates nobody — every
request must already carry a valid OIDC access token from an external IdP
(Cognito, Keycloak, or any spec-compliant provider). See
src/auth/oidc-verifier.ts. Building a login screen, MFA, or password reset flow here would directly contradict docs/ENTERPRISE_READINESS_ASSESSMENT.md §2.1's standing decision to delegate authentication entirely to an external IdP. - Persistence has both an in-memory and a Postgres implementation for
each store, deliberately disclosed as untested-by-execution for the
Postgres side.
IamStore(control-plane metadata),CaseStore(patient cases),PrincipalStore(identity/membership), andCaseMigrationStore(staged imports) have in-memory and Postgres paths. Postgres uses shared RLS-protected tables for IAM metadata and one schema per organization for PHI-bearing case data. Both interfaces are the same swappable-seam pattern this monorepo already uses forapp/src/patient-cases-store.ts'sPatientCasesBackendandapp/src/medical-safety.ts'sMedicationSafetyProvider. - The Electron client is wired to this service end to end, including a
Settings UI.
app/src/shared-backend-auth.ts(OIDC PKCE, reusingapp/src/mcp-oauth.ts's loopback-redirect shape),app/src/shared-backend-client.ts(org discovery/bootstrap/selection —GET /me,POST /organizations), andapp/src/shared-patient-cases-backend.ts(a realPatientCasesBackendcalling this server's/organizations/:id/casesendpoints) all exist and are tested.frontend/src/pages/AuditPrivacy.tsx'sSharedBackendConnectionSectiondrives configure → connect → pick-an- organization end to end from the renderer — see that component's own doc comment. Not verified: this component has not been exercised in a running Electron window in the environment it was built in (no display available) — see "Known gaps." - Hybrid tenant isolation is implemented. Clinical data routes through a validated schema-per-organization directory. IAM/control-plane metadata remains in shared tables, protected by tenant-bound repositories and RLS. See ADR 0001 and migrations 008-009.
- Case data has no server-side encryption at rest —
InMemoryCaseStoreholds plaintext JSON in process memory; a real deployment behindDATABASE_URLwould need Postgres-level encryption (TDE, encrypted volumes) configured by whoever operates it, since this service does nothing itself here. This is exactly the trade-off docs/ENTERPRISE_MULTIUSER_ARCHITECTURE.md §7 names explicitly (server-managed keys, not end-to-end) — not silently assumed away.
IamStore (src/store/iam-store.ts) has two implementations, selected by
whether DATABASE_URL is set:
InMemoryIamStore(default) — everything lost on restart. Good for local development and this package's own test suite.PostgresIamStore(src/store/postgres-iam-store.ts) — real, parameterized-query SQL against the schema inmigrations/001_init.sql(run automatically at startup viasrc/store/migrate.ts, idempotent). Uses shared tables with anorganization_idcolumn, not schema-per-tenant — a deliberate simplification for this specific metadata (org/user/group/policy documents, not PHI), distinct from the architecture plan's schema-per-tenant default for patient-case data. Not run against a real Postgres instance in the environment this was built in —src/store/postgres-iam-store.test.tsis a real integration suite (TRUNCATE-based fixture reset, cascade-delete checks, the works), gated onDATABASE_URLand skipped (not failed) when it's absent. Run it for real before trusting this in production:DATABASE_URL=postgres://user:pass@localhost:5432/modelforge_iam npm test
CaseStore (src/store/case-store.ts) is exposed only through a
TenantCaseRepository. InMemoryCaseStore keeps independent tenant maps;
PostgresCaseStore resolves the tenant through TenantDirectory and uses
the schema created by migrations/009_tenant_clinical_schemas.sql.
Application-provided schema names are never interpolated into SQL.
PrincipalStore models OIDC identities separately from memberships, pending
invitations, and service principals. Its Postgres implementation is backed by
migrations/007_identity_membership.sql; the shared control-plane tables are
protected by the policies in 008_control_plane_rls.sql and are reached
through a tenant-bound repository/connection.
008_control_plane_rls.sql's tenant_isolation policies only have teeth
against a role that is both NO BYPASSRLS and not the owner of the
tables it queries — Postgres exempts table owners from row-level security
by default, regardless of BYPASSRLS. With a single role for everything
(the default — see below), RLS is enabled but inert; it becomes a real,
enforced second layer of defense only once the application connects as a
separate, restricted role.
DATABASE_URL— always where migrations run (src/store/migrate.tsviasrc/index.ts). Needs owner-level rights:CREATE SCHEMA,CREATE FUNCTION(theSECURITY DEFINERprovisioning function in009_tenant_clinical_schemas.sql),ALTER DEFAULT PRIVILEGES.RUNTIME_DATABASE_URL(optional) — when set, the application's own long-lived connection pool uses this URL instead, once migrations (againstDATABASE_URL) have finished. When unset — every deployment before this variable existed —DATABASE_URLis used for both, exactly as before: one role does everything, and RLS remains inert.
To actually enable enforcement, create a role named exactly
modelforge_runtime before running migrations —
migrations/010_runtime_role_grants.sql conditionally grants it (and
every future tenant schema 009's provisioning function creates) exactly
what it needs, and is a complete no-op if this role doesn't exist:
CREATE ROLE modelforge_runtime WITH LOGIN PASSWORD '...'
NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS NOREPLICATION;
GRANT CONNECT ON DATABASE modelforge_iam TO modelforge_runtime;then set RUNTIME_DATABASE_URL to that role's connection string. See
.github/workflows/ci.yml's "Create restricted runtime database role" step
for the exact statements CI runs, and
src/store/postgres-rls.test.ts for the adversarial tests (cross-tenant
read/write/join attempts, pooled-connection tenant-context reuse, the
runtime role's own privilege bounds) that only run meaningfully once both
DATABASE_URL and RUNTIME_DATABASE_URL point at real, distinct roles —
gated and skipped otherwise, for the same reason a superuser connection
can't be used to prove RLS works.
CachingIamStore (src/store/caching-iam-store.ts) sits between the
routes and IamStore when caching is enabled (default) — every
authenticated request resolves the caller's User record and full
effective policy set at least once (requireOrgUser →
findUserByExternalSubject; requirePermission//authz/check →
resolveEffectivePolicies), and against PostgresIamStore each of those
is a real round trip. See src/cache/ for the implementation; this
section is the operational summary.
- In-memory (
MemoryCache, default) — per-process, lost on restart. Fine for local development and a single-instance deployment. - Redis (
RedisCache,REDIS_URLset) — shared across every instance of this service, so a write on one instance is visible to every other instance's next read instead of each carrying its own cold cache.docker compose up -d redis(docker-compose.yml, this directory) runs one locally.
Both implement the same Cache<V> interface (src/cache/cache.ts) and the
same generation-tagged-key correctness scheme — see that file and
redis-cache.ts/memory-cache.ts's doc comments for the full detail. They
differ in a few ways that can't be made equivalent:
| MemoryCache | RedisCache | |
|---|---|---|
| Capacity bound | maxSize (LRU-evicted) |
none — Redis's own maxmemory/maxmemory-policy |
| Expired-entry reclaim | proactive periodic sweep + lazy on read | Redis's own internal TTL expiry |
| Stampede coalescing | per-process | per-process only (not cross-instance) |
| Invalidation failure | cannot fail | can fail (Redis unreachable) → degraded mode, below |
Since RedisCache never enforces a size cap itself, a deployment running
against Redis should configure Redis's own maxmemory and
maxmemory-policy (e.g. volatile-lru, since every key this service
writes already carries a TTL) rather than relying on this process to
bound memory.
Every T | null lookup here (getUser, getOrganization, getGroup,
getPolicy) caches a found result but never a miss — with one
deliberate exception: findUserByExternalSubject, called on every request
to an org-scoped route. An authenticated caller with no account in the
target organization is a real, repeating request pattern (an outsider
retrying, or a caller pointed at the wrong org), not a one-off — so that
miss is cached too, at a much shorter TTL (CACHE_NEGATIVE_TTL_MS,
default 5s, vs. CACHE_TTL_MS's default 30s) than a found user.
createUser/updateUser invalidate it through the exact same cache key
and mechanism as a positive result — no separate invalidation path exists
or is needed. See that method's doc comment in caching-iam-store.ts for
the full reasoning, including why every other id lookup deliberately does
not do this (their ids are server-generated UUIDs a legitimate caller
only ever has after the entity exists).
resolveEffectivePolicies depends on a user's own policies and every
policy attached to every group it belongs to. There's no reverse index
from a policy or group back to the users it affects, and building one (to
narrow invalidation to just the affected users) would trade a rare,
already-cheap admin write for correctness risk on the read side that
matters far more: this is an authorization cache, and an incorrectly-
narrowed invalidation would leak a stale Allow/Deny decision to a
request that has nothing to do with the write that just happened. Given
that trade, a group or policy mutation invalidates the entire
effectivePolicies cache namespace; a user update invalidates only that
one user's entry (unambiguous — no index needed). Group/policy writes are
rare admin operations; /authz/check is not, so this favors correctness
on the hot path over narrowing the cost of the cold one.
A burst of concurrent requests all missing on the same freshly-expired (or
never-cached) key join a single in-flight load instead of each issuing
their own backing-store query — see each Cache<V> implementation's doc
comment for how this stays safe under a concurrent invalidation (a caller
never coalesces onto a load that started before an invalidation its own
request arrived after).
If Redis becomes unreachable, every cache read degrades to a cache
miss (correctness-neutral — the request just falls through to the store,
slower but never stale). If Redis becomes unreachable during a
write-invalidation (a policy revoked, a user suspended, mid-outage),
that specific cache namespace enters a degraded state: every read
bypasses the cache entirely (never risking serving what might still be the
pre-invalidation value) until a generation-bump operation actually
succeeds again. Recovery is automatic and needs no operator action —
retried opportunistically on the next read to that namespace, and
proactively as soon as the shared Redis connection reports healthy again
(the ready event, src/cache/create-cache.ts). This only protects the
process that observed the failure; a multi-instance deployment relies on
the shared Redis INCR itself (one counter, not one per process) for
cross-instance consistency.
"Unreachable" isn't the only failure shape — an operator can also restore
Redis from a backup taken before a revoking mutation, which silently
undoes that mutation's own cache-invalidation call along with everything
else, potentially resurrecting a stale Allow under its original key. This
is exactly what "Redis outage cannot preserve an allow decision"
(docs/ENTERPRISE_ARCHITECTURE_ROADMAP.md's P0 item 9) is about: an outage
alone already can't (see above); a full data rollback is the harder case
this section covers.
resolveEffectivePolicies closes it with a durable, per-organization
authorization epoch (IamStore.getAuthorizationEpoch, bumped by
updateGroup/updatePolicy/deletePolicy atomically with their own
mutation — see that method's doc comment). The cached value is tagged with
the epoch it was computed under; every read compares that tag against the
current epoch, fetched through CachingIamStore's own small
authorizationEpochs cache — deliberately always in-process, never
Redis-backed, so the comparison target can't be rolled back along with
the rest of Redis. A resurrected stale entry's tag reads as older than the
current epoch and is recomputed rather than served. That epoch cache's own
TTL (2s, EPOCH_CACHE_TTL_MS) bounds how long this specific failure mode
could still serve a stale decision — not zero, but a small, fixed window
after what is already a rare, deliberate operational event, not an
ongoing exposure.
CachingIamStore.stats() returns per-sub-cache {hits, misses, size, loads, coalesced, loadTimeMsTotal, evictions, expirations, invalidations, redisErrors, degraded} (src/cache/cache.ts's CacheStats) — counts
only, never a cached key or value, so it's safe to log or expose without
risk of leaking a subject identifier, user id, or policy document. This
service logs one structured line ({"event":"cache_stats", ...}) per
sub-cache every 5 minutes (src/index.ts), plus an immediate line
whenever a Redis-backed cache enters or leaves degraded mode
({"event":"cache_degraded"|"cache_recovered", ...}) and on every caught
Redis operation failure ({"event":"cache_redis_error", ...}) — grep for
these in production logs to correlate a degraded-cache period with what
Redis itself was doing.
These same stats() numbers are also exposed as Prometheus gauges on GET /metrics (src/metrics.ts), alongside HTTP/authorization-decision/
audit-write latency histograms and audit-write outcome counts — see
docs/OBSERVABILITY.md for the full metric
list, the importable Grafana dashboard at server/observability/
grafana-dashboard.json, and how each panel maps to the roadmap's §17
provisional SLOs. /metrics is unauthenticated by default (every metric it
reports is a bounded-label aggregate, never a caller- or tenant-identifying
value) — set METRICS_TOKEN for a second layer if an operator wants one.
There's still no separate authenticated diagnostic endpoint beyond this —
a route that could return raw per-key cache state was judged more new
attack surface than this cache layer alone warrants; stats() remains
available programmatically to any future tool that wants it on demand.
See .env.example for the full list (CACHE_DISABLE, CACHE_TTL_MS,
CACHE_NEGATIVE_TTL_MS, REDIS_URL). CACHE_TTL_MS and
CACHE_NEGATIVE_TTL_MS are validated at startup — a malformed or
out-of-range value (non-numeric, negative, or outside the [1s, 1h] /
[100ms, CACHE_TTL_MS] bounds respectively) fails loudly with a
ConfigError rather than silently producing a cache that never expires or
expires every entry immediately. Every other numeric env var (PORT,
POOL_MAX, POOL_*_TIMEOUT_MS, RATE_LIMIT_MAX, RATE_LIMIT_WINDOW_MS)
gets the same treatment, for the same reason — a typo'd value fails startup
instead of silently becoming NaN somewhere downstream.
loadConfig (src/config.ts) also refuses to start against insecure
production-shaped configuration:
OIDC_ISSUERmust be HTTPS, unless it's an explicithttp://localhost/http://127.0.0.1/http://[::1]address (local development only) — a plaintext issuer makes JWKS/discovery tamperable in transit, a direct path to accepting forged tokens. Same policyapp/src/shared-backend-auth.ts'sisAllowedRemoteUrlalready applies client-side, applied here too.DATABASE_URL, when set, may not explicitly disable TLS (sslmode=disable/sslmode=allow) against a non-loopback host — an unsetsslmodeis left alone (most providers and every local setup work correctly without spelling it out); this only rejects an unambiguous opt-out of transport security against a real network destination.- Each entry in
OIDC_ADDITIONAL_ISSUERS, when set, must also be HTTPS (same bar asOIDC_ISSUER) and must not duplicateOIDC_ISSUERor another entry — see "Multiple identity providers, proxies, and custom certificate authorities" above.
Neither check is a substitute for docs/ENTERPRISE_ARCHITECTURE_ROADMAP.md's
P0 item 19 (TLS/secrets/KMS/production-configuration baseline) — there's no
secrets manager or KMS integration here, and Fastify itself still expects
TLS to be terminated in front of it (a reverse proxy, per TRUST_PROXY's
own doc comment) — this is the narrow, unilaterally-safe slice of that item:
failing startup on the specific insecure configurations above, matching
that item's own "Verification: Startup fails on insecure configuration."
npm install
cp .env.example .env # fill in OIDC_ISSUER and OIDC_AUDIENCE at minimum
npm run dev # tsx watch, http://localhost:4000npm test runs the full suite (251 tests as of this writing, plus 53 more
gated on DATABASE_URL/REDIS_URL — see "Persistence" above and
src/cache/create-cache.redis.test.ts) — the policy evaluator, the
in-memory store, OIDC token verification, and full request/response
integration tests (including patient-case CRUD and its authz gating) against
a real fastify.inject()-driven app instance, all using a locally generated
RSA keypair (no real IdP or network call needed to run the tests; see
src/app.test.ts). CI (.github/workflows/ci.yml's server job) runs the
whole suite against real Postgres and Redis service containers, so the
gated suites above are actually exercised on every push/PR, not just when a
developer happens to run them locally.
npm run build && npm start builds to dist/ and runs the compiled output.
OIDC_ISSUER and OIDC_AUDIENCE (both required) point at any
spec-compliant OIDC provider — Cognito User Pools, Keycloak, or anything
else. src/auth/oidc-verifier.ts verifies a bearer token's signature
(against the issuer's JWKS, resolved via OIDC_JWKS_URI directly or OIDC
discovery), issuer, audience, and expiry. Nothing here is Cognito- or
Keycloak-specific — this is deliberate, per
docs/ENTERPRISE_MULTIUSER_ARCHITECTURE.md §4's decision to decide the actual
IdP per deployment topology rather than hard-code one.
OIDC_AUDIENCE is required, not just recommended, specifically because
this is a generic relying party: without checking aud, a validly-signed
ID token — or an access token minted for a completely different client of
the same IdP — would verify successfully here (same issuer, same signing
key, a real sub claim) and be treated as a genuine access token for this
API. aud is the portable, spec-defined way (RFC 7519 §4.1.3) to rule that
out without coupling this service to one provider's own token-type claim
(e.g. Cognito's token_use).
The verified token's sub claim becomes User.externalSubject — the only
thing tying a User record to a real identity. This service never stores a
password or any other credential.
(docs/ENTERPRISE_ARCHITECTURE_ROADMAP.md P2 item 3: "proxy, custom CA, and multiple-IdP compatibility.")
Multiple IdPs. Set OIDC_ADDITIONAL_ISSUERS to a JSON array of
{issuer, audience, jwksUri?} objects to accept tokens from more than one
IdP at once — e.g. migrating between providers, or federating a legacy
on-prem provider alongside a new cloud one. OIDC_ISSUER/OIDC_AUDIENCE
remain required and always trusted; this is purely additive. Each issuer
still needs its own real, HTTPS discovery/JWKS endpoint and a distinct
issuer string — a token is routed to the right issuer's keys by its own
(unverified) iss claim, but that routing is never itself the security
check: src/auth/oidc-verifier.ts's verifyAccessToken independently
re-validates the real signature and issuer against whichever issuer got
selected, so a token that merely claims a trusted issuer without actually
being signed by it is rejected the normal way. Example:
OIDC_ADDITIONAL_ISSUERS='[{"issuer":"https://idp-b.example-hospital.org","audience":"modelforge-iam-server-b"}]'HTTP(S) proxy. This process's outbound calls (OIDC discovery, JWKS
fetch) use Node's global fetch. If your network requires all outbound
traffic through a corporate proxy, set NODE_OPTIONS=--use-env-proxy (Node
≥ 22) and the standard HTTP_PROXY/HTTPS_PROXY/NO_PROXY environment
variables — no application code or configuration here is needed, since this
is a Node runtime capability that applies to every outbound HTTPS call this
process makes.
Custom/internal certificate authority. If your IdP (or anything else
this process calls over HTTPS) presents a certificate issued by an internal
or self-signed CA, set NODE_EXTRA_CA_CERTS=/path/to/your-ca-bundle.pem —
again a Node runtime capability, not something this service implements or
needs to. Do not disable certificate validation (NODE_TLS_REJECT_UNAUTHORIZED=0)
as a substitute for this: that disables validation for every TLS connection
this process makes, including to your database and object storage.
A Policy is a named JSON document of statements, not a fixed role enum:
{
"version": "2026-01-01",
"statements": [
{
"sid": "AllowCaseViewInCardiology",
"effect": "Allow",
"actions": ["patientCase:view"],
"resources": ["organization:org-1/*"],
"condition": { "StringEquals": { "user:department": "cardiology" } }
}
]
}- Actions/resources support
*as a wildcard (patientCase:*,organization:org-1/*) — matched bysrc/domain/policy-evaluator.ts'smatchesPattern, a fixed pattern language, never a policy-author-supplied regex (that would make a policy document a code-injection-shaped surface instead of a data-shaped one). - Conditions support
StringEquals/StringNotEqualsagainst a context map.user:idanduser:organizationIdare always injected server-side bysrc/routes/guards.ts'srequirePermissionafter any caller-supplied context, so a caller can never override its own resolved identity by sending a context key with the same name. - Evaluation semantics are AWS IAM's: default deny (nothing matched →
deny), and an explicit
Denystatement always overrides any number of matchingAllowstatements, regardless of order or which policy they're in. Seesrc/domain/policy-evaluator.test.tsfor the full behavior matrix. - Policies attach to a
Userdirectly, to aGroupthe user belongs to, or both —IamStore.resolveEffectivePoliciesunions and de-duplicates across both before evaluation. - A
Usercan optionally carry a permission boundary (permissionBoundaryPolicyId, another AWS IAM concept) — a single policy that caps what their own policies (direct + via groups) can ever grant, regardless of how permissive those are. The effective decision is Allow only if both the identity's own policies and the boundary allow it; seesrc/domain/policy-evaluator.ts'sevaluateWithBoundary. Useful for safely delegatingiam:managePoliciesto a sub-admin without risking them (or anyone they grant access to) exceeding a defined ceiling. Setting or changing it is gated behindiam:managePolicies, same aspolicyIds(see the table below). Fails closed, not open: if the referenced policy is later deleted, the reference is left dangling on purpose (no foreign key at the storage layer) sosrc/routes/guards.ts'sresolveEffectivePoliciesWithBoundarydenies that user everything rather than silently treating a vanished boundary as no boundary at all.POST /organizations/:id/authz/check— documented below as "the primitive every other service is meant to call" — goes through this same resolution, not a bare policy evaluation, so it never gives a different answer than every other route already enforces for the same user.
POST /organizations is the one deliberate exception to "every other
endpoint requires an existing account with the right permission": creating
an organization requires only a valid bearer token (any authenticated
identity), because no organization exists yet for a permission check to be
scoped against. The caller automatically becomes that organization's admin,
via a builtin: true policy (src/domain/builtin-policies.ts) granting
* on organization:{id} and organization:{id}/* — scoped to that one
organization only, never wider. Every other route requires the caller to
already hold an account with the relevant iam:* permission in the target
organization; there is no self-service sign-up path beyond that one
bootstrap case.
| Method | Path | Auth | Notes |
|---|---|---|---|
| GET | /health |
none | Original combined probe (kept for back-compat) — DB-aware, same as /health/ready below |
| GET | /health/live |
none | Liveness probe — never depends on Postgres; "this process can answer HTTP" only |
| GET | /health/ready |
none | Readiness probe — DB-aware; 503 degraded when configured healthCheck fails |
| GET | /metrics |
none (optional METRICS_TOKEN) |
Prometheus exposition — see docs/OBSERVABILITY.md |
Before promoting a candidate deployment, build the server and run the platform-neutral canary gate with CANARY_BASE_URL=https://candidate... npm run ops:canary. It verifies liveness, database-aware readiness, Prometheus metrics, consecutive success, and readiness p95, then emits a machine-readable JSON decision. See docs/CANARY_RELEASES.md for configuration and scope limits.
| POST | /organizations | bearer token only | Bootstrap: creates org + admin user + admin policy |
| GET | /organizations/:id | account in org | |
| GET | /me | bearer token only | Every org membership + effective policy names for this identity |
| GET/POST | /organizations/:id/users | iam:listUsers/iam:manageUsers | Setting policyIds/permissionBoundaryPolicyId also requires iam:managePolicies |
| PATCH | /organizations/:id/users/:userId | iam:manageUsers | 404 if the user id belongs to a different org; same extra iam:managePolicies gate as above |
| GET/POST | /organizations/:id/invitations | iam:listUsers/iam:manageUsers | Pending membership is first-class; acceptance binds the authenticated identity |
| POST | /organizations/:id/invitations/:invitationId/accept | bearer token + invite token | Activates the membership; invitation tokens are stored hashed |
| GET/POST | /organizations/:id/service-principals | iam:listUsers/iam:manageUsers | Non-human callers have distinct credentials and membership lifecycle |
| PATCH/DELETE | /organizations/:id/service-principals/:principalId | iam:manageUsers | Disable, rotate metadata, or revoke a service principal |
| GET/POST | /organizations/:id/groups | iam:listGroups/iam:manageGroups | |
| PATCH | /organizations/:id/groups/:groupId | iam:manageGroups | |
| GET/POST | /organizations/:id/policies | iam:listPolicies/iam:managePolicies | |
| PATCH/DELETE | /organizations/:id/policies/:policyId | iam:managePolicies | Deleting a builtin policy is 400 |
| POST | /organizations/:id/authz/check | account in org | The enforcement primitive — {action, resource, context?} → {effect, matchedStatement?} |
| GET | /organizations/:id/cases?since={cursor} | patientCase:view | Snapshot-consistent high-water feed; returns upserts and tombstones, filtered per resource |
| GET | /organizations/:id/cases/:caseId | patientCase:view | A denied case is indistinguishable from an absent case (404) |
| POST | /organizations/:id/cases | patientCase:create | 409 + current on id collision. Optional Idempotency-Key (see below) |
| PUT | /organizations/:id/cases/:caseId | patientCase:edit | Requires If-Match; changing access attributes also requires patientCase:manageAccess |
| DELETE | /organizations/:id/cases/:caseId | patientCase:delete | If-Match optional; 412 + current on mismatch, 404 if already gone. Already idempotent via the 404 convention — no Idempotency-Key support needed |
| POST | /organizations/:id/case-migrations | patientCase:migrate | Start/resume an idempotent staged local-to-shared migration |
| GET | /organizations/:id/case-migrations/:migrationId | patientCase:migrate | Inspect resumable state and the stored validation preview |
| PUT | /organizations/:id/case-migrations/:migrationId/batches | patientCase:migrate | Upload idempotent batches; same item key with different data is 409 |
| POST | /organizations/:id/case-migrations/:migrationId/validate | patientCase:migrate | Full-schema validation plus collision preview; staged rows stay invisible |
| POST | /organizations/:id/case-migrations/:migrationId/activate | patientCase:migrate | Atomically exposes validated rows and emits change-feed events |
| POST | /organizations/:id/case-migrations/:migrationId/rollback | patientCase:migrate | Hides imported rows and emits tombstones so clients converge |
| GET | /organizations/:id/audit | audit:read | Immutable audit trail (see below) — own action, not folded into any iam:*/patientCase:* permission |
| GET/POST | /organizations/:id/scim-tokens | scim:manageTokens | Manage SCIM bearer tokens for external IdP provisioning — see docs/SCIM.md |
| DELETE | /organizations/:id/scim-tokens/:tokenId | scim:manageTokens | Revoke a SCIM token |
| GET/POST/PUT/PATCH/DELETE | /scim/v2/organizations/:id/Users[/:userId] | SCIM bearer token (not OIDC) | RFC 7643/7644 provisioning — see docs/SCIM.md |
Idempotency-Key (POST/PUT only, src/routes/idempotency.ts): a
client-generated token identifying one logical write attempt. Resending the
same key with an identical body replays the original response instead of
re-running the write — the case this matters for is PUT: a client whose edit
succeeded server-side but never saw the response (dropped connection,
timeout) would otherwise retry with its now-stale If-Match and get a
spurious 412 indistinguishable from a real concurrent edit. Resending the
same key with a different body is rejected 409 idempotency_key_reused
rather than silently running either version. Fully opt-in — omitting the
header behaves exactly as if it didn't exist. Records are kept 24h
(InMemoryIdempotencyStore/PostgresIdempotencyStore). Server-side support
only: nothing in app/'s client currently generates/retries with a key
(there's no offline-queue/retry loop yet to reuse one across attempts) — see
docs/ENTERPRISE_ARCHITECTURE_ROADMAP.md's "Offline edit and reconnect"
flow for the client-side half this sets up for.
Audit log (src/store/audit-store.ts): every IAM mutation
(organization/user/group/policy create/update/delete) and every case
write/delete records an entry — who (actorExternalSubject, plus
actorUserId once a User record exists), what (action, a fixed
<resource>.<verb> vocabulary like "policy.delete"), and which record
(targetType/targetId). Immutable: no update or delete method is exposed
by this store, by design. On Postgres, the audit row is written inside the
same transaction as the mutation it records (insertAuditEntry, called
with the mutation's own PoolClient) — a rolled-back mutation writes no
audit row, and a committed mutation can't fail to produce one; this is the
"transactional outbox" property
docs/ENTERPRISE_ARCHITECTURE_ROADMAP.md's P0 item 11 asks for. The
in-memory mode has no transactions to join (nothing here does), so the
same guarantee holds trivially: InMemoryIamStore/InMemoryCaseStore
append the entry synchronously, in the same call, right alongside the
in-memory mutation itself — one shared InMemoryAuditStore instance is
passed to both (see index.ts) so IAM and case mutations merge into one
chronological trail, the same thing Postgres mode gets from both stores
writing into the same audit_log table. Not included: a publisher
that ships entries to an external system — nothing reads this table today
except GET /organizations/:id/audit itself; wiring a real "outbox" (a
process that tails new rows and forwards them) is separate work with no
concrete external destination yet to build against.
Every non-bootstrap, non-/me route requires an active first-class
membership for the authenticated human or service principal
(src/routes/guards.ts). A valid OIDC token without a membership gets
403, not a silent empty result and not auto-provisioning. A narrow legacy
fallback remains for pre-migration User rows that do not yet have an
Identity; once an identity exists the new membership model fails closed.
- No server-side encryption of case data (see "What this is not"
above) —
InMemoryCaseStoreholds plaintext in process memory,PostgresCaseStorestores plaintext JSONB (patient_cases.data); a real deployment needs Postgres-level encryption (TDE, encrypted volumes) configured by whoever operates it. This is the server-managed-keys trade-off docs/ENTERPRISE_MULTIUSER_ARCHITECTURE.md §7 names explicitly, not silently assumed away. PostgresIamStore,PostgresCaseStore,PostgresCaseMigrationStore, andpostgres-rls.test.ts's role-separation/RLS penetration tests are untested against a real database in this environment (see "Persistence" and "Role separation" above) — the SQL has been reviewed carefully, including the Postgres-documented interaction between RLS and FK-triggered cascade deletes (deleteOrganization's cleanup relies on cascades bypassing RLS rather than being blocked by it), but none of it is proven by execution. Run withDATABASE_URL(and, for the role-separation tests specifically,RUNTIME_DATABASE_URLpointed at a realmodelforge_runtimerole) before trusting any of this in production.- Role separation has no retroactive-grant path for an already-populated
database —
010_runtime_role_grants.sql'sALTER DEFAULT PRIVILEGESonly covers tables created aftermodelforge_runtimeexists. Every environment this ships to today (CI, any fresh deployment) creates the role before the first organization is bootstrapped, so the gap doesn't arise there; an operator turning on role separation against an existing, already-populated database needs to run the equivalentGRANTs by hand once, against each existing tenant schema, first. - Staged case migration has no persisted reconciliation report or
rolled-back-data retention policy —
activate()now re-checks for a destination that changed since validation (concurrent case creation) before applying anything, and a failed activation/rollback attempt cleans up after itself (seein-memory-case-migration-store.ts's andpostgres-case-migration-store.ts's doc comments), but there is no durable, queryable record of what a completed activation actually did beyond the migration's ownpreviewsnapshot, and no scheduled cleanup ofrolled-backmigrations' staged data — both are real features, not bugs, and need their own design pass. SharedBackendConnectionSection(the Settings UI) has not been visually verified in a running Electron window — no display was available in the environment it was built in. It typechecks cleanly and its underlying calls (app/src/shared-backend-auth.ts,app/src/shared-backend-client.ts,app/src/shared-patient-cases-backend.ts) are unit-tested against a mockedfetch, but the component itself — state transitions, form-then-connect-then-pick-organization flow, error rendering — has only been read-reviewed, not clicked through.- Version threading from the Electron UI is now real, but only for
edits/deletes made through
PatientCaseDetail.tsx/PatientCases.tsx— both screens now pass the case's loadedversionthroughpatientCases:update/patientCases:deleteand surface aCaseWriteConflictErroras a toast + automatic reload rather than silently overwriting a concurrent edit.grantConsent/revokeConsent/addClinicalNote/reviewClinicalNoteremain intentionally version-unaware (seeapp/src/patient-cases-store.ts's doc comments on each — they're additive/append operations, not whole-record overwrites, so there is no "base snapshot" to protect). - No refresh-token handling, logout, or session concept — this service only ever verifies a bearer token per request; session lifecycle is entirely the IdP's responsibility, consistent with delegating authentication to it.
- No request logging beyond Fastify's own, or production TLS
termination — assumed to sit behind a reverse proxy/load balancer in any
real deployment, not configured here. (Rate limiting is configured —
see the API surface section above and
.env.example'sRATE_LIMIT_*.) - The audit log (see "Audit log" above) has no publisher/outbox consumer
and no hash-chaining — every mutation is durably recorded and readable
via
GET /organizations/:id/audit, but nothing ships those entries anywhere external yet, and entries aren't cryptographically chained against tampering. The aggregated, hash-chained audit stream docs/ENTERPRISE_MULTIUSER_ARCHITECTURE.md §3/§5 describes is a further, not-yet-built piece on top of this one. - Workspace and department are authorization attributes, not independent directory objects yet. Case resources carry workspace, department, ownership, assignment, and consent attributes and policies can constrain all of them. CRUD/lifecycle management for workspace and department entities remains outside this slice.