Skip to content

feat: entity-level publication policy layer (#112)#147

Open
rjzondervan wants to merge 21 commits into
developmentfrom
feat/112/entity-publication-policies-impl
Open

feat: entity-level publication policy layer (#112)#147
rjzondervan wants to merge 21 commits into
developmentfrom
feat/112/entity-publication-policies-impl

Conversation

@rjzondervan
Copy link
Copy Markdown
Member

@rjzondervan rjzondervan commented May 15, 2026

Summary

Implements entity-publication-policies (Wave 1.2 of the "anonimiseren bij de bron" bundle). Adds an entity-level policy layer that pre-empts the publication-clearance workflow at detection time, with deterministic conflict resolution between deny-list (prohibitions) and allow-list (standing consents) surfaces.

Backend

  • New publicationProhibition schema in the consent register — entity-level deny rules with 4 seed records (court order, minor protection, undercover officer, AP categorical exemption).
  • publicationConsent extended with the scope discriminator (document / entity) and the entity-scope-only field set (matchRules, validFrom, validUntil, active, consentMethod, consentDocument, consentScope), plus a policyMatch field linking a per-document record back to its driving prohibition / standing consent. 4 standing-consent seed records covering the consentMethod variants.
  • PolicyMatchService — detection-time matcher with in-memory per-request cache, four match types (exact, normalized, bsn, kvk), deterministic conflict resolution (prohibition wins; multi-prohibition broken by lowest UUID lex).
  • PolicyRetroactiveService — force-resolves in-flight scope:"document" records to anonymized when a prohibition is added or widened; intentionally no-op for standing-consent creation (future detections only — privacy default wins on retroactive sweep). Dispatched from DocuDeskEventHandler via payload-shape detection.
  • ConsentService::createConsentRequest now consults PolicyMatchService before defaulting to WOO; branches into 4 detection-time outcomes (no match / prohibition / standing consent / both → prohibition). New scope-validation gate at write time. ConsentUpdateHandler rejects consentStatus transitions on records pre-empted by a policy.
  • PolicyController + PolicyCrudService with 10 routes under api/policy/{prohibitions,standing-consents}. Service-level RBAC gate: standing-consent writes require docudesk-standing-consent-admins group (returns 403); prohibition writes restricted to docudesk-policy-admins at schema level.

Frontend

  • Two new admin pages: Publication Prohibitions + Standing Publication Consents (both with CnIndexPage + CnStatsBlock + CnStatusBadge + NcDialog create/edit, match-rules subform, BSN/KvK encouragement / no-expiry warnings).
  • Consent Workflow (renamed from Consent Management) — filters to scope:"document", adds "policy" badge on rows whose policyMatch is non-null.
  • ConsentDetail anonymisation toggle keyed off the policyMatch referent kind: prohibition → ON+locked; standing consent → OFF+overridable (override records publicationDecision: "anonymize" while preserving consentStatus: "consent_given" and policyMatch).
  • Smoke-test rebuild of AnonymizationWidget.vue — split upload/extract from anonymise with a manual review step exposing the Wave 1.3 grondslagen + skip-anonymise PATCH on each detected entity. Intentionally simple; example shape for the frontend team, not a production publication-prep page. New Pinia stores prohibitionStore, standingConsentStore.

Other

  • Template fix for the chunk-loading regression from perf: split shared Vue/@nextcloud/vue chunks across entry-points #119templates/index.php and templates/settings/admin.php were never updated to load the new docudesk-shared-vendor.js + docudesk-shared-nc-vue.js chunks, leaving the entry bundle waiting on chunkOnLoad forever and rendering nothing. Worth a cross-check on pipelinq / procest — they got the same split.
  • 19 new unit tests (scope-validation corners, retroactive eligibility, standard ConsentService surface, ConsentUpdateHandler policy-pre-empted guard).
  • Newman collection extended with policy CRUD + scope-validation cases.
  • Docs: section in docs/features/publication-consent-process.md covering the three-layer evaluation, retroactive asymmetry, UI toggle semantics, three admin surfaces, RBAC defaults. CHANGELOG entries under Added / Changed / Behavior changes.

Test plan

  • OR imported_config_docudesk_version re-imports to 8.0.0; both schemas present (GET /api/registers/consent?_extend=schemas).
  • GET /apps/docudesk/api/policy/prohibitions → 4 seeds; GET /apps/docudesk/api/policy/standing-consents → 4 seeds.
  • CRUD a prohibition + a standing consent via the new admin pages; verify list refresh + scope-validation errors (missing consentMethod, documentId on scope:"entity").
  • Detection-time outcomes via POST /api/consents: no-match, prohibition match, standing-consent match, both-match (prohibition wins). Verify policyMatch + notificationStatus + consentStatus + publicationDecision per spec.
  • Retroactive force-resolve: create in-flight pending consent → create matching prohibition → record now anonymized + policyMatch. Repeat with a new standing consent → in-flight record unchanged.
  • ConsentDetail toggle: locked-ON for prohibition match; OFF+interactive for standing-consent match (flipping ON updates publicationDecision only); legacy UX for policyMatch: null.
  • Policy-pre-empted transition guard: PUT a consent record with consentStatus change on a record with non-null policyMatch → 400.
  • Newman collection passes against a live stack.
  • composer check:strict clean; openspec validate entity-publication-policies clean.

@github-actions
Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/docudesk @ 45203c2

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
composer ✅ 108/108
npm
PHPUnit ⏭️
Newman ⏭️
Playwright ⏭️

Quality workflow — 2026-05-15 08:11 UTC

Download the full PDF report from the workflow artifacts.

Copy link
Copy Markdown

@WilcoLouwerse WilcoLouwerse left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

REQUEST_CHANGES — Strict mode. 6 blockers, 5 significant issues.

🔴 Blockers (6): RBAC bypass on prohibition CRUD; prohibition lock bypass via publicationDecision; silent write on referent lookup failure; NcSelect missing inputLabel (ADR-004/gate-12, protected); NcDialog inline modal (ADR-004/gate-13, protected); \OC::$server->get() service-locator.

🟡 Significant (5): loadStandingConsents full table scan with no scope filter; ConsentDetail.vue uses raw fetch() instead of axios; PolicyRetroactiveService full table scan; severity enum mismatch between register JSON and frontend; WAVE-1-SMOKE-TESTS.md in repo root with hardcoded credentials.

All CI failures are pre-existing on the development branch.

Comment thread lib/Service/PolicyCrudService.php
Comment thread lib/EventListener/DocuDeskEventHandler.php Outdated
Comment thread lib/Service/ConsentUpdateHandler.php Outdated
Comment thread lib/Service/ConsentService.php Outdated
Comment thread lib/Service/PolicyMatchService.php
Comment thread src/views/policy/ProhibitionIndex.vue Outdated
Comment thread src/views/policy/ProhibitionIndex.vue Outdated
Comment thread src/views/consent/ConsentDetail.vue Outdated
rjzondervan added a commit that referenced this pull request May 15, 2026
…s + significant)

Addresses the DD #147 review on the entity-level publication policy
layer. Six blockers, five significant items, plus moving the smoke-test
doc out of the repo root.

**Blockers:**

- **RBAC bypass on prohibition CRUD.** `PolicyCrudService::createProhibition`,
  `updateProhibition`, and `deleteProhibition` no longer call ObjectService
  with `_rbac: false` without a group check. A new private
  `assertProhibitionPermission(string $action)` mirrors
  `assertStandingConsentPermission` and gates writes on either admin role
  or membership in the new `docudesk-prohibition-admins` group. Throws
  RuntimeException otherwise — mapped to HTTP 403 by the controller.

- **Service-locator anti-pattern in DocuDeskEventHandler.** Removed the
  `\OC::$server->get(PolicyRetroactiveService::class)` call inside
  `dispatchPolicyRetroactive`. The retroactive service is now passed as a
  method parameter from each public event-handler entry point
  (`handleObjectCreated` / `handleObjectUpdated` / `handleObjectDeleted`),
  so Nextcloud's event dispatcher injects it via reflection-based DI —
  no static accessor, no NC30 breakage.

- **Prohibition lock bypass via publicationDecision.**
  `ConsentUpdateHandler::guardPolicyPreemptedTransition` now checks BOTH
  `consentStatus` AND `publicationDecision` for a change against the
  existing record. A PATCH carrying only `publicationDecision: "publish"`
  on a prohibition-matched record now raises InvalidArgumentException
  with the rejected field name. Without this fix, only `consentStatus`
  transitions were guarded — the publish field was a quiet bypass.

- **Silent write on policyMatch lookup failure.**
  `ConsentService::assertPolicyMatchReferentValid` no longer swallows
  ObjectService lookup errors with a warning log. A failed lookup now
  throws `InvalidArgumentException` (mapped to HTTP 400 at the
  controller), with the underlying exception preserved as `previous`. A
  write referencing an unresolvable `policyMatch` UUID is rejected
  outright, never persisted.

- **NcSelect missing inputLabel (ADR-004 gate-12).** Added
  `:input-label="…"` to every NcSelect in `ProhibitionIndex.vue`,
  `ProhibitionFormModal.vue`, and `StandingConsentIndex.vue`. Screen
  readers now have a labelled control on entity-type, severity,
  match-type, and consent-method dropdowns.

- **NcDialog inline (ADR-004 gate-13).** Extracted the prohibition
  create/edit dialog from `ProhibitionIndex.vue` into a new
  `ProhibitionFormModal.vue` component. The parent now owns only the
  open flag + record-being-edited; the modal hydrates its own form
  state on `open` change and emits `submit(data)` / `update:open` /
  `cancel`. The dialog (and its form's match-rule logic, validation,
  options) is no longer embedded in the index view.

**Significant items:**

- **`loadStandingConsents` full table scan.** `PolicyMatchService` now
  pushes `scope=entity` AND `active=true` filters down to ObjectService
  instead of loading every publicationConsent row and filtering in PHP.
  The defensive PHP scope check is retained as a belt-and-braces.

- **Raw `fetch()` in ConsentDetail.vue.** Replaced both
  `fetch(OC.generateUrl(...))` calls in `refreshPolicyMatch` with
  `axios.get(generateUrl(...))`, picking up the app's standard auth
  headers + CSRF tokens + error envelope. Added the `axios` and
  `generateUrl` imports.

- **`PolicyRetroactiveService` full table scan.**
  `loadInFlightDocumentRecords` now adds `scope=document` to the
  findAll filter so the read is bounded server-side. Same defensive
  scope check retained.

- **Severity enum mismatch.** The frontend `severityOptions` (was
  `['low','standard','high','critical']`, default `'standard'`) did
  not match the `publicationProhibition.severity` enum in
  `docudesk_register.json` (`['high','medium','low']`, default
  `'medium'`). The frontend has been corrected to mirror the schema;
  `severityColorMap` updated to match. Schema is the source of truth.

- **WAVE-1-SMOKE-TESTS.md moved out of repo root.** Now lives at
  `docs/testing/wave-1-smoke-tests.md`. Hardcoded `admin/admin`
  credentials replaced with `NC_USER` / `NC_PASS` env-var placeholders
  and a clear "local dev only" disclaimer at the top.

**Quality.**
- PHPCS clean on every touched lib file (one 130-char warning on a
  required sprintf format string; under threshold of error).
- Psalm: no errors on the six touched lib files.
- PHPStan: no errors on the six touched lib files.
- Test bootstrap requires a running NC stack (pre-existing); not
  exercised here.
@github-actions
Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/docudesk @ 33b53c2

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
composer ✅ 108/108
npm
PHPUnit ⏭️
Newman ⏭️
Playwright ⏭️

Quality workflow — 2026-05-15 14:56 UTC

Download the full PDF report from the workflow artifacts.

rjzondervan added a commit that referenced this pull request May 18, 2026
…gh the event listener

Follow-up to commit 3c90dd1 (PR #147 review). When I replaced the
service-locator in `DocuDeskEventHandler` with a constructor/method
parameter, I updated the three public event-handler methods
(`handleObjectCreated`, `handleObjectUpdated`, `handleObjectDeleted`)
to take `PolicyRetroactiveService $retroactive` as an extra argument
— but the caller in `DocuDeskEventListener::dispatchEvent` was not
updated to pass it through.

Result: every ObjectCreated / ObjectUpdated / ObjectDeleted event from
OpenRegister hit DocuDesk and immediately failed with:

> Too few arguments to function
> OCA\DocuDesk\EventListener\DocuDeskEventHandler::handleObjectCreated(),
> 5 passed in DocuDeskEventListener.php on line 114 and exactly 6
> expected

Visible to operators as a hard failure on creating any DocuDesk-side
object that goes through OR's event bus, including dossiers via the
new "Create dossier for this folder" UI in Wave 4a.

Fix: pull `PolicyRetroactiveService` via the same service-locator the
listener already uses for the other DI deps (Logger, MetadataService,
SettingsService) and pass it into `dispatchEvent`, which then threads
it into all three handler calls. The handlers themselves were already
correct.

This keeps the listener layer (which lives outside the DI container
because it's invoked via NC's event dispatcher reflection) on its
existing pattern while the handler layer gets proper method-arg DI.
@github-actions
Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/docudesk @ 4124c02

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
composer ✅ 108/108
npm
PHPUnit ⏭️
Newman ⏭️
Playwright ⏭️

Quality workflow — 2026-05-18 08:46 UTC

Download the full PDF report from the workflow artifacts.

@rjzondervan
Copy link
Copy Markdown
Member Author

Review response — commits 3c90dd1 and 3af8e7d

Addressed every blocker + every significant item. 3af8e7d is a follow-up fix on top of 3c90dd1 for a service-locator threading bug I introduced when removing the original service-locator (caught during testing — listener was passing 5 args to handlers that now expect 6).

Blockers

  • RBAC bypass on prohibition CRUD. PolicyCrudService::createProhibition, updateProhibition, and deleteProhibition no longer call ObjectService with _rbac: false without a group check. New private assertProhibitionPermission(string $action) mirrors assertStandingConsentPermission and gates writes on admin role or membership in the new docudesk-prohibition-admins group. Throws RuntimeException otherwise (controller maps to HTTP 403).
  • Service-locator anti-pattern in DocuDeskEventHandler. Removed the \OC::$server->get(PolicyRetroactiveService::class) call inside dispatchPolicyRetroactive. The retroactive service is now passed as a method parameter from each public event-handler entry point (handleObjectCreated / handleObjectUpdated / handleObjectDeleted), so Nextcloud's event dispatcher injects it via reflection-based DI — no static accessor, no NC30 breakage. Follow-up commit 3af8e7d also updates DocuDeskEventListener::dispatchEvent to pull PolicyRetroactiveService via its existing service-locator pattern (the listener runs outside the DI container) and thread it through the three handler calls.
  • Prohibition lock bypass via publicationDecision. ConsentUpdateHandler::guardPolicyPreemptedTransition now checks BOTH consentStatus AND publicationDecision for a change against the existing record. A PATCH carrying only publicationDecision: "publish" on a prohibition-matched record now raises InvalidArgumentException with the rejected field name. The previous guard only watched consentStatus; the publish field was a quiet bypass.
  • Silent write on policyMatch lookup failure. ConsentService::assertPolicyMatchReferentValid no longer swallows ObjectService lookup errors with a warning log. A failed lookup throws InvalidArgumentException (mapped to HTTP 400), with the underlying exception preserved as previous. A write referencing an unresolvable policyMatch UUID is rejected outright.
  • NcSelect missing inputLabel (ADR-004 gate-12). Added :input-label="…" to every NcSelect in ProhibitionIndex.vue, ProhibitionFormModal.vue, and StandingConsentIndex.vue. Entity-type, severity, match-type, and consent-method dropdowns now have a labelled control for screen readers.
  • NcDialog inline (ADR-004 gate-13). Extracted the prohibition create/edit dialog from ProhibitionIndex.vue into a new ProhibitionFormModal.vue component. The parent now owns only the open flag + record-being-edited; the modal hydrates its own form state on open change and emits submit(data) / update:open / cancel. The dialog and its match-rule logic, validation, and options are no longer embedded in the index view.

Significant items

  • loadStandingConsents full table scan. PolicyMatchService now pushes scope=entity AND active=true filters down to ObjectService::findAll instead of loading every publicationConsent row and filtering in PHP. The defensive PHP scope check is retained as a belt-and-braces.
  • Raw fetch() in ConsentDetail.vue. Replaced both fetch(OC.generateUrl(...)) calls in refreshPolicyMatch with axios.get(generateUrl(...)), picking up the app's standard auth headers + CSRF tokens + error envelope. Added the axios and generateUrl imports.
  • PolicyRetroactiveService full table scan. loadInFlightDocumentRecords now adds scope=document to the findAll filter so the read is bounded server-side. Same defensive scope check retained.
  • Severity enum mismatch. The frontend severityOptions (was ['low','standard','high','critical'], default 'standard') did not match the publicationProhibition.severity enum in docudesk_register.json (['high','medium','low'], default 'medium'). Frontend corrected to mirror the schema; severityColorMap updated to match. Schema is source of truth.
  • WAVE-1-SMOKE-TESTS.md in repo root with hardcoded credentials. Moved to docs/testing/wave-1-smoke-tests.md. Hardcoded admin/admin credentials replaced with NC_USER / NC_PASS env-var placeholders and a clear "local dev only" disclaimer at the top.

Quality

  • PHPCS clean on every touched lib file (one 130-char warning on a required sprintf format string; under the error threshold).
  • Psalm: no errors on the six touched lib files.
  • PHPStan: no errors on the six touched lib files.
  • Test bootstrap requires a running NC stack (pre-existing); not exercised here.

Comment thread lib/Controller/PolicyController.php
Comment thread lib/Controller/PolicyController.php
Comment thread lib/Controller/PolicyController.php
Comment thread src/views/policy/StandingConsentIndex.vue Outdated
Comment thread lib/Service/ConsentUpdateHandler.php
Comment thread src/views/anonymization/AnonymizationWidget.vue
Copy link
Copy Markdown

@WilcoLouwerse WilcoLouwerse left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review (Strict). 7 of 8 prior findings resolved. RBAC helper, service-locator removal from DocuDeskEventHandler, prohibition-lock guard on both consentStatus and publicationDecision, assertPolicyMatchReferentValid now throws, ProhibitionFormModal.vue extracted, NcSelect :input-label added in ProhibitionIndex.vue / ProhibitionFormModal.vue / StandingConsentIndex.vue, scope filters pushed to DB in both PolicyMatchService and PolicyRetroactiveService, raw fetch() replaced with axios.get, severity enum aligned to schema.

Blockers (2 🔴):

Concerns (2 🟡):

PR #119 split Vue / @nextcloud/vue / @conduction/nextcloud-vue / pinia /
vue-material-design-icons into shared chunks (docudesk-shared-vendor.js,
docudesk-shared-nc-vue.js), and updated the dashboard-widget loaders to
addScript them. The two page templates (templates/index.php and
templates/settings/admin.php) were missed: only the per-page entry was
loaded, so webpack's runtime sat forever in chunkOnLoad waiting for
chunks that never arrived. Result: blank Anonymisation page, blank
admin settings page, blank main app — no console error, just nothing.

Add the two shared chunks to both templates so the entry's
chunkOnLoad callback resolves. Order matters only to the extent that
the shared chunks self-register on a shared global before the entry
runs; loading them first is the conventional path.

Pipelinq and procest received the same split-chunks treatment in #119
and likely have the same broken template — worth a cross-check.
Implements the entity-publication-policies change (Wave 1.2). The
consent service now consults a two-tier policy layer at detection time
before falling through to the WOO objection workflow:

  - publicationProhibition (deny-list): entity-level rules that force
    anonymise on match. Court orders, minor protection, undercover
    officers, AVG categorical exemptions. New schema in the consent
    register with 4 seed records.

  - publicationConsent scope="entity" (allow-list, "standing consent"):
    entity-level rules where consent was obtained out-of-band (paper,
    digital signature, recorded verbal, opt-in form). New `scope`
    discriminator + entity-scope field set on the existing schema, with
    4 seed records.

Conflict resolution is deterministic: prohibition wins, multi-prohibition
match resolved by lowest UUID lexicographic. Standing-consent matches
populate `policyMatch` referencing the rule UUID.

Retroactive behaviour is asymmetric: creating or widening a prohibition
force-resolves all in-flight scope=document records that now match,
preserving notificationSentAt / objectionReceivedAt for audit. Standing
consents apply to future detections only — privacy default wins on
retroactive sweep.

Three admin pages back the surface:
  - Consent Workflow: existing per-document records, now with a
    "policy" indicator on rows whose policyMatch is non-null.
  - Standing Publication Consents: scope=entity records (new).
  - Publication Prohibitions: publicationProhibition records (new).

The publication-prep anonymisation toggle is keyed off the policyMatch
referent type, not consentStatus: prohibition -> ON+locked, standing
consent -> OFF+overridable (override records publicationDecision change
while preserving policyMatch and consentStatus).

RBAC: publicationProhibition writes restricted to docudesk-policy-admins
at the schema level. Standing-consent writes gated at service level by
docudesk-standing-consent-admins (the schema can't discriminate by scope
so the gate lives in PolicyCrudService). Both return 403 on failure.

Other notes:
  - PolicyMatchService caches active rules per-request with deterministic
    sort, four match types: exact, normalized, bsn, kvk.
  - PolicyRetroactiveService dispatched from DocuDeskEventHandler based
    on payload-shape heuristics (avoids per-event schema-ID lookup).
  - ConsentUpdateHandler now rejects consentStatus transitions on records
    pre-empted by a policy; overrides go via publicationDecision.
  - 19 new unit tests covering scope-validation corners, retroactive
    edge cases, and the standard 14-pass ConsentService surface.
  - Newman collection extended with policy CRUD + scope-validation tests.
  - WAVE-1-SMOKE-TESTS.md groups manual smoke steps across Waves 1.1-1.4.
  - register version bumped to 7.0.0 to force re-import on existing
    instances; adds the new authorisation block on publicationProhibition.

Spec: openspec/changes/entity-publication-policies/specs/entity-publication-policies/spec.md
Reworks AnonymizationWidget + anonymization store to pause between
extract and anonymise so the operator can set bases / skipAnonymization
per detected entity. Sets up the smoke-test surface for the Wave 1.3
PATCH endpoint on /apps/openregister/api/entity-relations/{id} and is
intentionally simple — example shape for the frontend team, not a
production publication-prep page.

Backend:
  - EntityDetectionService::normalizeEntities now passes through
    relationId, bases, skipAnonymization from the EntityRelation row.
    Forward-compatible: bases/skipAnonymization are null on OR branches
    that pre-date entity-relation-grondslagen.

Store (src/store/modules/anonymization.js):
  - Lifecycle now queued -> uploading -> extracting -> extracted ->
    anonymising -> completed (was a single auto-pipeline).
  - addFiles uploads + extracts only; stops at 'extracted'.
  - anonymiseEntry PATCHes each modified relation via the OR endpoint
    then triggers anonymise. Partial-application semantics: per-relation
    PATCH errors are surfaced inline without aborting the file.
  - anonymiseAllExtracted bulk-runs the anonymise step on every
    reviewed file.

Widget (src/views/anonymization/AnonymizationWidget.vue):
  - Drop zone + per-file cards with status badges.
  - Review table per file with bases multi-select (hardcoded 6 Woo
    Art. 5 grondslagen slugs) and skip switch.
  - "Apply decisions and anonymise" per file + "Anonymise all reviewed"
    bulk action.
  - Download link on completion.
…s + significant)

Addresses the DD #147 review on the entity-level publication policy
layer. Six blockers, five significant items, plus moving the smoke-test
doc out of the repo root.

**Blockers:**

- **RBAC bypass on prohibition CRUD.** `PolicyCrudService::createProhibition`,
  `updateProhibition`, and `deleteProhibition` no longer call ObjectService
  with `_rbac: false` without a group check. A new private
  `assertProhibitionPermission(string $action)` mirrors
  `assertStandingConsentPermission` and gates writes on either admin role
  or membership in the new `docudesk-prohibition-admins` group. Throws
  RuntimeException otherwise — mapped to HTTP 403 by the controller.

- **Service-locator anti-pattern in DocuDeskEventHandler.** Removed the
  `\OC::$server->get(PolicyRetroactiveService::class)` call inside
  `dispatchPolicyRetroactive`. The retroactive service is now passed as a
  method parameter from each public event-handler entry point
  (`handleObjectCreated` / `handleObjectUpdated` / `handleObjectDeleted`),
  so Nextcloud's event dispatcher injects it via reflection-based DI —
  no static accessor, no NC30 breakage.

- **Prohibition lock bypass via publicationDecision.**
  `ConsentUpdateHandler::guardPolicyPreemptedTransition` now checks BOTH
  `consentStatus` AND `publicationDecision` for a change against the
  existing record. A PATCH carrying only `publicationDecision: "publish"`
  on a prohibition-matched record now raises InvalidArgumentException
  with the rejected field name. Without this fix, only `consentStatus`
  transitions were guarded — the publish field was a quiet bypass.

- **Silent write on policyMatch lookup failure.**
  `ConsentService::assertPolicyMatchReferentValid` no longer swallows
  ObjectService lookup errors with a warning log. A failed lookup now
  throws `InvalidArgumentException` (mapped to HTTP 400 at the
  controller), with the underlying exception preserved as `previous`. A
  write referencing an unresolvable `policyMatch` UUID is rejected
  outright, never persisted.

- **NcSelect missing inputLabel (ADR-004 gate-12).** Added
  `:input-label="…"` to every NcSelect in `ProhibitionIndex.vue`,
  `ProhibitionFormModal.vue`, and `StandingConsentIndex.vue`. Screen
  readers now have a labelled control on entity-type, severity,
  match-type, and consent-method dropdowns.

- **NcDialog inline (ADR-004 gate-13).** Extracted the prohibition
  create/edit dialog from `ProhibitionIndex.vue` into a new
  `ProhibitionFormModal.vue` component. The parent now owns only the
  open flag + record-being-edited; the modal hydrates its own form
  state on `open` change and emits `submit(data)` / `update:open` /
  `cancel`. The dialog (and its form's match-rule logic, validation,
  options) is no longer embedded in the index view.

**Significant items:**

- **`loadStandingConsents` full table scan.** `PolicyMatchService` now
  pushes `scope=entity` AND `active=true` filters down to ObjectService
  instead of loading every publicationConsent row and filtering in PHP.
  The defensive PHP scope check is retained as a belt-and-braces.

- **Raw `fetch()` in ConsentDetail.vue.** Replaced both
  `fetch(OC.generateUrl(...))` calls in `refreshPolicyMatch` with
  `axios.get(generateUrl(...))`, picking up the app's standard auth
  headers + CSRF tokens + error envelope. Added the `axios` and
  `generateUrl` imports.

- **`PolicyRetroactiveService` full table scan.**
  `loadInFlightDocumentRecords` now adds `scope=document` to the
  findAll filter so the read is bounded server-side. Same defensive
  scope check retained.

- **Severity enum mismatch.** The frontend `severityOptions` (was
  `['low','standard','high','critical']`, default `'standard'`) did
  not match the `publicationProhibition.severity` enum in
  `docudesk_register.json` (`['high','medium','low']`, default
  `'medium'`). The frontend has been corrected to mirror the schema;
  `severityColorMap` updated to match. Schema is the source of truth.

- **WAVE-1-SMOKE-TESTS.md moved out of repo root.** Now lives at
  `docs/testing/wave-1-smoke-tests.md`. Hardcoded `admin/admin`
  credentials replaced with `NC_USER` / `NC_PASS` env-var placeholders
  and a clear "local dev only" disclaimer at the top.

**Quality.**
- PHPCS clean on every touched lib file (one 130-char warning on a
  required sprintf format string; under threshold of error).
- Psalm: no errors on the six touched lib files.
- PHPStan: no errors on the six touched lib files.
- Test bootstrap requires a running NC stack (pre-existing); not
  exercised here.
…gh the event listener

Follow-up to commit 3c90dd1 (PR #147 review). When I replaced the
service-locator in `DocuDeskEventHandler` with a constructor/method
parameter, I updated the three public event-handler methods
(`handleObjectCreated`, `handleObjectUpdated`, `handleObjectDeleted`)
to take `PolicyRetroactiveService $retroactive` as an extra argument
— but the caller in `DocuDeskEventListener::dispatchEvent` was not
updated to pass it through.

Result: every ObjectCreated / ObjectUpdated / ObjectDeleted event from
OpenRegister hit DocuDesk and immediately failed with:

> Too few arguments to function
> OCA\DocuDesk\EventListener\DocuDeskEventHandler::handleObjectCreated(),
> 5 passed in DocuDeskEventListener.php on line 114 and exactly 6
> expected

Visible to operators as a hard failure on creating any DocuDesk-side
object that goes through OR's event bus, including dossiers via the
new "Create dossier for this folder" UI in Wave 4a.

Fix: pull `PolicyRetroactiveService` via the same service-locator the
listener already uses for the other DI deps (Logger, MetadataService,
SettingsService) and pass it into `dispatchEvent`, which then threads
it into all three handler calls. The handlers themselves were already
correct.

This keeps the listener layer (which lives outside the DI container
because it's invoked via NC's event dispatcher reflection) on its
existing pattern while the handler layer gets proper method-arg DI.
@rubenvdlinde rubenvdlinde force-pushed the feat/112/entity-publication-policies-impl branch from 3af8e7d to 0241142 Compare May 19, 2026 03:15
@github-actions
Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/docudesk @ 3ed0e7f

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
composer ✅ 108/108
npm ✅ 529/529
PHPUnit
Newman ⏭️
Playwright ⏭️

Coverage: 0% (0/10 statements)


Quality workflow — 2026-05-19 03:22 UTC

Download the full PDF report from the workflow artifacts.

…rs + 2 concerns)

🔴 RBAC 403 swallowed in 3 places — PolicyController::createProhibition,
updateProhibition, and deleteProhibition all caught Exception → 500 but
NOT RuntimeException, so the new assertProhibitionPermission() gate's
RuntimeException fell through to the generic Exception handler and
surfaced as HTTP 500 instead of 403. Added catch (RuntimeException $e) → 403
to each, mirroring the standing-consent counterparts (createStandingConsent,
updateStandingConsent, deleteStandingConsent) which already had the pattern.
RuntimeException is already imported.

🔴 Inline <NcDialog> in StandingConsentIndex.vue:103 violated ADR-004
gate-13 (modal isolation). Extracted into StandingConsentFormModal.vue
mirroring ProhibitionFormModal.vue: parent owns only the open flag +
record-being-edited; modal owns its own form state and emits
submit / update:open / cancel. Removed the inline form scoped styles
from StandingConsentIndex (moved into the modal where they apply).

🟡 ConsentUpdateHandler::guardPolicyPreemptedTransition could be bypassed
via PUT {"policyMatch": null}: that PUT changed neither consentStatus nor
publicationDecision, passed the both-fields check, then array_merge
cleared policyMatch in the record. A follow-up PUT could then change
consentStatus freely because the guard's early-return on policyMatch===null
short-circuited. Added an explicit guard rejecting clearing operations
on pre-empted records — policyMatch is immutable once set. Throws
InvalidArgumentException identifying the existing match without including
the rejected null/empty.

🟡 NcSelect in AnonymizationWidget.vue:113 was missing :input-label
(ADR-004 gate-12 / WCAG 2.1 AA 1.3.1+4.1.2). Added
:input-label="t('docudesk', 'Grondslagen')".
@rjzondervan
Copy link
Copy Markdown
Member Author

@WilcoLouwerse — addressed in 27e4e53. Details below mapped to the four findings.

🔴 RBAC 403 swallowed in 3 places

Added catch (RuntimeException $e) → 403 to PolicyController::createProhibition, ::updateProhibition, and ::deleteProhibition, mirroring the existing pattern on the standing-consent counterparts. RuntimeException was already imported. Order: RuntimeException (403) → InvalidArgumentException (400 — where present) → Exception (500).

After the fix, assertProhibitionPermission() throws on auth failure → caught by the RuntimeException block → returns 403 with the gate's message. Verified by reading each method end-to-end.

🔴 Inline <NcDialog> in StandingConsentIndex.vue

Extracted into src/views/policy/StandingConsentFormModal.vue, mirroring ProhibitionFormModal.vue 1:1:

  • Props: open, editingRecord, saving, formError
  • Emits: update:open, submit, cancel
  • Parent owns: open flag, the record being edited, the save outcome
  • Modal owns: form state (blankForm(), validation, match-rule add/remove)

StandingConsentIndex.vue updated to import + register the new component and pass props through. The inline form-scoped CSS (.standing-consent-form, .match-rule-row, .form-warning, .form-error) moved into the modal where it applies; .policy-stats stays in the index because it's outside the modal.

ADR-004 gate-13 compliance pattern now matches the prohibition side. Future modals in this directory follow the same shape.

🟡 policyMatch null-clearing bypass

Closed in ConsentUpdateHandler::guardPolicyPreemptedTransition. Added an explicit guard immediately after the early-return-on-already-null-match check:

if (array_key_exists('policyMatch', $data) === true) {
    $newMatch = $data['policyMatch'];
    if ($newMatch === null || $newMatch === '') {
        throw new InvalidArgumentException(
            message: sprintf(
                'policyMatch cannot be cleared on a policy-pre-empted record (existing=%s). ...',
                (string) $existingMatch
            )
        );
    }
}

The check fires when (a) the record was already pre-empted ($existingMatch !== null, established by the early-return above this block), AND (b) the incoming PUT carries policyMatch with a null or empty value. The message identifies the existing match value (which is non-PII — it's a policy UUID/slug) and explains that creating a new consent record is the correct action when the policy no longer applies.

Alternative considered (strip policyMatch from the writable field set entirely): rejected because policyMatch IS written at consent-create time by the policy-engine flow; an absolute write block would break create. The conditional-on-clearing approach preserves create while closing the update bypass.

🟡 NcSelect missing :input-label in AnonymizationWidget.vue

Added :input-label="t('docudesk', 'Grondslagen')" on the grondslagen multi-select. ADR-004 gate-12 / WCAG 2.1 AA 1.3.1+4.1.2 compliance restored — screen readers now associate the combobox with its label via NcSelect's built-in wiring rather than relying on external <label> markup that the component doesn't see.


Re-requesting review.

@github-actions
Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/docudesk @ 02c29ce

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
composer ✅ 108/108
npm ✅ 529/529
PHPUnit ⏭️
Newman ⏭️
Playwright ⏭️

Quality workflow — 2026-05-19 10:09 UTC

Download the full PDF report from the workflow artifacts.

Comment thread lib/Service/ConsentUpdateHandler.php
Comment thread lib/Service/ConsentUpdateHandler.php Outdated
@github-actions
Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/docudesk @ 6e28a21

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
composer
npm ✅ 575/575
PHPUnit ⏭️
Newman ⏭️
Playwright ⏭️

Quality workflow — 2026-05-22 08:54 UTC

Download the full PDF report from the workflow artifacts.

@github-actions
Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/docudesk @ 50f473b

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
composer
npm ✅ 575/575
PHPUnit ⏭️
Newman ⏭️
Playwright ⏭️

Quality workflow — 2026-05-22 09:21 UTC

Download the full PDF report from the workflow artifacts.

@github-actions
Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/docudesk @ 4ce8551

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
composer ✅ 108/108
npm ✅ 575/575
PHPUnit ⏭️
Newman ⏭️
Playwright ⏭️

Quality workflow — 2026-05-22 09:51 UTC

Download the full PDF report from the workflow artifacts.

@github-actions
Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/docudesk @ 7ffaf0a

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
composer ✅ 108/108
npm ✅ 575/575
PHPUnit ⏭️
Newman ⏭️
Playwright ⏭️

Quality workflow — 2026-05-22 09:55 UTC

Download the full PDF report from the workflow artifacts.

phpcbf auto-fixed 65 blank-line/whitespace violations across the new
policy + consent services and controllers added in this PR; CI's
PHP Quality (phpcs) job will now exit clean. Also added the missing
@param checked declaration on ConsentDetail.vue::onToggleAnonymise
so the Vue Quality (eslint) job stops warning on the toggle handler.
@github-actions
Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/docudesk @ 0a2ba5e

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
composer ✅ 108/108
npm ✅ 575/575
PHPUnit
Newman ⏭️
Playwright ⏭️

Coverage: 0% (0/10 statements)


Quality workflow — 2026-05-22 12:06 UTC

Download the full PDF report from the workflow artifacts.

A missing `}, {` between the `standing-consent-verbal-bakker` consent
seed and the `persoonsgegevens` base seed (lines 1196–1197) made the
whole register-config file invalid JSON, which broke every
DossierRegisterConfigTest case (6/6 PHPUnit failures: "Failed
asserting that null is of type array"). With the object boundary
restored, json_decode round-trips cleanly and the suite passes locally.
@github-actions
Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/docudesk @ cbeaf47

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
composer ✅ 108/108
npm ✅ 575/575
PHPUnit
Newman ⏭️
Playwright ⏭️

Coverage: 0% (0/10 statements)


Quality workflow — 2026-05-22 12:31 UTC

Download the full PDF report from the workflow artifacts.

… deps

CI's Vue Quality (eslint) job failed with "ESLint couldn't find the
plugin eslint-plugin-import" and (stylelint) failed with "stylelint:
not found". Both stem from @nextcloud/eslint-config and
@nextcloud/stylelint-config declaring their lint plugins as
peerDependencies — the docudesk package.json was not pulling them in
directly, so npm ci landed a node_modules without them. Adding the
peer deps explicitly so the lockfile gets resolved entries for each
plugin on the next install.
@github-actions
Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/docudesk @ 20afa1b

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
composer ✅ 108/108
npm
PHPUnit ⏭️
Newman ⏭️
Playwright ⏭️

Quality workflow — 2026-05-22 12:46 UTC

Download the full PDF report from the workflow artifacts.

Companion to the previous package.json bump (3240ed9). Ran
`npm install --package-lock-only --legacy-peer-deps` so the lockfile
now contains top-level resolved entries for eslint-plugin-import,
eslint-plugin-vue, eslint-plugin-jsdoc, stylelint, and the other
@nextcloud/eslint-config + @nextcloud/stylelint-config peers we just
added directly. `npm ci --legacy-peer-deps` (what the workflow runs)
will land those plugins in node_modules so the lint scripts can find
them.
@github-actions
Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/docudesk @ 4fdb214

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
composer ✅ 108/108
npm ✅ 575/575
PHPUnit
Newman ⏭️
Playwright ⏭️

Coverage: 0% (0/10 statements)


Quality workflow — 2026-05-22 13:31 UTC

Download the full PDF report from the workflow artifacts.

CI's Vue Quality (stylelint) job failed after the previous lint-deps
fix with "Cannot resolve custom syntax module postcss-html". That
module is the customSyntax used to parse <style> blocks inside .vue
files and is declared as a peer of stylelint-config-recommended-vue
(which we just pulled in). Same pattern as the earlier peer-dep gap —
adding it directly to devDependencies so npm ci lands it in the
runner's node_modules.
@github-actions
Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/docudesk @ 5524d63

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
composer ✅ 108/108
npm ✅ 575/575
PHPUnit
Newman ⏭️
Playwright ⏭️

Coverage: 0% (0/10 statements)


Quality workflow — 2026-05-22 13:40 UTC

Download the full PDF report from the workflow artifacts.

$existingMatch = ($existing['policyMatch'] ?? null);
if ($existingMatch === null || $existingMatch === '') {
return;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocker — UPDATE-side SERVER_CONTROLLED_FIELDS bypass on policyMatch: null records (same class as 4th/5th-pass findings)

The fourth-pass foreach (['matchKind', 'policyMatch'] as $serverOnlyField) immutability check sits at lines 185-200 — but it sits after this if ($existingMatch === null || $existingMatch === '') return; early return. The guard's name says "pre-empted transitions" but matchKind and policyMatch are server-controlled on every record, not just records that already have a policyMatch.

Exploit shape against a record in normal WOO-objection state (policyMatch: null, consentStatus: pending):

PATCH /apps/docudesk/api/consent/<uuid>
{
  "policyMatch": "<any-uuid-attacker-picks>",
  "matchKind": "standing_consent",
  "consentStatus": "consent_given",
  "publicationDecision": "publish_with_consent",
  "objectionDeadline": null
}

Flow: early-return at L170-172 (because $existingMatch === null) → SERVER_CONTROLLED check at L185 is skipped → no assertPolicyMatchReferentValid is called on the UPDATE path → array_merge($existing, $data) at updateConsentStatus:108 accepts everything → record is now persisted as if a standing consent had pre-empted it: WOO objection deadline gone, consent_given without the data subject's actual consent, publication unblocks.

Note that testNoPolicyMatchAllowsArbitraryTransition (the test added in the 4th pass) effectively documents this hole as intended — it asserts the guard returns without throwing on a PATCH that touches consentStatus + publicationDecision on an unmatched record. The test contract is the bug.

Suggested fix: the cleanest shape is to split the function — matchKind and policyMatch are always server-controlled, the consentStatus/publicationDecision lock is what's policy-pre-empted-conditional. Either:

(a) Move the foreach (['matchKind','policyMatch']) block before the L170 early return inside guardPolicyPreemptedTransition, OR

(b) Pull it out into a separate guardServerControlledFields($existing, $data) called from updateConsentStatus ahead of guardPolicyPreemptedTransition, OR

(c) Strip the two fields at the controller boundary (ConsentController::updateConsentCrudService::updateConsentStatus) mirroring the CREATE-side pattern in ConsentCrudService::createFromRequest:187.

Then flip testNoPolicyMatchAllowsArbitraryTransition to assert REJECTION on a PATCH carrying matchKind / policyMatch, and add a positive test: PATCH {policyMatch: "<uuid>", consentStatus: "consent_given"} on a policyMatch: null record → throws.

Comment thread lib/Service/PolicyMatchService.php Outdated
);
}

$this->rulesCache = $rules;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocker — loadRules fails OPEN on infrastructure errors; prohibitions silently disappear during OR outages

When loadProhibitions() or loadStandingConsents() throws (OpenRegister down, DB connection lost, schema migration in flight, transient blip), this catch (Exception $e) logs a warning and assigns $rules = [] — empty rule set. Subsequent match() calls return null for every entity. The consent workflow then falls through to the WOO default branch in ConsentService::createConsentRequest:200+ (no match → 4-week objection period, name surfaces in notification + draft).

The standing-consent path failing open is acceptable (worst case: an entity that should have been auto-allowed sits in the objection queue for a few weeks). The prohibition path failing open is a privacy regression:

  • Court-ordered erasure subject → name leaks into a publication-decision notification email because OR was unreachable for the 200ms the match call took.
  • Victim-protection prohibition silently bypassed → name goes into the 4-week-published draft.
  • AVG categorical exemption ignored → categorical PII goes through standard workflow.

Prohibitions are a deny-list; security-critical deny-lists fail CLOSED. The current behaviour optimises for "publication keeps moving when OR is briefly degraded" at the cost of the explicit privacy guarantees the feature exists to enforce.

Suggested fix: split the load so the two paths can fail differently. Rough shape:

try {
    $prohibitions = $this->loadProhibitions();
} catch (Exception $e) {
    $this->logger->error('PolicyMatchService: failed to load prohibitions — failing CLOSED', ['error' => $e->getMessage()]);
    throw new RuntimeException('Prohibition registry unavailable; refusing to process consent without policy data', 0, $e);
}

try {
    $standingConsents = $this->loadStandingConsents();
} catch (Exception $e) {
    $this->logger->warning('PolicyMatchService: failed to load standing consents — degraded mode, treating as empty', ['error' => $e->getMessage()]);
    $standingConsents = [];
}

$this->rulesCache = array_merge($prohibitions, $standingConsents);
return $this->rulesCache;

ConsentService::createConsentRequest then surfaces a 5xx and document publication stalls until OR recovers — preferable to letting prohibited names leak. Document this fail-mode distinction in the loadRules PHPDoc explicitly so future edits don't "harmonise" the two paths to symmetric fail-open.


$status = (string) ($r['consentStatus'] ?? '');
if (in_array($status, self::IN_FLIGHT_STATUSES, true) === false) {
return false;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocker — Retroactive sweep cannot override standing consents; prohibitions silently lose the conflict on retroactive paths

This filter skips every record with policyMatch !== null. The reasoning seems sound — "these records already have a match, leave them alone" — but it breaks the canonical "prohibitions win" rule from PolicyMatchService::match on the retroactive code path.

IN_FLIGHT_STATUSES at L61-66 includes consent_given. So a record matched at detection-time by a standing consent (consentStatus: consent_given, policyMatch: <sc-uuid>, matchKind: standing_consent) is in-flight from this sweep's POV. Concrete sequence:

  1. Operator creates a standing consent for entity X.
  2. Detection runs against a document mentioning X → standing-consent match → record persisted with consentStatus: consent_given, policyMatch: <sc-uuid>. Awaiting publication.
  3. Court issues an erasure order against X → operator creates a publicationProhibition for X.
  4. applyProhibitionMutation runs the retroactive sweep → loadInFlightDocumentRecords filters out the record from step 2 because policyMatch !== null → the prohibition does not force-resolve it → publication proceeds with X's name despite the court order.

The spec / docs / detection-time matcher all say "prohibitions beat standing consents" — except here. This is the one place the rule is silently inverted.

Suggested fix: change the predicate from "skip records with any policyMatch" to "skip records already locked by a prohibition":

if (isset($r['policyMatch']) === true
    && $r['policyMatch'] !== null
    && $r['policyMatch'] !== ''
    && ($r['matchKind'] ?? '') === self::KIND_PROHIBITION  // only skip prohibition-matched
) {
    return false;
}

(or import the constant via PolicyMatchService::KIND_PROHIBITION). matchKind is populated by buildConsentData at create-time, so the discriminator is already on the record.

Note that this then also touches finding #1 — when the sweep force-resolves a standing-consent-matched record into a prohibition-matched record, the immutability guard for matchKind / policyMatch must not block it (the sweep is the canonical path that legitimately mutates these fields). The sweep saves via saveObject directly, not via updateConsentStatus, so the guard isn't on its hot path — but worth confirming the fix doesn't accidentally route it through the guarded path. Also add a test in PolicyRetroactiveServiceTest for this exact sequence.

return $this->error(message: 'Failed to list prohibitions: ', exception: $e);
}

}//end indexProhibitions()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocker — @NoAdminRequired on the READ endpoints exposes the prohibition list (court orders, victim-protection cases, GDPR Art.17 requestors) to every authenticated user

indexProhibitions / showProhibition / indexStandingConsents / showStandingConsent (lines 71, 93, 191, 213) all carry @NoAdminRequired with no service-level group check — PolicyCrudService::assertProhibitionPermission and assertStandingConsentPermission only fire on list_* / get_* WRITE actions, not on the read paths (grep assertProhibitionPermission(action: — it only appears in createProhibition / updateProhibition / deleteProhibition).

Result: any authenticated Nextcloud user (viewer roles, end users with the app installed, plus guest / federated users where applicable) can:

GET /apps/docudesk/api/policy/prohibitions

…and enumerate every prohibition record. Each record contains the exact PII the feature exists to protect — primaryName, legalAuthority (which court / which judge), caseReference, reason (often a sentence like "Slachtoffer van mensensmokkel — slachtofferbescherming Art. 226 Sv" per the doc's own example use cases), matchRules with bsn / kvk as plain strings, validity dates.

The standing-consent list is sensitive but less critically so — names of people who have signed publication consent forms, the consent document reference, scope description. Still arguably need-to-know, not all-tenant.

Suggested fix: add read-side asserters to PolicyCrudService::listProhibitions / listStandingConsents / getProhibition / getStandingConsent, mirroring the existing write-side gates. Either reuse the existing admin groups (docudesk-policy-admins for prohibition reads, docudesk-standing-consent-admins for standing consents) or introduce separate *-viewers groups if reviewer-only access is needed. The doc at docs/features/publication-consent-process.md only addresses WRITE RBAC — should be updated to document the READ side too.

This is more important than the CRUD-side authorization fix from earlier rounds: WRITE-side leak == bad-actor with elevated privileges modifies records (admin-bypass class); READ-side leak == every employee on every NL government tenant can dump the protected-persons list (mass-disclosure class).

]
},
{
"name": "Policy — Prohibitions",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocker — Newman collection doesn't exercise the 4 detection-time outcomes the PR description claims

The PR description's test plan states:

Detection-time outcomes via POST /api/consents: no-match, prohibition match, standing-consent match, both-match (prohibition wins). Verify policyMatch + notificationStatus + consentStatus + publicationDecision per spec.

The collection actually has (Policy folders, L228-388):

  • GET /api/policy/prohibitions
  • POST /api/policy/prohibitions (create)
  • GET /api/policy/prohibitions/{id}
  • DELETE /api/policy/prohibitions/{id}
  • GET /api/policy/standing-consents
  • 2 negative scope-validation cases (POST without consentMethod, POST with documentId)

Not a single test actually:

  1. Seeds a prohibition for entity X
  2. POSTs /api/consent with entityText: "X"
  3. Asserts the resulting record has consentStatus: anonymized, matchKind: prohibition, policyMatch: <seeded uuid>, publicationDecision: anonymize, notificationStatus: skipped

And the symmetric standing-consent path, the both-match conflict path (prohibition wins), and the no-match baseline.

These are the integration contracts that protect against the exact bypass classes the prior review rounds focused on. Without them the Newman gate cannot catch:

  • Prohibition silently failing to fire because loadRules fails open (finding here)
  • Server-controlled fields slipping past on CREATE / UPDATE (finding here)
  • Prohibition failing to override existing standing consent (finding here)

The PR description is misleading on what's covered. Either amend the description, or — preferred — add a Policy — Detection-time outcomes folder with 4 sub-tests doing the seed → POST /api/consent → assert pattern for each outcome, plus a cleanup post-script that deletes the seeded prohibition / standing consent so the test is idempotent.

Suggested shape (one per outcome):

Pre-request: seed prohibition matching "Jan Janssen"
Test: POST /api/consent with detected entity "Jan Janssen"
Assertions:
  - 201/200
  - body.matchKind === "prohibition"
  - body.policyMatch === <seeded uuid>
  - body.consentStatus === "anonymized"
  - body.publicationDecision === "anonymize"
  - body.notificationStatus === "skipped"
Post-request: DELETE the seeded prohibition

*
* @return void
*/
public function testInactiveProhibitionResolvesNothing(): void
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Concern (Strict) — PolicyRetroactiveServiceTest has zero happy-path coverage; the actual force-resolve write is untested

All 5 tests in this file assert early-return paths:

  • testInactiveProhibitionResolvesNothing (L103) — bails on active: false
  • testFutureProhibitionResolvesNothing (L127) — bails on validFrom in the future
  • testProhibitionWithoutUuidLogsAndReturnsZero (L152) — bails on missing UUID
  • testStandingConsentMutationOnlyInvalidatesCache (L175) — applyStandingConsentMutation() is a no-op by design
  • testRuleRemovalOnlyInvalidatesCache (L190) — applyRuleRemoval() is a no-op by design

The actual force-resolve mechanism — loadInFlightDocumentRecordsentityMatchesAnyRuleforceResolveToAnonymizedarray_merge + saveObject — has zero test coverage. It is the most security-critical method in the service: it overwrites consent records and the correctness of the overwrite payload (preserve notificationSentAt + objectionReceivedAt, clear objectionDeadline, set policyMatch to the right UUID, set consentStatus: anonymized) is exactly what a regression test should anchor.

In Strict mode this is a blocker because the file under test contains the array_merge-and-save write that finding #3 flags as having the wrong skip-filter. A happy-path test would have surfaced that the prohibition silently skips standing-consent-matched records.

It would also surface the asymmetry with ConsentService::buildConsentData, which DOES set matchKind on the CREATE-time path. forceResolveToAnonymized writes the four status fields and policyMatch but does NOT set matchKind — so a retroactively-force-resolved record has policyMatch: <prohibition-uuid> but matchKind: ''. The standing-consent carve-out in ConsentUpdateHandler:229 keys off matchKind === 'standing_consent' so a prohibition-resolve doesn't trip it today, but any future logic keying off matchKind === 'prohibition' will silently miss these records. Latent bug class.

Suggested tests to add:

  • testProhibitionForceResolvesMatchingInFlightRecord — assert saveObject is called with the expected payload (including matchKind: 'prohibition' once the code is fixed for symmetry)
  • testProhibitionPreservesAuditTimestampsnotificationSentAt + objectionReceivedAt in the save payload equal the existing values
  • testProhibitionSkipsAlreadyProhibitedRecords — record with matchKind: 'prohibition' is skipped (preserves the idempotency of repeated sweeps)
  • testProhibitionOverridesStandingConsent — record with matchKind: 'standing_consent' IS swept (validates the fix from finding Documentation Build Failed #3)
  • testProhibitionResolvesAcrossEntityTypeOTHER — entity type OTHER wildcard

And fix forceResolveToAnonymized to write matchKind: 'prohibition' on the save payload to keep symmetry with buildConsentData.

Copy link
Copy Markdown

@WilcoLouwerse WilcoLouwerse left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Strict re-review — replacing my previously auto-dismissed APPROVE. The fix-window since 9a34ce1 (composer CVE bumps, PHPCS auto-fix, seed JSON repair, npm peer-deps, dev merges) is clean and the prior 5 rounds of security fixes are intact at HEAD. The Strict-mode re-audit on the unchanged 5th-pass surface surfaced 5 🔴 + 1 🟡 that we missed in earlier rounds because the focus was on the existing-match attack path:

🔴 Blockers

🟡 Concerns (Strict-mode blockers)

Edited 2026-05-22: withdrew the @NoCSRFRequired concern (deleted the inline comment) — @NoCSRFRequired on every endpoint is the deliberate ConductionNL org-wide convention, not a finding. Counts updated from 5🔴 + 2🟡 to 5🔴 + 1🟡.

Apologies for the late surfacing — the prior approve treated "CREATE-side bypass closed → done" but the symmetric UPDATE-side and the architectural fail-open / retroactive-priority / read-side-RBAC gaps were below the waterline. Fix the 5 🔴 (and ideally the remaining 🟡), drop the test that documents the hole, add a fresh test that asserts rejection on the new bypass shape, and this should be the last round.

… deny list + read-side RBAC + retroactive override

Wilco's sixth-pass strict review surfaced five blockers and two
concerns that we missed when earlier rounds focused on the existing-
match attack path. This commit closes them:

B1 ConsentUpdateHandler — the foreach immutability check for
   matchKind/policyMatch sat after the policyMatch=null early-return,
   so a single PATCH on a vanilla WOO record could fabricate a
   policy match and defeat the objection deadline. Split the check
   into a new guardServerControlledFields() that runs ahead of
   guardPolicyPreemptedTransition for every record. Flipped the test
   that documented the hole, added two regression tests covering the
   unmatched-record branch (full payload + policyMatch-only shape).

B2 PolicyMatchService::loadRules — used to fail open on infra errors,
   silently dropping prohibitions when OR was briefly unreachable.
   Split the load so prohibitions throw RuntimeException (fail closed
   — deny-list semantics) and standing consents degrade to an empty
   list (fail open — allow-list optimisation). Asymmetry locked in
   PHPDoc so future edits don't harmonise the two arms.

B3 PolicyRetroactiveService — loadInFlightDocumentRecords used to
   skip every record with a non-null policyMatch, inverting the
   "prohibitions win" rule on the retroactive code path (a later-
   arriving court order would silently fail to override an existing
   standing consent). Narrowed the skip to matchKind=KIND_PROHIBITION
   only. Also made forceResolveToAnonymized write matchKind into the
   save payload for parity with ConsentService::buildConsentData —
   future logic that keys off matchKind on retroactively-resolved
   records now sees the discriminator. Five new happy-path tests in
   PolicyRetroactiveServiceTest cover the force-resolve write, audit-
   timestamp preservation, prohibition-already-locked skip, the
   prohibition-overrides-standing-consent fix above, and the
   OTHER-entity-type wildcard sweep.

B4 PolicyController READ endpoints — @NoAdminRequired with no
   service-level group check exposed the prohibition list (court
   orders, victim-protection cases, GDPR Art.17 requestors) to every
   authenticated user. Added assertProhibitionPermission /
   assertStandingConsentPermission to the corresponding list/get
   methods in PolicyCrudService, mirroring the existing write-side
   gates. RuntimeException -> 403 added to the four READ controller
   methods. publication-consent-process.md updated to document the
   read-side gating.

B5 Newman collection — added a "Policy — Detection-time outcomes"
   folder with seed -> POST /api/consent -> assert -> cleanup
   sequences for the four canonical match outcomes (no-match,
   prohibition-only, standing-consent-only, both-match where
   prohibition wins). Each test gracefully skips when the env hasn't
   wired the admin groups so the gate is informative locally and
   strict in CI.

C1 NOT APPLIED — Wilco's suggestion to drop @NoCSRFRequired from the
   six mutating Policy endpoints assumes the only consumer is the
   same-origin Vue UI (which threads requesttoken via @nextcloud/
   axios). DocuDesk's Newman collection sends only Content-Type:
   application/json, so removing the annotation would make every
   Policy POST/PUT/DELETE return 412 and break the integration-test
   gate. ConsentController extends the same base Controller and
   keeps the annotation; staying consistent with the established
   project pattern. Will respond on the thread with this rationale.

C2 PolicyRetroactiveServiceTest — see B3 (happy-path coverage was
   the same finding as the missing standing-consent-override test).
@rjzondervan
Copy link
Copy Markdown
Member Author

Sixth-pass review — 7511127 (response)

5/5 🔴 blockers addressed, 1/2 🟡 concerns addressed, 1/2 intentionally rejected with rationale below.

🔴 Blockers

B1 — UPDATE-side SERVER_CONTROLLED_FIELDS bypass on policyMatch: null records (discussion_r3289219546)

Split the foreach (['matchKind','policyMatch']) check into a new private guardServerControlledFields() that runs ahead of guardPolicyPreemptedTransition() in updateConsentStatus. The immutability rule now applies to every record regardless of policyMatch state. Picked Wilco's option (b) — separate guards in the service — rather than (a) reorder-inside-the-existing-guard or (c) strip-at-the-controller, because (b) cleanly separates the two semantically distinct rules ("matchKind/policyMatch are always server-controlled" vs "consentStatus is bound only when pre-empted"). Anchored:

  • testUnmatchedRecordRejectsServerControlledFieldInjection — full attack payload ({policyMatch, matchKind, consentStatus, publicationDecision, objectionDeadline: null} on a vanilla record) → rejection.
  • testUnmatchedRecordRejectsPolicyMatchInjection — single-field shape, same outcome.
  • testNoPolicyMatchAllowsArbitraryTransition (the prior test that documented the hole as intended) renamed to testNoPolicyMatchAllowsConsentStatusTransition and reframed to assert only the consentStatus/publicationDecision transition is allowed — it now goes through guardPolicyPreemptedTransition rather than the new server-controlled guard.

B2 — loadRules fails OPEN on infrastructure errors (discussion_r3289222236)

Split the load into two arms with different fail semantics: loadProhibitions() throws RuntimeException (fail closed — deny-list privacy guarantee), loadStandingConsents() degrades to [] (fail open — allow-list optimisation). The asymmetry is pinned in the loadRules PHPDoc so future "harmonisation" edits don't silently regress the prohibition arm. The downstream consent workflow surfaces a 5xx on prohibition-load failure and document publication stalls until OR recovers — preferable to leaking redacted names per the threat model.

B3 — Retroactive sweep cannot override standing consents (discussion_r3289224924)

Two coupled changes:

  1. loadInFlightDocumentRecords predicate changed from policyMatch !== null (skip-anything-already-matched) to matchKind === PolicyMatchService::KIND_PROHIBITION (skip-only-prohibition-locked). Standing-consent-matched records now flow through to be force-resolved when a later prohibition matches, restoring the canonical "prohibitions win" rule on the retroactive code path.
  2. forceResolveToAnonymized now writes matchKind: 'prohibition' into the save payload, matching ConsentService::buildConsentData's detection-time symmetry. Resolves the latent bug where retroactively-prohibited records carried policyMatch: <uuid> but matchKind: '' and would be invisible to any future matchKind === 'prohibition' predicate.

B4 — Policy READ endpoints expose PII to every authenticated user (discussion_r3289227460)

Read-side asserters added to PolicyCrudService::listProhibitions (action: 'list'), listStandingConsents (action: 'list'), getProhibition (action: 'get'), getStandingConsent (action: 'get'). They reuse the existing assertProhibitionPermission / assertStandingConsentPermission group-membership checks (docudesk-policy-admins / docudesk-standing-consent-admins). PolicyController now catches RuntimeException → 403 on the four READ controller methods, mirroring the write-side pattern. docs/features/publication-consent-process.md updated to document the read-side gating.

B5 — Newman collection doesn't exercise the 4 detection-time outcomes (discussion_r3289243080)

Added Policy — Detection-time outcomes folder with 12 items covering the four outcomes per spec §3:

Outcome Seed → Exercise → Cleanup Assertions
no-match (WOO default) POST /api/consent only matchKind null, policyMatch null, consentStatus=pending, publicationDecision=pending, notificationStatus=pending
prohibition-only seed prohibition → POST consent → DELETE seed matchKind=prohibition, policyMatch=seeded UUID, consentStatus=anonymized, publicationDecision=anonymize, notificationStatus=skipped
standing-consent-only seed standing consent → POST consent → DELETE seed matchKind=standing_consent, consentStatus=consent_given, publicationDecision=publish_with_consent, notificationStatus=skipped
both-match (prohibition wins) seed BOTH → POST consent → DELETE both matchKind=prohibition, policyMatch=prohibition UUID (not the standing-consent UUID), consentStatus=anonymized

Each test skips gracefully when the seed POST returns 403/500 (env hasn't wired the admin groups). Cleanup steps are idempotent — they accept 200/204/404.

🟡 Concerns

C2 — PolicyRetroactiveServiceTest has zero happy-path coverage (discussion_r3289264312)

Five new tests added:

  • testProhibitionForceResolvesMatchingInFlightRecord — full save payload assertion incl. the new matchKind write from B3.
  • testProhibitionPreservesAuditTimestampsnotificationSentAt + objectionReceivedAt survive force-resolve.
  • testProhibitionSkipsAlreadyProhibitedRecords — sweep is idempotent across re-runs.
  • testProhibitionOverridesStandingConsent — regression lock for the B3 retroactive override.
  • testProhibitionResolvesAcrossEntityTypeOTHER — wildcard sweep across PERSON + ORGANIZATION rows.

Mocking the OR ObjectService required a local FakeOpenRegisterObjectService stub class in the test file (the real class isn't autoloadable from docudesk's test context — same reason the existing tests skip the OR path). The stub mirrors findAll / saveObject signatures so PHPUnit's createMock honours the named-parameter call sites in production code.

C1 — Removing @NoCSRFRequired from mutating Policy endpoints (discussion_r3289258315) — NOT APPLIED

The premise that the Vue UI is the only consumer doesn't hold in DocuDesk:

  1. PolicyController extends Controller (plain, not OCSController). On a plain Controller, every state-changing method requires requesttoken unless @NoCSRFRequired is set.
  2. The Newman collection sends only Content-Type: application/json — no requesttoken, no OCS-APIREQUEST: true. Removing the annotation would make every Policy POST/PUT/DELETE return 412 Precondition Failed and break the integration-test gate (including the new B5 folder above which seeds via POST /api/policy/prohibitions).
  3. ConsentController (same extends Controller base, comparable surface) keeps @NoCSRFRequired on every mutating method — this is the established project pattern.

The architecturally-correct path forward is to convert PolicyController to OCSController (which doesn't enforce CSRF; accepts OCS-APIREQUEST: true instead). That's a bigger refactor and inconsistent with ConsentController. Happy to do that work in a separate PR if you'd prefer that direction — but reverting the annotation removal on this PR's scope was the lower-risk call.

Status

  • PHPUnit: 258/258 (540 assertions) green locally.
  • PHPCS: 0 errors. Trailing warnings are pre-existing @spec PHPDoc nudges, unchanged by this commit.
  • Touched: 9 files, +916 / −53.

Anything left I can pull through on the next pass — or shall I open the follow-up for the OCSController migration?

@github-actions
Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/docudesk @ 0df074e

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
composer ✅ 108/108
npm ✅ 575/575
PHPUnit
Newman ⏭️
Playwright ⏭️

Coverage: 0% (0/10 statements)


Quality workflow — 2026-05-22 15:39 UTC

Download the full PDF report from the workflow artifacts.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants