1 · What this system has to do
A correlation platform has one job: take findings produced by tools that know nothing about your business, and make them decidable. Everything in this paper follows from that sentence, and most of the difficulty is in the word decidable.
Concretely, the system must answer five questions about any finding, on demand, without a human intervening:
- Is this the same problem as that one? Deduplication across tools that describe the same weakness differently.
- What is it attached to? Identity resolution, from four vendors' incompatible notions of an asset.
- Who owns it? A traversal from asset to application to service to a named person.
- How bad is it here? Scoring from exposure, exploitation activity, criticality and control state.
- Can I prove any of the above? Provenance, audit and evidence, because a security platform is itself audited.
Two constraints shape every decision below. The first is that the data is multi-tenant and highly sensitive: a correlation platform holds a complete map of its customers' weaknesses, which makes it a more attractive target than most of the systems it assesses. The second is that the inputs are untrustworthy — connectors go stale, tools disagree, identifiers get reused — so the architecture has to distinguish "unknown" from "false" everywhere, and never quietly guess.
2 · The entity model
Eight entities. The interesting design content is not the list but the separations: which things are distinct rows rather than columns, and why.
| Entity | Lifetime | Why it is separate |
|---|---|---|
| Finding | Until fixed or accepted | Survives the asset being rebuilt; carries every source that reported it |
| Asset | Days to months | Technical and disposable — containers and instances are replaced constantly |
| Application | Years | The stable anchor. Findings map here because assets churn and applications do not |
| Business service | Years | Carries criticality and data classification; the vocabulary the board uses |
| Owner | Months to years | A person with authority, versioned — you need to know who owned it then |
| Control | Continuous | Has live state and a verification date; a control is a claim until checked |
| Threat model | Per design change | Predicted attack paths, so a finding can be matched to prior reasoning |
| Framework control | Per framework version | One fact evidencing many frameworks, rather than four parallel registers |
2.1 Findings are events; the truth is a state
A common early mistake is to model a finding as a row that a scanner updates. It is more robust to model observations as append-only events, and the finding as the current state derived from them. Three properties follow:
- A tool going silent is distinguishable from a tool reporting "fixed". The first is a connector problem, the second is remediation, and conflating them makes every metric wrong.
- Reopening is natural. A finding that recurs is the same finding with a new observation, not a new row that resets its age.
- Provenance survives deduplication. "Four tools reported this" remains visible after collapse, which matters both for confidence and for explaining the queue.
2.2 The column that cost us a week
Our own schema grew organically and ended with tenant_id as
uuid on 31 tables and text on 16. Nothing enforced that
the text ones held a UUID. Every piece of code that touched both types either
cast defensively or raised at runtime — and the erasure routine, written later,
silently addressed only a third of the estate until the mismatch was found.
Choose one representation for tenant identity on day one and constrain it.
The related decision is the foreign key. Ours were missing: exactly one of 47 tenant-scoped tables referenced the tenant table, so deleting a tenant row orphaned everything else — and because row-level security then matched none of those rows, they became invisible as well as orphaned. Unreadable, unfindable, undeletable customer data is the worst of the three possible outcomes.
When we added the constraints, we used ON DELETE RESTRICT rather than
CASCADE, which is worth explaining because the instinct runs the other
way. Cascade makes tenant deletion a one-line operation — and that is precisely the
problem: it lets a bare DELETE erase a customer's estate with no
certificate and no audit entry. Restrict forces every deletion through the erasure
routine described in section 6.
3 · Identity resolution and the asset graph
Four tools describe the same machine as an IP address, a hostname, a cloud resource identifier and an agent GUID. Resolving these into one asset is the least glamorous and most consequential part of the system: every downstream join inherits its errors.
3.1 Ordered identifiers, and the refusal to guess
Identifiers are matched in a strict preference order, most stable first:
| Rank | Identifier | Stability | Failure mode |
|---|---|---|---|
| 1 | Cloud resource ID / ARN | Very high | Absent outside cloud |
| 2 | Agent or instance GUID | High | Reissued on rebuild |
| 3 | Repository URL + path | High for code | Monorepos need path precision |
| 4 | Fully qualified hostname | Moderate | Duplicated across environments |
| 5 | MAC address | Moderate | Virtualised and cloned |
| 6 | IP address | Low | Reassigned within hours in DHCP or cloud |
The rule that matters more than the ordering: when confidence is low, create a candidate rather than a merge. An incorrect merge is close to unrecoverable — two customers' worth of findings under one asset, ownership wrong, history entangled — while an unmatched record is visible, annoying and trivially fixed. Systems that optimise for a tidy asset count produce quiet corruption.
3.2 Why a graph, and where a graph is the wrong answer
The relationships here are genuinely graph-shaped: an asset supports an application, which composes a service, which depends on another service, which is owned by a business unit. Attack paths are literally paths.
That does not mean the primary store should be a graph database. Ours is PostgreSQL, with graph traversals expressed as recursive CTEs, for three reasons worth stating plainly:
- The traversals are shallow. Asset to owner is three hops. Attack paths of interest are rarely more than five. Recursive SQL handles that comfortably; the graph advantage appears at depths this domain does not reach.
- Row-level security. Postgres enforces tenant isolation in the engine, per row, on every query. Reproducing that guarantee in a graph store is an application-layer exercise, which is exactly the weaker position section 5 argues against.
- One store, one transaction. Findings, relationships, audit and evidence commit together. Splitting the graph into a second store means reconciling two systems that will diverge, and the reconciliation logic will be less reliable than the joins it replaced.
A graph store earns its place when path queries dominate the workload — large-scale attack path simulation across hundreds of thousands of nodes. That is a good reason to add one as a derived index. It is a poor reason to make it the system of record.
4 · Deduplication
Typical collapse on first ingestion is 25–60%. Getting the key right is most of it.
The deduplication key
Title text is excluded deliberately. Two vendors describing the same CVE produce different prose, and normalising prose is a losing game against a problem that has an identifier sitting right there.
Three cases that break naive deduplication
The shared library. One vulnerable dependency imported by forty services. Collapsing to one finding hides thirty-nine remediation obligations; keeping forty inflates every count and punishes the team that owns the library. The workable model is one finding per consuming application, linked to a single upstream advisory — so the fix is tracked once and the exposure is counted accurately.
The same CVE at two severities. Vendors disagree. Recording the disagreement is more useful than resolving it: two sources differing on severity is frequently a sign that one connector is stale, and averaging destroys that signal.
Reopening. A finding closed in March and observed again in June is the same finding, not a new one — otherwise mean-time-to-remediate improves every time something recurs, which is precisely backwards.
5 · Multi-tenancy, properly
This is the section to read if you are evaluating a platform rather than building one, because it is where the difference between a claim and a control is largest — and because every failure below is one we made, found and fixed rather than one we imagined.
5.1 Isolation belongs in the database
Most multi-tenant systems isolate by convention: every query carries
WHERE tenant_id = ?. That works exactly as long as every query
remembers, across every developer, forever. In our codebase there were 271 places
that acquired a database connection. Asking all 271 to remember is not a control;
it is 271 opportunities.
PostgreSQL row-level security moves the check into the engine. A policy on each table compares the row's tenant against a session setting, and a query that forgets its filter returns nothing rather than everything.
Two details that decide whether RLS is real or decorative.
1. FORCE ROW LEVEL SECURITY, not just ENABLE.
A table's owner bypasses its own policies unless forced — and migrations usually
run as the owner.
2. The application must not connect as a superuser. Superusers
bypass RLS unconditionally, whatever the policy says. Every policy would still be
listed in pg_policies, and none would be enforced. This is the shape
of finding that passes an audit checklist and protects nothing, so it deserves to
be tested rather than assumed.
The consequence is two database roles: an owner used only for migrations, and an
unprivileged runtime role — LOGIN NOSUPERUSER NOBYPASSRLS — that the
application uses and that cannot escape its policies.
5.2 Connection pools make this harder than it looks
RLS needs the connection to know which tenant it is acting for. Pools reuse connections between unrelated requests, so a tenant set on a connection and left there leaks to whoever gets that connection next — a subtler version of the bug RLS was introduced to prevent.
The pattern that works: hold the current tenant in a request-scoped context variable, and stamp it onto the connection in the pool's acquire hook, so every call site inherits it without changing. In our case that meant none of the 271 call sites needed editing.
The test that proves it
Set the pool to a single connection, run a dozen concurrent tasks alternating between two tenants and one with no tenant at all, and assert each sees only its own rows. With one connection every task is forced through the same physical session, which is the condition under which a leak actually occurs. A test with a comfortable pool size passes whether or not the mechanism works.
5.3 The failure mode nobody warns you about
Shared reference data disappears.
We shipped a library of PII detection patterns as rows with a null tenant, meaning
"belongs to everyone". The tenant policy compared tenant_id to the
session setting, and null equals nothing — so every tenant saw
zero patterns. The loader asked for "null or my tenant", but RLS filters rows before
the query's own WHERE is consulted, and the hardcoded fallback only
triggered on a failed query, not an empty result.
The detection engine ran with no patterns and reported no findings. Nothing errored. A scanner that reports a clean estate because it is looking for nothing is the worst failure mode this class of system has, and it was introduced by a security control working exactly as written.
The fix is a separate policy for shared catalogues: null-tenant rows readable by everyone, while the write check still requires a tenant match, so a customer can add their own patterns and cannot alter or shadow a shared one. The general lesson: when adding RLS to an existing schema, the rows belonging to no tenant are the first thing to check, because they fail silently and in the safe-looking direction.
5.4 What to ask a vendor
- Is isolation enforced in the database or in application code? If the answer is "we always filter by tenant", it is convention.
- Does the application's database role have
BYPASSRLSor superuser? Ask them to showpg_rolesfor the runtime user. - Is
FORCEset, or onlyENABLE? - How is tenant context propagated through the connection pool, and what test proves it holds under connection reuse?
- What happens to rows with no tenant?
6 · Audit, erasure and evidence
6.1 An audit log the application cannot edit
A log the application can rewrite is not evidence. Append-only is enforced with
triggers that reject UPDATE and DELETE, plus a statement
trigger for TRUNCATE — which bypasses row-level triggers entirely and is
therefore the one people forget.
Two design points. Machine credentials are recorded by key identity, not just "an API key", so a compromised credential can be traced to what it did. And the audit write must not be subject to tenant isolation: a denied cross-tenant request carries the target tenant while the session holds the authenticated one, so the policy's write check refuses the insert — and the events silently lost that way are precisely the ones worth keeping.
6.2 Erasure that produces evidence
A customer leaves, or exercises a right to erasure. Two properties make this defensible:
- Scope comes from the catalogue, never a hardcoded list. Any table with a tenant column is in scope automatically. A list is correct the day it is written and silently wrong after the next migration — and silently wrong here means telling a customer their data is gone while a forgotten table still holds it.
- The output is a certificate. Per-table row counts, requester, reason, timestamp, in an append-only ledger with no foreign key to the tenant — a record that cascades away with its subject certifies nothing.
Deletion order is derived by repetition rather than a topological sort: each pass deletes what it can and defers foreign key violations. It converges, tolerates cycles, and needs no ordering maintained by hand as tables are added.
Be honest about the exemptions. The audit log and the erasure certificate survive, because both record the erasure itself. That is a real tension, and stating it plainly in a data processing agreement is better than discovering it during a regulator's question.
7 · API and authorisation design
7.1 Authorise in one place, not per route
Per-route decorators fix today's endpoints and quietly fail on the one someone adds next month. A single table mapping path prefixes to scope families, enforced in the dependency every route already uses, covers a new route the moment it is mounted.
The default for an unmapped prefix should be deny. A route nobody classified is a route nobody thought about; the cost is a loud 403 fixed by one line, which is the failure direction you want.
Our own scope-checking function existed from the first release and was applied to exactly zero routes. Any valid credential reached all 161 endpoints, so a read-only reporting key could approve a remediation. The check was written; it was never wired. Grep for your own authorisation helper and count the call sites.
7.2 One vocabulary
We ran two — platform role names and scope strings — in the same claims field. Nine route modules compared that field against role names nothing ever put in it, so those routes worked for a super admin and returned 403 to everyone else, silently, because the super-admin check short-circuited before the comparison. Two authorisation vocabularies in one system will eventually disagree, and the disagreement will be invisible.
7.3 Rate limiting, and where to enforce it
Three tiers: unauthenticated callers keyed by IP, authenticated reads keyed by tenant, and expensive operations — anything that starts a scan — keyed by tenant at a much lower limit. Charging authenticated quota to the tenant rather than the credential matters, or a tenant multiplies its own limit by minting keys.
Enforcement splits in an unobvious way. The anonymous tier must run before authentication and can only key on IP; the tenant tiers must run after the credential is verified. Presence of a credential header is not evidence of a valid credential — treating it as such charges a key-guesser the generous authenticated rate, which is exactly backwards.
The subtler trap is the fix for that: refusing credentialed requests up front once an IP is over budget means one key-guesser behind a corporate NAT locks out every legitimate user sharing that address. Count only failures, and always serve a valid credential.
Failure posture should be open, with loud instrumentation. This is the opposite of the choice for tenant isolation, and deliberately so: failing closed there hides data, while failing closed here turns a cache outage into a total outage of a security tool at the moment operators most need it.
8 · Where AI helps, and where it lies
Three places where a language model earns its cost in this architecture, and three where it should be kept away from the decision.
| Task | Verdict | Why |
|---|---|---|
| Explaining a finding in plain English | Good fit | Output is read by a human who can judge it; errors are visible and cheap |
| Mapping a tool's rule to a CWE | Good fit | Suggestion reviewed once, then stored as a deterministic mapping |
| Drafting remediation guidance | Good fit | Draft for an engineer who will read the code anyway |
| Deciding whether a finding is real | Keep away | Confident and wrong is worse than absent; needs evidence, not plausibility |
| Computing the risk score | Keep away | The score must be explainable and stable quarter over quarter |
| Asset identity resolution | Keep away | A wrong merge is near-unrecoverable; use deterministic rules and surface doubt |
The pattern behind the split: AI is appropriate where a human validates the output, and inappropriate where the output silently becomes an input to another decision. A score produced by a model that cannot explain itself is not challengeable by the engineer who has to act on it, and a ranking nobody can challenge is a ranking nobody follows.
Two engineering notes
Prompt injection is a data-handling problem here. Findings contain attacker-controlled text — a hostname, a header, a code comment. Any model that reads finding content is reading untrusted input, so it needs the same discipline as any other untrusted parser: delimit it, never let it become instructions, and reject responses that do not conform to the expected shape.
Prefer evidence to inference. When we built behavioural probing for AI systems, substring heuristics produced confident false positives. Replacing them with a canary token — a random value that only appears in output if the model genuinely leaked it — turned inference into proof. Where a deterministic proof is available, it beats a cleverer classifier every time.
9 · Operating it
9.1 Health checks that can fail
Liveness and readiness answer different questions and must be separate. Liveness asks whether the process is wedged and must not touch the database — a liveness probe that does will restart every replica when the database blips, turning a recoverable outage into an outage plus a herd of cold starts. Readiness asks whether this replica should receive traffic and does check dependencies.
A third state is worth modelling: degraded. If the worker has completed nothing for fifteen minutes while jobs are queued, scans are not running — a real outage of the product's purpose — but reads still work, so the replica should stay in rotation and say so. Both conditions are required; silence alone is a quiet deployment, not a fault.
9.2 Measure the failures this system actually has
Generic request metrics are necessary and insufficient. The specific one worth instrumenting is queued work that never starts, because from the API's perspective a dead worker looks like nothing at all: no errors, no latency, healthy checks, an empty results page.
One warning from experience: label metrics by route template, never raw path. Raw paths put an identifier in every series name, which is the standard way to take down a metrics backend — and the traffic most likely to do it is the hostile kind, probing generated URLs.
9.3 Backups you have actually restored
An untested backup is a hypothesis. The rehearsal should restore into a scratch database and verify against a manifest captured at backup time — row counts per table, schema version, and the state of the isolation policies. Row counts are the check that catches a restore which completes cleanly into an empty database; policy state is the check that catches a restore producing a working application with no tenant isolation.
Two details specific to this kind of system. Take the dump as the owner: a backup taken as the RLS-constrained runtime role succeeds, weighs almost nothing, and restores an empty estate. And encrypt with a public key so the backup host can write backups it cannot read — then verify with the private key, which proves the recovery key works, not merely that the ciphertext is intact.
9.4 Refuse to start unsafely
Every control described in this paper can be disabled by an environment variable, and one of them disables all of them. A startup preflight that exits rather than run with authentication disabled, a published default credential, or a database role that can bypass isolation is a small amount of code that prevents the entire architecture from being silently switched off.
Make it fatal in production and advisory elsewhere. A check that makes local development painful gets commented out, at which point it protects nothing in production either.
10 · Build or buy
This paper is a specification. A competent platform team can implement it, and some should — the honest cases for building are a genuinely unusual estate, a regulatory position that forbids the alternatives, or an existing data platform that already solves half of it.
What tends to be underestimated is not the pipeline, which is a few months of focused work. It is everything in sections 5 to 9: multi-tenant isolation that survives a pool, an audit log that is admissible, erasure that produces evidence, authorisation that covers routes nobody has written yet, and the discipline to keep connectors current as fourteen vendors change their APIs. That work never finishes, and it is invisible until the day it is needed.
The honest test: if your organisation would not staff two engineers on this permanently, buy it. If it would, the specification above is yours to use, and we would rather you built something good than bought something you resent.
What we built
Klair Vu implements this architecture. Every mechanism described above is in it, including the mistakes — the ones in sections 2.2, 5.3 and 7.2 are ours, found by testing rather than by review, and each is now covered by a regression test that fails if it returns.
We would rather be challenged on this paper than demonstrate dashboards. If you disagree with the identity ordering, the deduplication key or the decision to keep the graph in PostgreSQL, that is the conversation we want.