03 — Data Model¶
| Status | IMPLEMENTED. schemas/database/0001_core.sql is the baseline, applied and upgraded through Alembic migrations (backend/migrations/); its security properties are tested in CI by tests/database/run.sh (section 7). |
| Related | ADR-008, ADR-016, ADR-017, ADR-022 |
1. Modelling principles¶
- Ledgers vs projections. Facts that must be auditable are append-only ledgers: evidence, evidence validity events, control evaluations, finding events, risk assessments, exception events, classifications, audit events. "Current state" tables (
control_states,evidence_current_state,requirement_states, status columns onfindings) are projections that can be rebuilt from the ledgers. - Configuration is data with a version. The control catalogue, risk model, classification model, evidence types and licence policy are loaded into
config_versionswith a git SHA and content hash. Every derived record references the config version it used. - Official framework content is stored verbatim in
framework_items.official_text, separate from organisation-specificcontrols. - Source-control identity uses the platform's stable ids (
repositories(provider, external_id), e.g. GitHub's numeric id or GitLab's project id), not names, which change on rename or transfer. People have any number of identities (user_identities: one per identity provider and per source-control platform). - No secrets, ever. Secret findings store type, location and validity. The value is redacted before storage.
2. Entity-relationship diagram (core)¶
erDiagram
ORG_UNITS ||--o{ ORG_UNITS : parent
ORG_UNITS ||--o{ APPLICATIONS : owns
USERS ||--o{ ORG_UNIT_MEMBERS : member
ORG_UNITS ||--o{ ORG_UNIT_MEMBERS : has
APPLICATIONS ||--o{ APPLICATION_REPOSITORIES : implemented_by
REPOSITORIES ||--o{ APPLICATION_REPOSITORIES : implements
APPLICATIONS ||--o{ ENVIRONMENTS : runs_in
APPLICATIONS ||--o{ CLOUD_SCOPES : hosted_in
APPLICATIONS ||--o{ APPLICATION_ASSETS : has
APPLICATIONS ||--o{ APPLICATION_CLASSIFICATIONS : classified_by
REPOSITORIES ||--o{ PIPELINES : defines
PIPELINES ||--o{ PIPELINE_RUNS : executes
PIPELINE_RUNS ||--o{ ARTIFACTS : builds
ARTIFACTS ||--o{ DEPLOYMENTS : deployed_as
ENVIRONMENTS ||--o{ DEPLOYMENTS : receives
CONFIG_VERSIONS ||--o{ CONTROLS : catalogue_version
CONTROLS ||--o{ CONTROL_FRAMEWORK_MAPPINGS : maps
FRAMEWORK_ITEMS ||--o{ CONTROL_FRAMEWORK_MAPPINGS : mapped_by
FRAMEWORKS ||--o{ FRAMEWORK_ITEMS : contains
APPLICATIONS ||--o{ EVIDENCE : about
EVIDENCE ||--o{ EVIDENCE_VALIDITY_EVENTS : freshness
APPLICATIONS ||--o{ CONTROL_EVALUATIONS : evaluated
CONTROL_EVALUATIONS }o--o{ EVIDENCE : uses
APPLICATIONS ||--o{ CONTROL_STATES : current
APPLICATIONS ||--o{ SBOMS : has
SBOMS ||--o{ SBOM_COMPONENTS : lists
COMPONENTS ||--o{ SBOM_COMPONENTS : in
COMPONENTS ||--o{ COMPONENT_VULNERABILITIES : affected_by
VULNERABILITIES ||--o{ COMPONENT_VULNERABILITIES : affects
APPLICATIONS ||--o{ FINDINGS : has
FINDINGS }o--o| VULNERABILITIES : instance_of
FINDINGS }o--o| COMPONENTS : in
FINDINGS ||--o{ FINDING_EVENTS : history
FINDINGS ||--o{ FINDING_RISK_ASSESSMENTS : prioritised_by
FINDINGS }o--o{ TICKETS : tracked_in
FINDINGS }o--o| EXCEPTIONS : covered_by
APPLICATIONS ||--o{ EXCEPTIONS : has
APPLICATIONS ||--o{ THREAT_MODELS : modelled_by
THREAT_MODELS ||--o{ THREATS : lists
THREATS ||--o{ MITIGATIONS : mitigated_by
APPLICATIONS ||--o{ ASSESSMENTS : assessed_by
ASSESSMENTS ||--o{ ASSESSMENT_ANSWERS : answers
APPLICATIONS ||--o{ SECURITY_REQUIREMENTS : requires
SECURITY_REQUIREMENTS ||--|| REQUIREMENT_STATES : status
MATURITY_SNAPSHOTS ||--o{ MATURITY_SCORES : contains
3. Table groups¶
| Group | Tables | Type |
|---|---|---|
| Organisation & access | org_units (levels configured in config/organization.yaml, e.g. organisation → business unit → team; exactly one root), users, user_identities, org_unit_members, role_assignments |
Mutable, synced from Entra/GitHub |
| Registry | applications, application_revisions, application_classifications, environments, cloud_scopes, application_assets, application_dependencies, third_party_services, application_repositories |
Mutable + revision ledger |
| Source & delivery | repositories, pipelines, pipeline_runs, artifacts, deployments |
Mutable (synced) |
| Config & frameworks | config_versions, frameworks, framework_items, controls, control_framework_mappings, control_applicability, trusted_producers |
Versioned, loaded from git |
| Evidence | evidence ⧉, evidence_validity_events, evidence_current_state, evidence_rejections, evidence_checkpoints |
Ledger + projection |
| Control evaluation | control_evaluations ⧉, control_states |
Ledger + projection |
| Assessment & maturity | assessments, assessment_answers, architecture_reviews, security_tests, maturity_snapshots, maturity_scores, maturity_targets, security_requirements, requirement_states |
Mixed |
| Threat modelling | threat_models, threats, mitigations |
Parsed per commit (immutable per commit SHA) |
| Supply chain | vulnerabilities, components, component_vulnerabilities, sboms, sbom_components, dependencies |
Synced/derived |
| Findings & risk | findings, finding_events ⧉, finding_risk_assessments, finding_tickets, tickets, exceptions, exception_events, risks |
Projection + ledgers |
| People programmes | security_champions, champion_activities, training_records |
Mutable |
| Platform | audit_events ⧉, outbox, jobs, webhook_deliveries |
Ledger / operational |
⧉ = monthly RANGE-partitioned.
4. Deduplication identities (fingerprints)¶
findings.fingerprint is the platform's dedup key. It is deterministic, so every pipeline run maps to the same finding instead of creating a new one.
| Finding type | Fingerprint input (SHA-256 of the canonical string) | Notes |
|---|---|---|
| dependency | dep|<scope_ref>|<purl without version>|<vuln_key canonical (CVE > GHSA > OSV)> |
An upgrade to a still-vulnerable version stays the same finding. Aliases are resolved to the canonical ID first. |
| code (SAST) | code|<repo external_id>|<tool>|<rule_id>|<GHAS alert number or DefectDojo hash_code> |
Reuses the source system's stable identity, which tracks code movement better than file+line |
| secret | secret|<repo external_id>|<secret_type>|<GHAS alert number> |
|
| iac | iac|<repo>|<policy_id>|<resource address> |
|
| container | ctr|<artifact name>|<package purl without version>|<vuln_key> |
Per image name, not digest, so a rebuild with the same vulnerability is the same finding |
| cloud | cloud|<provider>|<resource id>|<rule/control id> |
|
| control_gap | gap|<application_id>|<control_key>|<scope_ref> |
|
| dast/api | dyn|<environment_id>|<tool rule id>|<normalised URL path template>|<parameter> |
|
| pentest/architecture | man|<assessment_id>|<item ref> |
Tickets dedup separately (tickets.dedup_key). See ADR-018: dependency findings are grouped per (application, repository, component), so one upgrade ticket covers every CVE that upgrade fixes.
5. Key indexes and the queries they serve¶
| Query | Index |
|---|---|
| "Which apps are affected by CVE-X?" | vulnerabilities(vuln_key) / aliases GIN → component_vulnerabilities_vuln_idx → sbom_components_component_idx → sboms_one_current_per_scope (partial, is_current) |
| App scorecard: current control states | control_states PK prefix (application_id, …) |
| Org gap view: "all apps failing ASC-BLD-01" | control_states_gap_idx (control_key, state) WHERE mandatory |
| Open findings by app and priority | findings_app_open_idx (partial on open statuses) |
| SLA breach sweep | findings_sla_idx (partial) |
| Freshness sweep | ecs_expiry_idx (valid_until) WHERE active/expiring |
| Evidence lookup for evaluation | evidence_app_type_idx (application_id, evidence_type, observed_at DESC) |
| Revocation sweep by tool version | evidence_tool_idx |
| Search apps/repos/components/findings | pg_trgm GIN on names/titles |
| Queue claim | jobs_ready_idx (queue, run_at) WHERE queued with FOR UPDATE SKIP LOCKED |
6. Retention strategy¶
Defaults: each adopting organization confirms the periods with its records, privacy and audit owners (ADR-017).
| Data | Online (PostgreSQL) | Archive | Mechanism |
|---|---|---|---|
| Evidence records | 24 months | to 7 years in archive (Parquet export to immutable blob) | Detach monthly partition → export → verify hash chain → drop |
| Raw evidence artefacts (scans, SBOMs) | n/a | 3 years WORM | Blob immutability policy; lifecycle to cool tier at 90 days, archive tier at 1 year |
| Manual assessment artefacts (pen test, arch review) | n/a | 7 years WORM | Separate container with longer policy |
| Control evaluations | 24 months | 7 years | As evidence |
| Audit events | 24 months | 7 years | As evidence. Also in Sentinel per SOC retention. |
| Findings (closed / FP) | 3 years after closure | then anonymised aggregate only | Scheduled job |
| Finding events | 3 years | Partition drop | |
| Maturity snapshots | Indefinite (small) | ||
| Jobs / outbox | 30 days after completion | Scheduled delete | |
| Webhook delivery IDs | 7 days | Scheduled delete | |
| Users who have left | Pseudonymise display name/email 90 days after deactivation, keep the ID for referential integrity | Scheduled job |
Monthly partitioning means retention never needs DELETE on a ledger. It is a DETACH PARTITION by the migrator role after archive verification. That keeps the "no UPDATE/DELETE on ledgers" property true.
7. Verified schema properties¶
tests/database/run.sh applies the DDL to a throwaway postgres:16-alpine (tmpfs) and asserts:
| Test | Property |
|---|---|
| T1a–c | Evidence ledger blocks UPDATE, DELETE and TRUNCATE, even for the table owner |
| T2 | An exception requester cannot be its approver (SoD) |
| T3 | An approved exception must have an expiry |
| T4 | A finding cannot be closed without closure (re-scan) evidence |
| T5 | RLS: a team-scoped API session sees only its own applications and findings |
| T6 | RLS fails closed: an unscoped session sees nothing |
| T7 | RLS blocks cross-team writes |
| T8 | Partitions cannot be read directly. This closes the RLS bypass, because partitions do not inherit parent policies. |
| T9 | The API role cannot insert evidence. Only workers ingest. |
| T10 | Workers have no UPDATE grant on ledgers. Grants and trigger are both in place. |
Writing these tests surfaced two defects, both now fixed: an array/subquery ambiguity in the RLS helper, and partition-level grants that would have bypassed RLS.
8. Runtime tables added by migrations¶
schemas/database/0001_core.sql is the verified baseline. Later migrations (backend/migrations/versions/) add:
| Migration | Tables / changes | Notes |
|---|---|---|
| 0002_runtime | aap_hook role, repositories.head_sha / detected |
Receiver can only enqueue |
| 0003_api_grants | Column-level grants for triage and the receiver's ON CONFLICT |
|
| 0005_process_logins | Role aap_auth (sign-in only) and one NOINHERIT login per process |
Risk R-25 closed: the API login cannot switch to aap_worker; aap db provision-logins sets passwords and stores one URL per process |
| 0004_integrations | web_auth_requests, web_sessions, connector_runs |
Sessions and login state hold hashes of the opaque ids, never tokens. Worker-only access (the API resolves sessions through the worker role); aap_api can read connector_runs only. None are ledgers. |
9. Open design points¶
| # | Point | Proposed resolution |
|---|---|---|
| DM-1 | Hash-chain inserts serialise per chain | Volume (~0.6 evidence/s design peak) makes a per-chain advisory lock acceptable. Revisit above ~50/s (shard chains by evidence type). |
| DM-2 | evidence.application_id has no FK (partitioned ledger) |
Ingestion validates. A nightly orphan check job runs. |
| DM-3 | App moves between org units | RLS resolves visibility via the current applications.org_unit_id, so history follows the app. That is the intended behaviour. |
| DM-4 | Component table growth (2M+ purl@version) | Acceptable for PostgreSQL. Partition sbom_components by hash of sbom_id if it exceeds ~500M rows. |