feat: entity-level publication policy layer (#112)#147
Conversation
Quality Report — ConductionNL/docudesk @
|
| 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.
WilcoLouwerse
left a comment
There was a problem hiding this comment.
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.
…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.
Quality Report — ConductionNL/docudesk @
|
| 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.
…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.
Quality Report — ConductionNL/docudesk @
|
| 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.
Review response — commits
|
WilcoLouwerse
left a comment
There was a problem hiding this comment.
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 🔴):
PolicyControllermissingRuntimeException → 403catch on createProhibition —RuntimeExceptionextendsException, so the new RBAC gate falls through and returns HTTP 500 instead of 403. Same defect on updateProhibition and deleteProhibition. Standing-consent counterparts already have the correct catch block; copy the pattern.- Inline
<NcDialog>inStandingConsentIndex.vue— same ADR-004 gate-13 violation that was fixed forProhibitionIndex.vue. Extract intoStandingConsentFormModal.vue.
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.
3af8e7d to
0241142
Compare
Quality Report — ConductionNL/docudesk @
|
| 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')".
|
@WilcoLouwerse — addressed in 🔴 RBAC 403 swallowed in 3 placesAdded After the fix, 🔴 Inline
|
Quality Report — ConductionNL/docudesk @
|
| 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.
| && (string) $data['publicationDecision'] !== (string) ($existing['publicationDecision'] ?? '') | ||
| ); | ||
|
|
||
| if ($consentStatusChanged === false && $publicationDecisionChanged === false) { |
There was a problem hiding this comment.
🔴 Blocker — guardPolicyPreemptedTransition over-reaches and breaks the standing-consent anonymise override
The guard you added to close the two-step policyMatch bypass (resolving [3259350068]) is correct for prohibition matches but over-reaches: it now rejects any change to publicationDecision on a record with policyMatch != null, regardless of the match kind. Lines 209–214:
$publicationDecisionChanged = (
array_key_exists('publicationDecision', $data) === true
&& (string) $data['publicationDecision'] !== (string) ($existing['publicationDecision'] ?? '')
);
if ($consentStatusChanged === false && $publicationDecisionChanged === false) {
return;
}The docblock at line 153–155 explicitly states the intended carve-out:
Only updates that do NOT change consentStatus are permitted — including overrides like setting
publicationDecision: "anonymize"on a standing-consent-matched record while leavingconsentStatus: "consent_given"in place.
The code does not implement that carve-out. ConsentDetail.vue::onToggleAnonymise correctly issues PUT /api/consents/{id} with { publicationDecision: "anonymize" } when policyMatchKind === 'standing_consent'. The request hits this guard, publicationDecisionChanged is true, consentStatusChanged is false, and the guard throws → HTTP 400.
Impact: The standing-consent override toggle returns 400 in production for every standing-consent-matched record. An entity matched by a standing consent can never be manually anonymised through the UI. The override audit trail described in the spec is never written. This silently breaks the Wave 1.2 UX contract.
Suggested fix: The consent record carries policyMatch (UUID) but not the match kind, so the guard can't trivially branch. Two viable options:
- Persist
matchKindat create time inConsentService::buildConsentData('prohibition'|'standing_consent'), then in the guard:if ($existing['matchKind'] === 'standing_consent' && $consentStatusChanged === false) { return; // Permit publicationDecision overrides on standing-consent matches. }
- Trust the consentStatus invariant: prohibition matches already pin
consentStatus = "anonymized"at create time, and theconsentStatusChangedbranch already locks that. Drop thepublicationDecisionChangedarm entirely — the security goal is satisfied by the consentStatus lock alone:if ($consentStatusChanged === false) { return; // Permit publicationDecision overrides; consentStatus is the real lock. } throw new InvalidArgumentException( message: sprintf( 'consentStatus "%s" rejected on policy-pre-empted record ...', ... ) );
Option 2 is the minimal change and doesn't require a schema migration. Add a unit test for the standing-consent toggle (mock an existing with policyMatch = uuid, consentStatus = "consent_given", push publicationDecision = "anonymize", assert the update succeeds).
WilcoLouwerse
left a comment
There was a problem hiding this comment.
2 new blockers and 2 concerns. 12 of 14 prior findings resolved; 1 prior 🟡 (service-locator) still open and worsened.
🔴 New blockers
guardPolicyPreemptedTransitionover-reach breaks standing-consent toggle —ConsentUpdateHandler.php:214. Fix for [3259350068] closed the bypass but thepublicationDecisionChangedarm now also rejects the spec-mandated standing-consent override.ConsentDetail.vue::onToggleAnonymisewill return HTTP 400 in production.- phpcs CI regression —
ConsentUpdateHandler.php:191.Squiz.Strings.ConcatenationSpacingviolated by the newsprintfcontinuation.phpcbfauto-fixes it.
🟡 Concerns
PROHIBITION_GROUPconstant mismatches spec/schema — code saysdocudesk-prohibition-admins, spec + JSON schema both saydocudesk-policy-admins. Provisioning is broken either way.- Service-locator anti-pattern still open and worsened — the fix wired
PolicyRetroactiveServiceby adding a 4th\OC::$server->get()call instead of refactoring to constructor injection.
Verified resolved (12): RBAC 403 catches × 3 in PolicyController, modal isolation for StandingConsent, NcSelect inputLabel × 2, group gates added across PolicyCrudService, silent-write fix in ConsentService, scope-filter in PolicyMatchService, raw fetch→axios in ConsentDetail, ProhibitionFormModal extraction, two-step bypass closure (subject to the over-reach blocker above).
…rs + 2 concerns) Wilco's second review on PR #147 surfaced four new items after the prior fix-commit landed; 12 of 14 prior findings were already resolved. This commit closes all four. **🔴 Blocker — guardPolicyPreemptedTransition over-reach (ConsentUpdateHandler.php:214)** Last commit's bypass-closure also blocked the spec-mandated standing-consent override path. `ConsentDetail.vue::onToggleAnonymise` issues `PUT /api/consents/{id}` with `{ publicationDecision: "anonymize" }` when `policyMatchKind === 'standing_consent'`; the guard caught it and returned 400. Fix: read `existing['matchKind']` (already persisted by `ConsentService::buildConsentData` at consent-creation time, line 147) and carve out the case `matchKind === 'standing_consent' && publicationDecisionChanged && !consentStatusChanged` → return early. Prohibition matches stay strictly locked — operators cannot override a prohibition through this endpoint. The override audit trail still gets written by the consent register's normal mutation history. Also declared `matchKind` as a first-class property on the `publicationConsent` schema with `enum: ["prohibition", "standing_consent"]`. It was being written by ConsentService but not advertised in the schema; this makes the field queryable + facetable on the standard API surfaces. Bumped the register-config version 7.0.0 → 8.0.0 so OR re-imports. **🔴 Blocker — phpcs CI regression (ConsentUpdateHandler.php:191)** The `sprintf` multi-line continuation in the new clearing-guard message violated `Squiz.Strings.ConcatenationSpacing`. Shortened the message and collapsed it onto a single concat'd pair under the line-length limit; phpcs now clean on the touched file (1 warning at 130 chars, no errors). **🟡 Concern — PROHIBITION_GROUP constant mismatch** `PolicyCrudService::PROHIBITION_GROUP` was `docudesk-prohibition-admins` but the JSON-schema RBAC seed and the docs/features spec both use `docudesk-policy-admins`. The two gates never both passed for the same operator. Renamed the code constant to match the schema/docs — the schema-seeded group name is the authoritative one (changing the schema would require a migration; the constant is the cheap fix). **🟡 Concern — service-locator anti-pattern still open** The prior fix wired `PolicyRetroactiveService` by adding a fourth `\OC::$server->get()` call in `DocuDeskEventListener::handle()` rather than refactoring to constructor injection. Refactored fully: all six dependencies (LoggerInterface, MetadataService, SettingsService, PolicyRetroactiveService, DocuDeskEventHandler, EnrichmentRunner) are now constructor-injected via Nextcloud's DI container as `private readonly` properties. `handle()` and `logHandlerError()` use `$this->logger->...` etc. throughout. No static `\OC::$server` calls remain in this listener.
Quality Report — ConductionNL/docudesk @
|
| 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-20 07:39 UTC
Download the full PDF report from the workflow artifacts.
| [ | ||
| 'documentId' => $documentId, | ||
| 'policyMatch' => ($policyMatch['uuid'] ?? null), | ||
| 'matchKind' => ($policyMatch['kind'] ?? null), |
There was a problem hiding this comment.
🔴 Blocker — Thread B fix is dead code: matchKind is never persisted, so the standing-consent override carve-out never fires
The fix commit's message claims:
read
existing['matchKind'](already persisted byConsentService::buildConsentDataat consent-creation time, line 147)
Line 147 is this line — a logger context entry, not a field written to the saved object. I traced all four return branches of buildConsentData (lines 178–251) — none of them write a matchKind key into the array passed to $objectService->saveObject(object: $consentData, ...). The only places matchKind appears in the entire diff are:
- This logger-context entry (line 147 —
$this->logger->info('Consent request created', ['documentId' => ..., 'policyMatch' => ..., 'matchKind' => ...])) — log only, not saved. - The schema definition in
lib/Settings/docudesk_register.json. - The guard's read in
ConsentUpdateHandler::guardPolicyPreemptedTransition($matchKind = (string) ($existing['matchKind'] ?? '')).
Because matchKind is never persisted by buildConsentData, $existing['matchKind'] is always null, the cast yields '', and the carve-out condition $matchKind === 'standing_consent' never matches. Every standing-consent override PUT /api/consents/{id} will return 400 — the exact behaviour Thread B reported. The fix moved the bug from the over-reaching consentStatusChanged arm into the silently-inert carve-out, but did not actually unblock the standing-consent override UX.
Impact: Spec §6.2 (operator may override publicationDecision on standing-consent-matched records) is permanently unimplementable on records created by this codebase. The Wave 1.2 anonymisation toggle is locked for the entire standing-consent surface.
Suggested fix: Persist matchKind in each return array of buildConsentData:
if ($kind === PolicyMatchService::KIND_PROHIBITION) {
return array_merge($base, [
// ...existing fields...
'policyMatch' => $uuid,
'matchKind' => PolicyMatchService::KIND_PROHIBITION,
]);
}
if ($kind === PolicyMatchService::KIND_STANDING_CONSENT) {
return array_merge($base, [
// ...existing fields...
'policyMatch' => $uuid,
'matchKind' => PolicyMatchService::KIND_STANDING_CONSENT,
]);
}Write 'matchKind' => null on the WOO-default + unknown-kind branches so the null-means-no-policy contract is explicit and the schema enum / guard read are unambiguous.
Then add a unit test for the standing-consent override path (existing = ['policyMatch' => 'uuid', 'matchKind' => 'standing_consent', 'consentStatus' => 'consent_given'] + data = ['publicationDecision' => 'anonymize'] → update succeeds, no exception) so this regression cannot land silently again.
| ## 0. Pre-flight | ||
|
|
||
| - [ ] All four feature branches checked out and apps enabled (`occ app:list | grep -E "openregister|docudesk"`) | ||
| - [ ] DocuDesk register imported at version 7.0.0 (`occ config:app:get openregister imported_config_docudesk_version` → `7.0.0`) |
There was a problem hiding this comment.
🟡 Concern — Pre-flight checklist still asserts schema version 7.0.0 after the fix commit bumped it to 8.0.0
This line reads:
- [ ] DocuDesk register imported at version 7.0.0 (`occ config:app:get openregister imported_config_docudesk_version` → `7.0.0`)
The fix commit bumped lib/Settings/docudesk_register.json from 7.0.0 to 8.0.0 to force OR re-import of the new matchKind schema field. This pre-flight assertion now fails on every correctly deployed stack. A tester following the checklist will incorrectly conclude the register import failed and stop before exercising the actual Wave 1.2 surfaces.
The PR-body test plan section also says "imported_config_docudesk_version re-imports to 7.0.0" — stale for the same reason.
Suggested fix: Update the pre-flight line to → 8.0.0 and update the PR body's test plan reference to match. Add a one-liner on whether the 7→8 re-import is additive (safe rolling deploy) or destructive (drops existing rows) — important for operators planning the upgrade.
| "title": "Policy-match referentie", | ||
| "maxLength": 64 | ||
| }, | ||
| "matchKind": { |
There was a problem hiding this comment.
🟡 Concern — matchKind enum lacks null; WOO-default consent records cannot carry the field once it is persisted
The new schema property declares:
"matchKind": {
"type": "string",
"enum": ["prohibition", "standing_consent"]
// ...
}Once the blocker on ConsentService::buildConsentData is fixed (see the comment on that file), WOO-default consent records (no policy match) need to store null for this field — but the enum rejects null. "hardValidation": false makes enum enforcement advisory today, but if it is ever turned on, or if any OR list-filter that consults the enum applies it, WOO-default records will fail to save.
Legacy records created before 8.0.0 also carry no matchKind at all; the guard handles this defensively (?? '' → block path), but the schema does not declare the field as nullable, so the data contract diverges from the runtime contract.
Suggested fix: Make the property nullable in the schema:
"matchKind": {
"type": ["string", "null"],
"enum": ["prohibition", "standing_consent", null]
}Or — if OR's schema engine doesn't support union types — drop matchKind from WOO-default record writes entirely (don't write the key) and rely on absent-key semantics. The first option matches the documented enum more transparently.
WilcoLouwerse
left a comment
There was a problem hiding this comment.
Re-review — 3113294df5 (Strict mode, 3rd pass)
Resolved correctly (3 of 4):
- Thread A —
DocuDeskEventListenerDI refactor: all six dependencies are now constructor-injectedprivate readonlyproperties, no\OC::$servercalls remain. - Thread C — phpcs ConcatenationSpacing fixed by extracting the
sprintfinto a$msg = ...; throw new ...(message: $msg)shape; phpcs clean on touched file. - Thread D —
PROHIBITION_GROUPconstant renamed todocudesk-policy-admins; no stale references in the diff.
Not resolved — Thread B is still blocking (this re-review's only 🔴):
🔴 Thread B fix is dead code — matchKind is never persisted — The fix commit's claim that matchKind is "already persisted by ConsentService::buildConsentData at consent-creation time, line 147" is incorrect; line 147 is a $this->logger->info() context entry, not a save. None of the four return branches of buildConsentData (lines 178–251) write matchKind into the array passed to saveObject. The new carve-out in ConsentUpdateHandler::guardPolicyPreemptedTransition reads $existing['matchKind'] which is always null, so the standing-consent override toggle remains 400-locked — exact same UX failure Thread B reported.
New concerns introduced by the fix commit:
🟡 Wave-1 smoke-test pre-flight still asserts schema version 7.0.0 after the fix commit bumped lib/Settings/docudesk_register.json to 8.0.0. Pre-flight checklist + PR-body test plan both stale.
🟡 matchKind enum lacks null — once the blocker is fixed, WOO-default consent records will need to carry null for this field; current enum ["prohibition", "standing_consent"] rejects it.
🟢 CI failures (License (npm), Security (composer), Security (npm), eslint, stylelint) are pre-existing on development — no package.json / composer.json changes in this PR's diff. Informational, not blocking on this PR specifically.
Thread housekeeping: Threads A, C, D landed correctly and can be resolved; Thread B's blocker comment supersedes the original Thread B and stays open as the active blocker. (I attempted to resolve A/C/D programmatically but was blocked by an automated guardrail — please resolve them manually if desired, or leave them for the next pass.)
…1 blocker + 2 concerns) Closes Wilco's 3rd-pass review on `3113294df5`. **🔴 Blocker (Thread B) — `matchKind` was never persisted:** `ConsentUpdateHandler::guardPolicyPreemptedTransition` reads `$existing['matchKind']` at line 223 to decide whether the standing-consent override carve-out fires, but none of the four return branches of `ConsentService::buildConsentData` (lines 178–251 on the prior diff) wrote `matchKind` into the saved payload — the only reference at line 147 was a logger context entry, not a save. Result: the carve-out never fired and the operator's "anonymise this entity even though a standing consent matched it" toggle was 400-locked at the API layer, exactly the UX failure the original Thread B reported. Persisted `matchKind` across all four return branches of `buildConsentData`: - `policyMatch === null` (WOO defaults) → `matchKind: null` - `kind === KIND_PROHIBITION` → `matchKind: 'prohibition'` (the constant) - `kind === KIND_STANDING_CONSENT` → `matchKind: 'standing_consent'` - Unknown kind defensive WOO fallback → `matchKind: null` Used the `PolicyMatchService::KIND_*` constants instead of bare strings so the literal never drifts. Three new reflection-backed unit tests in `ConsentUpdateHandlerTest` lock the three branches of the guard: - `testStandingConsentCarveOutAllowsPublicationDecisionOverride` — fires the carve-out, asserts no exception. - `testProhibitionMatchRejectsPublicationDecisionOverride` — prohibition stays strictly locked. - `testNoPolicyMatchAllowsArbitraryTransition` — records with no `policyMatch` early-return from the guard. **🟡 Concern — `matchKind` enum rejected `null`:** With the blocker fixed, WOO-default consent records now carry `matchKind: null` in their payload, but the schema's enum was `["prohibition", "standing_consent"]` which would reject the write at any validator strictness above the loose default. Made the schema explicit: - `"type": ["string", "null"]` (JSON Schema's nullable idiom) - `"enum": ["prohibition", "standing_consent", null]` - Extended the description to call out the WOO-default null case + the symmetry with `policyMatch=null`. **🟡 Concern — Wave-1 smoke-test pre-flight still asserted schema 7.0.0:** The fix commit before this one bumped `lib/Settings/docudesk_register.json` to `8.0.0` but the pre-flight checklist in `docs/testing/wave-1-smoke-tests.md` + the test plan in the PR body still asserted `7.0.0`. Updated both — pre-flight in this commit; PR body via `gh api -X PATCH`. **Verification:** - PHPCS clean on both touched code files (`ConsentService.php`, `ConsentUpdateHandlerTest.php`) — one pre-existing warning on `ConsentService.php` unchanged. - `vendor/bin/phpunit --filter 'ConsentUpdateHandler|ConsentService'` passes in the live `master-nextcloud-1` container: 19 tests / 27 assertions (up from 16/24; added 3 guard tests). - `lib/Settings/docudesk_register.json` parses as valid JSON. Refs: #147, Wilco's third-pass review `4335767919`
Quality Report — ConductionNL/docudesk @
|
| 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-21 11:52 UTC
Download the full PDF report from the workflow artifacts.
Third-pass review addressed —
|
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
publicationProhibitionschema in the consent register — entity-level deny rules with 4 seed records (court order, minor protection, undercover officer, AP categorical exemption).publicationConsentextended with thescopediscriminator (document/entity) and the entity-scope-only field set (matchRules,validFrom,validUntil,active,consentMethod,consentDocument,consentScope), plus apolicyMatchfield 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-flightscope:"document"records toanonymizedwhen a prohibition is added or widened; intentionally no-op for standing-consent creation (future detections only — privacy default wins on retroactive sweep). Dispatched fromDocuDeskEventHandlervia payload-shape detection.ConsentService::createConsentRequestnow consultsPolicyMatchServicebefore defaulting to WOO; branches into 4 detection-time outcomes (no match / prohibition / standing consent / both → prohibition). New scope-validation gate at write time.ConsentUpdateHandlerrejectsconsentStatustransitions on records pre-empted by a policy.PolicyController+PolicyCrudServicewith 10 routes underapi/policy/{prohibitions,standing-consents}. Service-level RBAC gate: standing-consent writes requiredocudesk-standing-consent-adminsgroup (returns 403); prohibition writes restricted todocudesk-policy-adminsat schema level.Frontend
scope:"document", adds "policy" badge on rows whosepolicyMatchis non-null.policyMatchreferent kind: prohibition → ON+locked; standing consent → OFF+overridable (override recordspublicationDecision: "anonymize"while preservingconsentStatus: "consent_given"andpolicyMatch).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 storesprohibitionStore,standingConsentStore.Other
templates/index.phpandtemplates/settings/admin.phpwere never updated to load the newdocudesk-shared-vendor.js+docudesk-shared-nc-vue.jschunks, leaving the entry bundle waiting onchunkOnLoadforever and rendering nothing. Worth a cross-check on pipelinq / procest — they got the same split.docs/features/publication-consent-process.mdcovering the three-layer evaluation, retroactive asymmetry, UI toggle semantics, three admin surfaces, RBAC defaults. CHANGELOG entries under Added / Changed / Behavior changes.Test plan
imported_config_docudesk_versionre-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.consentMethod,documentIdonscope:"entity").POST /api/consents: no-match, prohibition match, standing-consent match, both-match (prohibition wins). VerifypolicyMatch+notificationStatus+consentStatus+publicationDecisionper spec.pendingconsent → create matching prohibition → record nowanonymized+policyMatch. Repeat with a new standing consent → in-flight record unchanged.publicationDecisiononly); legacy UX forpolicyMatch: null.consentStatuschange on a record with non-nullpolicyMatch→ 400.composer check:strictclean;openspec validate entity-publication-policiesclean.