Skip to content

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:

  1. INSERT ... ON CONFLICT (bpmn_process_id) DO UPDATE โ€” upserts the record header, returns the UUID
  2. DELETE FROM ropa_personal_data_fields WHERE ropa_record_id = $id โ€” clears existing fields
  3. INSERT loop โ€” writes all field rows with sort_order preserved
  4. COMMIT โ€” 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:

activeProcess: BpmnProcess | null;
onRopaRefChange: (ropaRef: string | undefined) => void;

The current ropaRef is extracted from the active process XML by a simple regex:

currentRopaRef={activeProcess.xml.match(/ronl:ropaRef="([^"]+)"/)?.[1]}

handleRopaRefChange in BpmnModeler.tsx performs three operations:

  1. Ensures xmlns:ronl="http://ronl.nl/schema/1.0" is declared on the <definitions> element
  2. Either sets, updates, or removes ronl:ropaRef depending on whether a value is passed
  3. Saves the modified XML via BpmnService.saveProcess

Deploy modal warning

BpmnCanvas.tsx sets a ropaRefMissing flag during bundle assembly in handleOpenDeployModal:

const ropaRefMissing = !xml.includes('ronl:ropaRef=');

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:

npx ts-node --project tsconfig.json src/db/seed-ropa.ts

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.