LQMS Security Posture¶
- Status: the C-5 security posture document from v1.0-criteria.md — assembly of evidence that already exists, not new claims.
- As of: v0.9.44 (2026-09-04). This is a hardening-era snapshot, and it says so: two fix waves are in flight toward v0.9.45 — lane A (audit walls: the V128 append predicate, the record-site census guard, the trail-honesty fixes) and lane B (second doors: the concurrency review's X-1..X-4). Where a control is in flight rather than shipped, this document marks it [in flight] instead of claiming it.
- Audience: a customer's security assessor, and an auditor. It assumes no prior contact with the codebase; every claim carries a pointer into the evidence that proves it.
- Reading rule: positives cite their proof (a review's attack number, a test class, a migration line). Negatives — what the system deliberately does not defend — are declared in §2, not omitted. The deliberate product limitations live in the companion known-limits statement.
1. The claim¶
One LQMS installation serves many organizations (mandators), and mandator/project data separation is the top quality goal of the system (ADR-0002; the risk criteria make cross-tenant harm S4 — never acceptable at any probability, risk_management.md). The specific promise:
A user authorized in organization A cannot read, infer, or influence organization B's data — and controlled records, together with the audit trail that witnesses them, cannot be forged, silently lost, or changed without a record.
That promise was not merely designed; it was attacked. Two independent efforts stand behind it as of this writing:
- The C-1a review arc (2026-08-31, eight modules, six lenses): 82 findings, 0 CRITICAL, no
separation breach; the fix queue emptied 2026-09-02 — every code-needing finding is in
production since v0.9.42 (review-c1a-findings.md and the
review-c1a-module3..8.mdregisters). - The adversarial separation review (2026-09-04): a dedicated cross-cutting assault on the seams between mechanisms — 30 attacks across seven surfaces, each with a named actor, held permissions and exact calls, traced through real code to a verdict. No breach. 29 of 30 attacks died at a named line; the thirtieth had no scope-boundary bearing at all. (review-separation-adversarial.md)
The rest of this document is the anatomy of that claim: the trust boundaries (§2), the mechanisms and the attacks they killed (§3), authentication (§4), authorization (§5), evidence integrity (§6), concurrency (§7), the deployed perimeter (§8), the supply chain (§9), and — because a posture that lists only its walls is advertising — the complete residual list with each item's register or risk home (§10).
2. Trust boundaries — what the system deliberately does NOT defend¶
The separation promise is made between tenants, below a set of trusted parties. Each boundary below is a design decision with a recorded home, not an oversight.
- The installation operator. A GLOBAL assignment carrying
IMPORT_PROJECTS/EXPORT_PROJECTS/MANAGE_*reaches every tenant — that is what a GLOBAL administrative grant means (ADR-0128: administrative classes cascade from GLOBAL; content classes do not). The adversarial review set its breach bar accordingly: an operator path is a finding only if it exceeds what the GLOBAL grant already grants — none did. In particular, the portability export of any scope is the exporter grant's documented meaning (ADR-0125 §4's installation-wide disclosure ruling; narrowing which scope kinds are exportable is ruled — register row 55 — and queued). An installation's tenants trust its operator; the system does not pretend otherwise. - The database superuser and the migration role.
lqms_migrationholdsBYPASSRLSby design — the SECURITY DEFINER functions require an RLS-bypassing owner, and the app refuses to boot if that is not so (FanoutDeploymentCheck, deployment.md §4). Anyone holding database-superuser or migration-role credentials is outside the defended perimeter: the runtime RLS discipline defends against application defects, not against a hostile DBA. - The host. Root on the server holds the PostgreSQL volume, the content blob store,
Keycloak's identity file, the backup archives and the secrets in
server.env. Encryption at rest is platform-level by decision (disk/volume encryption, managed by the platform — ADR-0018); application-level per-scope encryption / crypto-shredding is a documented future option (GAP-06), not a shipped control. - The identity provider. LQMS stores no passwords and no second-factor secrets. MFA/2FA, password policy, brute-force and credential-stuffing protection, and session idle/absolute timeouts are the IdP's controls (ADR-0011; REQ-AUTH-005 is an accepted-gap in the traceability matrix precisely because it is IdP-enforced). The bundled Keycloak provides all of these; configuring them is the deployment's act, not the application's.
- The backup archive holder. A backup archive is every tenant's data plus the full trail. Its custody is the operator's; its acquisition is today unaudited — that is trail-honesty finding TH-1 (MAJOR), fix [in flight] (§10).
- A whole-database restore replaces the world, trail included — a substrate-level act the trail it replaces cannot record (trail-honesty review, argued-exempt E-7 — an inherent limit). What IS defended: the restore validates the archive fully before its destructive point (RISK-007), and rehearsal records are graded against evidence outside the restored copy (server-deployment.md §9.3).
3. The separation boundary, and how it held¶
The architecture in one paragraph: every content-bearing table is ENABLE/FORCE row-level
security, keyed on a transaction-local authorized-scope set
(ADR-0009); the set is published with
SET LOCAL bind parameters so it can never outlive its transaction or leak across the connection
pool (ADR-0026, proven by
RlsConnectionReuseTest); the boundary runs between content plane (RLS-guarded) and control
plane (permission-guarded) (ADR-0027 — with
its declared trade-off: scope/mandator metadata is defended by permission filtering, not by the
DB backstop). Every scope-keyed content table is FORCE-RLS with a
DB-level cross-scope proof (37 tables at the 2026-08-21 re-verification, RISK-001), and
RlsPolicyInventoryTest classifies the whole table inventory and fails the build on an
unclassified newcomer — mutation-proven: nine policy mutations applied to the live schema, all
nine caught.
The adversarial review's defense map is the honest inventory of which mechanism actually stopped which attack. Reproduced here as the posture's spine — rows 1–9 are structure (predicates, policies, path shapes) and survived every attack aimed at them, usually redundantly; row 10 is convention, and is the subject of the current hardening wave:
| # | Mechanism | Proven by (attacks blocked, at the review's named lines) |
|---|---|---|
| 1 | The V121 resolver predicate — the RLS scope set is VIEW-gated; the MANDATOR disjunct keys on non-NULL mandator equality; the GLOBAL disjunct keys on the generated cascades_from_global column, never a literal |
A2 (NULL-edge widening), A3 (riding the universal Base Reader assignment into tenants — blocked at V121:176-177), A8, A10, A18, A30. Every "effective at GLOBAL" gate in the system bottoms out here |
| 2 | FORCE-RLS content policies over the transaction-local GUC (V002:26-35; per-table scope_id = any(...), including the newest table, V125 save-points) |
A11 (audit-pack citation closure as a drag-net — invisible documents are silently absent), A13, A14, A19, A24, A25 |
| 3 | Gate-then-publish — authority is checked before any scope set replaces the caller's (import cage, export twin, folder governance) | A8 (steering the import family), A9 (cage narrowing fails shut), A10 (aiming an export at a foreign scope — the 403 precedes any publish, asked again inside the plan transaction), A26, A30 |
| 4 | Self-gating SECURITY DEFINER bodies — every user-callable definer re-asks its own gate in its body; the sweeps are owner-only EXECUTE | A5 (roster functions as a directory), A6, A7 (fan-out bodies), A18 |
| 5 | The 404-merge / gate-first refusal posture — "not found" and "not authorized" answer identically; unknown ids resolve to GLOBAL and are refused namelessly | A17 (existence oracles), A21, A23 (refusal payloads carry only what the caller already supplied) |
| 6 | Per-scope blob partitioning + hash shape check — dedup is per-partition; no API accepts a hash as input | A24 (a hash known from A read or attached in B — blocked at FilesystemContentStore.kt:87) |
| 7 | Connect-time scope-set keying of live channels (SSE routing, collab room admission) | A20, A21 (collab join requires the identical triple as typing into the draft; frames relay only within the admitted room) |
| 8 | Explicit cross-scope boundary gates on the deliberate readers — dossier pins, trace links, the citation closure, the V124 base-relation policies all ask VIEW-or-authorized in the target scope on top of RLS | A11, A13 (a foreign trace verdict is computed only inside an authorized branch; the degrade emits the caller's own recorded claim), A15, A16 (the pin's disclosure table withholds everything live from an invisible target) |
| 9 | Bind-parameter publish discipline — all ten set_config sites feed UUIDs read from this database, never request strings; a malformed value raises rather than widens |
A1 (GUC forgery) |
| 10 | Call-site convention — gate-then-record (103 audit sites), pass-own-user-id (14 sites), oracle-behind-gate (10 sites) | A12, A22 — every site verified correct, and this is the one wall with no mechanism behind it (findings F-1, F-3) |
The honest asymmetry, stated as the review stated it: reads are defended by mechanism —
RLS plus gated publishes plus self-gating definers, defense-in-depth that killed most read
attacks twice — while audit appends (the influence channel into another tenant's evidence) were
defended by convention alone. Three individually-argued migrations had composed into a void
backstop: since V117 gave every active user a GLOBAL Base Reader assignment, the DB-level append
policy admitted any user's transaction writing into any tenant's trail, and the only wall was the
unanimity of ~103 hand-written call sites — correct at every one, pinned by nothing (finding
F-1, MAJOR as defense-in-depth, not a live hole: no caller-steered un-gated record site
exists). Ruled 2026-09-04 (register row 57, option c): both remedies — the V128 predicate
(append reach from GLOBAL requires a permission that actually cascades out of GLOBAL, the same
generated column the read side already obeys) and the AuditRecordCensusTest call-site
census guard. [in flight] — both are in the working tree for v0.9.45 at this writing.
4. Authentication and session¶
- OIDC only, no credentials stored (ADR-0011). Any standards-compliant provider; standalone installs bundle Keycloak. LQMS holds no passwords, no MFA secrets, no local-account fallback.
- Browser sessions are BFF-style with a stateless encrypted cookie
(ADR-0028):
httpOnly,Secure,SameSite=Lax, encrypted with a server-held key, carrying no client-extractable claims; no server-side session store. Authorization is re-evaluated on every request against live state, so administrative revocation (deactivation, role removal) is effective on the user's next request. Declared trade-off (ADR-0028): a stolen cookie cannot be force-killed mid-lifetime; the window is bounded by token/session lifetimes and closable globally by rotating the cookie-encryption key. - CSRF (ADR-0033):
SameSite=Laxplus JSON-only mutating endpoints, deliberately no synchronizer-token filter; the recorded guardrail is that permissive CORS or non-JSON mutation bodies may not be introduced without revisiting the decision. - Token rejection is verified, not just configured (RISK-009,
backend/api/src/test/kotlin/ch/lqms/api/WrongAudienceTokenTest.kt): a genuine same-realm token whose only defect is its audience — and a second specimen with a tampered signature — get a named 401, mint noapp_user, and settle no open invitation (the concrete "authenticated foothold" the risk row describes). Red-proven: widening the configured audience made the specimen pass, so a green run demonstrably reads the control, not the fixture. Named residue: wrong-issuer and expiry rejection are configured but untested, with the reason in the test's KDoc (§10). - JIT provisioning grants nothing. Authentication ≠ authorization: a provisioned user with no role assignment sees no scope's data (deny-by-default, enforced by RLS). The JIT mint itself currently leaves no trail row — trail-honesty TH-3, MINOR, in the hardening wave (§10).
- MFA/2FA is available through the IdP (the bundled Keycloak supports OTP/WebAuthn) and is a deployment configuration, not an application control — see §2, trust boundary four.
5. Authorization¶
- Deny-unless-granted, with no fallback policy. An unconfigured transition denies everyone (ADR-0096: "empty means NOBODY"); seeds and presets write explicit grants.
- GLOBAL is not a skeleton key. Permission classes are structural
(ADR-0128/ADR-0129):
content-class permissions and role identity from a GLOBAL assignment act in GLOBAL alone;
only administrative classes cascade. The classification is a generated column, pinned by
GlobalCascadeClassificationTest— it cannot be widened by editing a function body. - Admin ≠ content (ADR-0012 as amended by ADR-0121): system administration does not grant content read; the Project Administrator role is the recorded, narrow project-level exception.
- Review governance is structural. Four-eyes reads a stored, constraint-held submitter stamp (RISK-003: the V107 biconditional CHECK makes a missing stamp fail closed, mutation-proven); the cross-role distinct-person floor is on everywhere (ADR-0132 — its declared relaxation flag is post-1.0, see known-limits); the last-administrator invariant is held under an advisory lock, so two admins cannot deactivate each other concurrently.
- Escalation is a witness, not a capability. Break-glass and role substitution are decided as own-name, timed, audited self-staffing — never impersonation (ADR-0130/ADR-0131). At this writing the mechanism is not yet built (it is in the 1.0 runway, v1.0-criteria C-4c); the doctrine is already enforced in practice — see the register's row-48/QMB trail, where granting a role via API as a real person was refused as a false witnessed act.
6. Evidence integrity — the audit trail¶
- Append-only, twice-locked. No UPDATE/DELETE policy and no UPDATE/DELETE grant exist on
audit_event, both asserted byRlsPolicyInventoryTest(RISK-004) — a stray futureGRANT UPDATEfails the build rather than silently making evidence mutable. Unpaired surrogates are refused with a 400 rather than stored corrupted; a link-borne decline records no actor rather than forging one; caps are disclosed on the wire. - The census. The trail-honesty review (2026-09-04,
review-trail-honesty.md) walked ~110 distinct action kinds
from both directions — every recording site, and every mutation asking what records nothing.
Verdict: "the trail's writing discipline is unusually strong" — with the hard attribution
cases (token declines, acceptance staffing, scheduler acts, imported provenance) already argued
and right. The gaps it found are omission-shaped and are the current hardening wave: TH-1
(backup download unaudited), TH-2 (task resolution anonymous — for
DISPOSAL_DUEa retention deferral with no recorded actor), TH-8/TH-9 (payload-less governance-config events — the trail cannot reconstruct the review policy in force at a past release; the matrix's REQ-AUD-005 "verified" over-claims until this lands), TH-11 (within one transaction, display order is a stable coin flip). All [in flight], lane A (§10). - Exports record before bytes. Every derived-artifact pull (audit pack, dossier, CSV,
portability bundle, per-version PDF) records its
EXPORTEDevent on the transaction that decides the copy, before the first byte can stream — verified exporter-by-exporter (the D-11/row-51 family). The one artifact pull still outside the discipline is TH-1's backup download. - Destruction is witnessed. Only two designed destroyers can remove trail rows (project dissolution, mandator deletion); both record what they destroy — counts per table, trail included — at a surviving anchor, in the same transaction, and one witnessed act in a scope makes it undissolvable (the V106 working-record tripwire; a single trail row makes an organization permanent). Legal hold blocks disposal outright.
7. Concurrency and data integrity¶
The whole-system concurrency review (2026-09-04, review-concurrency.md) built the system lock/ordering map and hunted cross-service interleavings. Verdict: zero CRITICAL, zero MAJOR. The load-bearing mechanisms were attacked and held, with the refutations recorded as results:
- the RISK-006 collab-buffer fix (a stale flush replaying over a newer save) is certified "airtight in both orderings" — the generation re-check happens under the buffer lock, and no interleaving exists in which a stale batch both holds the lock and sees a current generation;
- the ADR-0092 blob-deletion mutex is a genuine mutex against every writer counterparty (import, restore, disposal-immediate, sweep-vs-sweep); RISK-005's named model residual (a third ordering) is on the post-1.0 list;
- SSE never notifies uncommitted state (post-commit relay through the one wrapper every content transaction uses); invitation redemption and the last-administrator invariant are closed by conditional UPDATEs and a held advisory lock.
Four findings stand — X-1 (export stream vs disposal's immediate purge: one lost export attempt, retryable, no corruption), X-2 (a disposal/dissolution lock-order inversion: a detectable deadlock two admins can trigger in a teardown), X-3/X-4 (retried approve/restore double-fire: a misleading 500, a duplicated history entry) — three MINOR, one NOTE, none a separation or corruption path. [in flight], lane B (§10).
8. The deployed perimeter¶
The reference topology (server-deployment.md) is a single Linux host, and its exposure summary is the deployment's security claim:
- Public: 443 (and 80 for ACME/redirect) only. Automatic TLS with HSTS via Caddy. PostgreSQL is never published; the app's HTTP port is never published; Keycloak's admin console and master realm are not routed publicly (Caddy answers 404 — not 403, no confirmation an admin console exists) and are reached only via an SSH tunnel to a loopback-bound port.
- Outbound: the server never fetches a user-supplied URL. No server-side request is ever made to a URL an author or a user provides — not to render content, not to preview a reference, not to validate one. The only URLs the application dials are its operator-configured infrastructure (the IdP, the database); document content reaches the server, never the reverse. Consequently the whole SSRF class is structurally absent rather than filtered: there is no fetching code path to defend, no allow-list to keep correct, no DNS-rebinding window. Content follows the same line: a document's only embeddable image is one of its own attachments, and an external image reference degrades to an inert chip on every surface, reader and author alike (register row 73, ruled 2026-09-06 — a third party's bytes cannot be part of a released record). This is a standing rule, overturnable only by ADR: the one feature that wants such a fetch — the external-image snapshot with provenance (register row 74, same day) — is designed client-side, as Tier 1 (browser CORS fetch) and Tier 2 (guided copy-paste), because the server-side Tier 0 is closed.
- Host hardening is in the guide, not assumed: ufw plus the
DOCKER-USERrule (Docker publishes around ufw — stated, with the iptables counter-rule), unattended OS updates, secrets via an env file with a §5.1 regeneration inventory, and a rotation runbook (§10 there). - The deliberate negatives are named in the guide's own §14 so none reads as an oversight,
and this posture repeats the security-relevant ones: Keycloak runs on its dev-file (H2) store
(backed up as a realm export every run — the volume is load-bearing); the runtime datasource
is a single BYPASSRLS credential with separation enforced by the per-transaction
SET LOCAL ROLE lqms_appdiscipline — DEBT-001, see §10 and known-limits; the published image is neither signed nor attested; the registry pull credential is one personal token; there is no metrics/log-aggregation stack; the reverse proxy's access logs are off as a privacy decision (client IPs + document URLs are personal data needing a retention decision first). - Backups: nightly
pg_dump+ blob copy with re-hash + Keycloak realm export; restore validates fully before the destructive point (RISK-007); rehearsals are graded against the archive's own manifest, and an aborted rehearsal is never a PASS. The reference installation's own two host-side gaps (off-server target, dead-man ping) are stated in §9.1 in present tense rather than papered over.
9. Supply chain¶
- The production frontend dependency set is gated at any severity:
npm audit --omit=devblocks the pipeline, with a dated-expiry exceptions file as the only bypass (ADR-0103). This gate has teeth in practice: the 2026-09-03 @tiptap prototype-pollution advisory blocked the release pipeline until the bump shipped (v0.9.43). - Release gating over the SBOM: the tag-only job runs grype over the CycloneDX SBOMs; any
finding ≥ High blocks unless a human decision exists in
vuln-triage.md — a
PENDINGrow still blocks, never silently. Dependabot is the closure authority for the continuous posture. The SBOM is attached to every GitHub release. - Declared limits: base images are pinned by tag, not digest (releases are not bit-reproducible from source — on the post-1.0 list with the 62304 §8 argument), and the image's own layer-level SBOM is the registered follow-up.
10. Residuals — the complete list, each with its home¶
Nothing below is hidden behind the walls above. State column: shipped = the control exists and is verified in a released version; in flight = in the v0.9.45 hardening waves at this writing; registered = decided/tracked, not yet built; accepted = a recorded acceptance with its argument.
| Residual | Class | Home | State |
|---|---|---|---|
| F-1 — the audit-append DB backstop void as a tenant boundary (convention-only wall) | MAJOR (defense-in-depth; no live path) | register row 57©: V128 predicate + AuditRecordCensusTest |
in flight (lane A) |
| F-2 — the source-event fan-out count is a standing cross-tenant time-series readable at GLOBAL | MINOR (designed disclosure, ADR-0035) | adversarial review F-2; revisit if a non-operator librarian persona ever exists | accepted |
| F-3 — four call-site conventions (gate-then-record, pass-own-id, oracle-behind-gate, bind-publish) have no census | MINOR | adversarial review F-3; row 57's census guard covers the first | partly in flight |
F-4 — lqms_scope_contents answers existence before authority (both callers gate first) |
NOTE | adversarial review F-4, S remedy named | registered |
| F-5 — the anchor-append republish window is ordering, not mechanism | NOTE | adversarial review F-5, S remedy named | registered |
| Four NEEDS-RUNTIME-TEST separation probes (save-point RLS, pin degrade, append backstop pin, fan-out entry gates) | test debt | adversarial review, specified for a lane | registered |
| TH-1 backup download unaudited · TH-2 anonymous task resolution/disposal deferral · TH-3 JIT mint · TH-8/9 payload-less governance events · TH-11/12 ordering | 2 MAJOR-unaudited, 2 MAJOR-payload, rest MINOR/NOTE | review-trail-honesty.md; candidate hazard row drafted for the register's audit-trail-integrity category | in flight (lane A) |
| REQ-AUD-005 marked verified in the matrix while three of its named examples are unrealized | evidence over-claim | trail-honesty TH-8 rider — downgrade until the payload fixes land | in flight |
| X-1..X-4 — export-vs-purge stream abort, disposal/dissolve lock inversion, approve/restore retry double-fires | 3 MINOR, 1 NOTE | review-concurrency.md | in flight (lane B) |
| RISK-001..009 — residual ratings all ⚠/acceptable, none ✗; formal acceptance of every row is OPEN pending the D-3 recording mechanism (acceptance document type + Quality Responsible role, neither built) | risk process | risk_management.md; register rows 61 (D-3 realization) and 60 | registered (1.0 build lane) |
| RISK-002 / DEBT-001 — single BYPASSRLS datasource; two-role packaging staged | accepted residual, compensated (Konsist rule + PrivilegedDataAccessInventoryTest, mutation-proven; residual re-rated P2→P1) |
D-4 ruling 2026-08-21; arc42 §11 DEBT-001; known-limits | accepted |
| RISK-005 — the quarantine model's uncovered third ordering | named model limitation | ADR-0092 honesty note; post-1.0 list (shared-(scope,hash)-lock amendment) | accepted, registered |
| RISK-009 — wrong-issuer and expiry rejection configured but untested | test residue | WrongAudienceTokenTest KDoc (reason: no second-realm path in the harness) |
accepted |
| Trail readback: no evidence artifact carries trail extracts (REQ-AUD-006 accepted-gap; TH-14) | MINOR | matrix accepted-gap; post-1.0 trail-extract lane | accepted, registered |
| Image signing/attestation absent; tag-pinned base images; personal registry token | supply chain | server-deployment §14; post-1.0 list (digest pinning) | accepted |
| Reference installation: off-server backup target + dead-man ping not yet applied | operational | server-deployment §9.1 (host-side, named owner); v1.0-criteria C-5 | registered (his hands) |
Sources¶
The five 2026-09-04 cross-cutting reviews (separation-adversarial · concurrency · trail-honesty · adr-coherence · v1-criteria-truth), the C-1a findings registers, the risk register, the open-decisions register, ADR-0002/0009/0011/0012/0018/0026/0027/ 0028/0033/0092/0125/0128/0129/0130/0131/0132, the traceability matrix, and server-deployment.md / deployment.md. Line-level citations in §3 are the adversarial review's own, quoted as it recorded them.