RoPA Records Implementation¶
The RoPA Records feature implements GDPR Article 30 record-keeping for deployed process bundles. It adds two PostgreSQL tables, a transactional service layer, authenticated asset routes, a CORS-open public endpoint, a full LDE editor UI, and a standalone static public site.
Component structure¶
packages/backend/src/
โโโ db/
โ โโโ migrate.ts # DDL โ ropa_records + ropa_personal_data_fields tables
โ โโโ seed-ropa.ts # One-time idempotent seed for example records
โโโ services/
โ โโโ ropa.service.ts # CRUD + transactional upsert + public listing
โโโ routes/
โ โโโ ropa.routes.ts # Authenticated asset routes (/v1/assets/ropa)
โ โโโ ropa.public.routes.ts # Public CORS-open route (/v1/ropa/public)
โโโ utils/
โ โโโ publicPaths.ts # Which paths get wildcard CORS โ isPublicPath()
โโโ types/
โโโ ropa.types.ts # RopaRecord, RopaPersonalDataField, PublicRopaRecord
packages/frontend/src/
โโโ types/
โ โโโ ropa.types.ts # Frontend mirror of backend types
โโโ services/
โ โโโ ropaService.ts # fetch-based API client
โโโ components/
โโโ RopaEditor/
โโโ RopaEditor.tsx # Root โ list state, load/save/delete orchestration
โโโ RopaList.tsx # Left panel โ record list with status badges
โโโ RopaRecordEditor.tsx # Right panel โ four-tab editor
packages/frontend/src/components/BpmnModeler/
โโโ RopaSelector.tsx # Footer panel in ProcessList
โโโ ronlModdleDescriptor.json # Extended with ropaRef on bpmn:Process
packages/ropa-site/
โโโ index.html # Complete zero-dependency static public site
โโโ staticwebapp.config.json # Azure Static Web Apps config
โโโ README.md
Database schema¶
Two tables are appended to the existing migrate.ts query block. Migrations run automatically at backend startup via migrate() called from index.ts.
CREATE TABLE IF NOT EXISTS ropa_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bpmn_process_id VARCHAR(255) NOT NULL,
process_level VARCHAR(20) NOT NULL
CHECK (process_level IN ('shell', 'subprocess')),
title VARCHAR(500) NOT NULL,
controller_name TEXT NOT NULL,
controller_contact TEXT NOT NULL,
dpo_contact TEXT,
purpose TEXT NOT NULL,
legal_basis_uri TEXT NOT NULL,
legal_basis_label TEXT NOT NULL,
gdpr_article VARCHAR(50) NOT NULL,
data_subjects TEXT NOT NULL,
recipients TEXT NOT NULL,
third_country_transfers BOOLEAN NOT NULL DEFAULT FALSE,
third_country_details TEXT,
retention_period TEXT NOT NULL,
security_measures TEXT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'active', 'archived')),
schema_version INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_ropa_bpmn_process_id_unique
ON ropa_records (bpmn_process_id);
CREATE INDEX IF NOT EXISTS idx_ropa_status
ON ropa_records (status);
CREATE TABLE IF NOT EXISTS ropa_personal_data_fields (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
ropa_record_id UUID NOT NULL
REFERENCES ropa_records(id) ON DELETE CASCADE,
form_id TEXT NOT NULL,
field_key VARCHAR(255) NOT NULL,
field_label TEXT NOT NULL,
data_category VARCHAR(100) NOT NULL,
special_category BOOLEAN NOT NULL DEFAULT FALSE,
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_rpdf_ropa_record_id
ON ropa_personal_data_fields (ropa_record_id);
The unique index on bpmn_process_id is what makes the seed idempotent โ ON CONFLICT (bpmn_process_id) DO UPDATE replaces rows rather than inserting duplicates.
ropa_personal_data_fields uses ON DELETE CASCADE so deleting a record removes all its field rows in a single operation.
Service layer¶
ropa.service.ts provides five functions following the same if (!pool) return ... guard pattern used throughout assets.service.ts:
| Function | Description |
|---|---|
listRopa() |
Returns all records with their fields, ordered by updated_at DESC |
getRopaById(id) |
Single record with fields by UUID |
getRopaByBpmnProcessId(bpmnProcessId) |
Used by the BPMN Link tab to check current linkage |
upsertRopa(record) |
Transactional: upserts the record header then replaces all field rows atomically |
deleteRopa(id) |
Deletes the record; CASCADE removes fields |
listPublicRopa(organisation?) |
Returns only status = 'active' records; strips controllerContact, dpoContact, and schemaVersion before returning |
Transactional upsert¶
upsertRopa uses a client connection with explicit BEGIN / COMMIT / ROLLBACK:
INSERT ... ON CONFLICT (bpmn_process_id) DO UPDATEโ upserts the record header, returns the UUIDDELETE FROM ropa_personal_data_fields WHERE ropa_record_id = $idโ clears existing fieldsINSERTloop โ writes all field rows withsort_orderpreservedCOMMITโ both operations land together or neither does
API routes¶
Authenticated routes โ /v1/assets/ropa¶
Registered in routes/index.ts alongside the other asset routes. All require the same database availability check as the other asset routes.
| Method | Path | Description |
|---|---|---|
GET |
/v1/assets/ropa |
List all records with fields |
POST |
/v1/assets/ropa |
Upsert a record (returns { id }) |
DELETE |
/v1/assets/ropa/:id |
Delete a record |
GET |
/v1/assets/ropa/by-bpmn-id/:bpmnProcessId |
Lookup by BPMN process ID |
Public route โ /v1/ropa/public¶
Registered separately in routes/index.ts as router.use('/v1/ropa/public', ropaPublicRoutes).
| Method | Path | Description |
|---|---|---|
GET |
/v1/ropa/public |
List active records โ ?organisation=flevoland filters by controller_name ILIKE '%flevoland%' |
The public route applies cors({ origin: '*', methods: ['GET', 'OPTIONS'] }) at the route level. However, the global CORS middleware in index.ts evaluates origins before route handlers are reached, so index.ts decides per path which policy applies:
app.use((req, res, next) => {
if (isPublicPath(req.path)) {
// Wildcard by design, for the public read-only mounts only -- see utils/publicPaths.ts.
// nosemgrep: javascript.express.web.cors-permissive-express.cors-permissive-express
cors({ origin: '*', methods: ['GET', 'OPTIONS'] })(req, res, next);
} else {
cors(corsOptions)(req, res, next);
}
});
The same decision is made in the preflight app.options('*', ...) handler.
There are two public mounts, not one, both listed in utils/publicPaths.ts: /v1/ropa/public for this register, and /v1/bundles/public, which the RONL Business API's caseworker dashboard reads. Both serve deliberately public, read-only data shaped for publication โ listPublicRopa returns active records only and strips controller and DPO contacts โ and the backend performs no inbound authentication, so these endpoints are already readable by anything that is not a browser. Wildcard CORS extends that to browser scripts on other origins, which is the point.
const PUBLIC_MOUNTS = ['/v1/ropa/public', '/v1/bundles/public'];
export const isPublicPath = (path: string) =>
PUBLIC_MOUNTS.some((mount) => path === mount || path.startsWith(`${mount}/`));
A mount or a path below it โ never a sibling that shares the prefix
Until v2026.09.3 this was req.path.startsWith('/v1/ropa/public'). That also
matches /v1/ropa/publications, so any future route whose name merely began
with public would have inherited wildcard CORS instead of the credentialed
allowlist โ without anyone deciding it should. The match is now exact-or-below,
and publicPaths.test.ts asserts the sibling case falls through.
The nosemgrep on each wildcard line names the one rule it suppresses and sits
beside its reason, so the decision is visible in review rather than held as
dashboard state.
BPMN moddleDescriptor¶
ronlModdleDescriptor.json is extended with a second type entry that adds ropaRef as an attribute on bpmn:Process:
{
"name": "RopaRefMixin",
"extends": ["bpmn:Process"],
"properties": [
{ "name": "ropaRef", "isAttr": true, "type": "String" }
]
}
This registers the attribute with the bpmn-js moddle system so it survives saveXML() serialisation. Without this registration the attribute is silently dropped on every save.
Serialised in BPMN XML as:
<bpmn:process id="TreeFellingPermitSubProcess"
ronl:ropaRef="b1c8f84a-bfac-43e3-9c0e-65bb1c1aadaf"
...>
RopaSelector โ ProcessList integration¶
RopaSelector.tsx is rendered as a fixed footer panel inside ProcessList.tsx, outside the scrollable list container. It is only shown when activeProcess is non-null.
ProcessList receives two new props:
The current ropaRef is extracted from the active process XML by a simple regex:
handleRopaRefChange in BpmnModeler.tsx performs three operations:
- Ensures
xmlns:ronl="http://ronl.nl/schema/1.0"is declared on the<definitions>element - Either sets, updates, or removes
ronl:ropaRefdepending on whether a value is passed - Saves the modified XML via
BpmnService.saveProcess
Deploy modal warning¶
BpmnCanvas.tsx sets a ropaRefMissing flag during bundle assembly in handleOpenDeployModal:
When true, an amber warning banner is rendered in the deploy modal between the resource list and the resource count line. The Deploy button remains enabled โ the warning is advisory, not blocking.
Seed script¶
packages/backend/src/db/seed-ropa.ts seeds four active records covering the two example bundles:
| Record | bpmnProcessId |
processLevel |
|---|---|---|
| AWB Shell โ Tree Felling Permit | AwbShellProcess |
shell |
| Tree Felling Permit โ material law assessment | TreeFellingPermitSubProcess |
subprocess |
| AWB Shell โ Zorgtoeslag | AwbZorgtoeslagProcess |
shell |
| Zorgtoeslag โ provisional entitlement assessment | ZorgtoeslagProvisionalSubProcess |
subprocess |
Run from packages/backend:
The script is idempotent โ re-running it updates existing rows in place via ON CONFLICT (bpmn_process_id) DO UPDATE.
Public site¶
packages/ropa-site/ is a zero-dependency static site with no build step. It fetches from GET /v1/ropa/public on load and renders collapsible cards.
Deployed as a separate Azure Static Web Apps resource โ independent of the LDE frontend SWA. The GitHub Actions workflow file generated by az staticwebapp create is committed to .github/workflows/ and scoped to changes in packages/ropa-site/**.
To find the deployed URL:
az staticwebapp show \
--name ropa-flevoland-acc \
--resource-group rg-ronl-acc \
--query "defaultHostname" \
--output tsv
Custom domain configuration is done in the Azure Portal under Static Web Apps โ ropa-flevoland-acc โ Custom domains.
Type safety โ DB row types and mappers¶
ropa.service.ts follows the same three-layer DB type pattern used by assets.service.ts. Row types RopaRecordRow and RopaFieldRow in src/db/types.ts mirror the exact column names and pg-native types of ropa_records and ropa_personal_data_fields. The mapper functions mapRopaRecord and mapRopaField in src/db/mappers.ts perform all snake_case โ camelCase conversion, null โ undefined coercion, and Date โ ISO string serialisation in one place. Services use pool.query<RopaRecordRow>() โ no as casts appear in query results.
RoPA types live in src/types/ropa.types.ts rather than src/domain/types.ts because they are mirrored on the frontend at packages/frontend/src/types/ropa.types.ts. The pattern is otherwise identical.
See DB Type Layer for the full pattern description and guidance on adding new entities.
Related pages¶
- RoPA Records features
- RoPA Records user guide
- Asset Storage โ PostgreSQL write-through cache architecture
- DB Type Layer โ DB row types, domain types, and mapper pattern
- PostgreSQL deployment โ firewall rules and schema management
- BPMN Modeler developer docs โ moddleDescriptor, ProcessList, deploy modal