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.
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.
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 —
|
WilcoLouwerse
left a comment
There was a problem hiding this comment.
Re-review — d12bb3c (Strict mode, 4th pass)
Resolved correctly (3 of 3 prior findings):
- 🔴 Thread B (matchKind dead code) —
buildConsentDatanow writesmatchKindin all four return branches; the guard reads$existing[matchKind]and the literalstanding_consentmatchesPolicyMatchService::KIND_STANDING_CONSENT. End-to-end carve-out flow is sound. - 🟡 Pre-flight schema version 7→8 —
docs/testing/wave-1-smoke-tests.mdupdated. - 🟡 matchKind enum lacks null — schema now
"type": ["string", "null"], enum includesnull. WOO-default + legacy null-symmetry restored.
New blocker introduced by this commit:
🔴 2-step prohibition lock bypass via client-controlled matchKind mutation — making matchKind a persisted field without rejecting client-supplied mutations of it lets any authenticated user (ConsentController::update is @NoAdminRequired) defeat any of the four prohibition seeds in two PUTs: first {matchKind: "standing_consent"} slips past the both-fields-false early-return and array_merge corrupts the field, then {publicationDecision: "publish"} fires the now-applicable standing-consent carve-out and the lock is gone. policyMatch is similarly mutable to another non-empty UUID. Suggested fix in the inline comment: reject any client-supplied change to matchKind or policyMatch inside guardPolicyPreemptedTransition, with two unit tests locking the regression shut.
🟢 PHPUnit job is skipped on this head SHA per the check-runs API — the new reflection-based tests in ConsentUpdateHandlerTest provide no CI regression protection. Same observation as the prior pass; informational. Vue ESLint, stylelint, Security composer failures remain pre-existing on development.
Thread housekeeping: Threads on the three resolved findings (matchKind dead code, pre-flight version, enum-lacks-null) plus the superseded over-reach thread from the 2nd pass can be resolved. The new blocker above is the only active item.
…tch, close 2-step prohibition bypass (PR #147 fourth-pass) Closes the new blocker from Wilco's 4th-pass review (commit `d12bb3c`). The third-pass fix persisted `matchKind` in `ConsentService::buildConsentData` to unbreak the standing-consent carve-out, but introduced a fresh attack surface: making `matchKind` a persisted field that the update endpoint didn't reject mutations of let any authenticated user defeat any prohibition seed in two PUTs. **🔴 Blocker — 2-step prohibition lock bypass:** ``` PUT /api/consents/{id} body: { "matchKind": "standing_consent" } → guardPolicyPreemptedTransition: - $existingMatch non-null → skip first early return - no policyMatch key in $data → skip clear-guard - consentStatusChanged = false, publicationDecisionChanged = false - early-return ✓ → array_merge writes matchKind="standing_consent" into the record 💥 PUT /api/consents/{id} body: { "publicationDecision": "publish" } → guard reads $existing['matchKind'] = "standing_consent" (corrupted) → standingConsentOverride fires, returns early ✓ → array_merge writes publicationDecision="publish" → prohibition lock defeated; record will publish without anonymisation ``` `ConsentController::update` is `@NoAdminRequired`, so any authenticated Nextcloud user could perform this — defeating the four prohibition seeds (court order, minor protection, undercover officer, AP categorical exemption) the entire prohibition layer was designed to block. `policyMatch` was similarly mutable via the same path. The existing clearing-only guard (rejected `policyMatch: null`) only covered one shape of the bypass; UUID-swap to a different non-empty value was wide open. **Fix per Wilco's exact suggestion** — broaden the `policyMatch`-clearing-only guard to a generic server-controlled-fields-immutable check covering both `matchKind` and `policyMatch`, positioned BEFORE the both-fields-false early-return so a payload with only `matchKind` can't slip through: ```php foreach (['matchKind', 'policyMatch'] as $serverOnlyField) { if (array_key_exists($serverOnlyField, $data) === true) { $newValue = $data[$serverOnlyField]; $existingValue = ($existing[$serverOnlyField] ?? null); if ($newValue !== $existingValue) { throw new InvalidArgumentException( sprintf('%s is server-controlled and cannot be modified via update (existing=%s, attempted=%s).', ...) ); } } } ``` The check rejects mutations to a DIFFERENT value but allows equal-value re-sends (idempotent clients that PUT the full record state on every update don't break). Same-value re-sends are locked by the new `testEqualServerControlledValuesAreAllowed` regression test. **Tests added** (3 new, lock the regression shut per Wilco's suggestion plus one idempotency anchor): 1. `testProhibitionMatchKindMutationRejected` — step 1 of the bypass attempt: `{matchKind: "standing_consent"}` on a prohibition record. Expects `InvalidArgumentException` with "matchKind is server-controlled". 2. `testProhibitionPolicyMatchUuidSwapRejected` — UUID-swap shape of the same bypass. Expects `InvalidArgumentException` with "policyMatch is server-controlled". 3. `testEqualServerControlledValuesAreAllowed` — idempotency anchor: a PUT carrying equal values for both server-controlled fields MUST NOT trip the guard. **Verification:** - `vendor/bin/phpunit --filter ConsentUpdateHandler` — 9 tests / 13 assertions all green in the `master-nextcloud-1` container (up from 6/8 — added 3, no regressions). - PHPCS clean on both touched files (cosmetic equals-alignment fixes from phpcbf included). Refs: #147, Wilco's fourth-pass review `4337447966`, inline comment `3281603312`
Fourth-pass review addressed —
|
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 14:34 UTC
Download the full PDF report from the workflow artifacts.
| policyMatch: $policyMatch | ||
| ); | ||
|
|
||
| $consentData = array_merge($consentData, $extra); |
There was a problem hiding this comment.
🔴 Blocker — Same bypass class exists on the CREATE endpoint ($extra overrides server-computed policy fields)
The fourth-pass 2-step UPDATE bypass is correctly closed — the new foreach (['matchKind', 'policyMatch'] as $serverOnlyField) guard in ConsentUpdateHandler::guardPolicyPreemptedTransition sits before the both-fields-false early-return, the regression tests lock the shape, well done. But the symmetric bypass on the CREATE endpoint is wide open.
This line does array_merge($consentData, $extra) — $extra is the second argument, so caller-supplied values override the server-computed buildConsentData output. ConsentCrudService::createFromRequest only strips documentId, entityType, entityText, _route, _method from the request body before forwarding everything else as $extra. The downstream validatePublicationConsentData checks scope constraints + verifies the policyMatch UUID resolves to a real referent — but does not cross-check matchKind against the referent kind, nor that consentStatus/publicationDecision/notificationStatus/objectionDeadline are consistent with the policy outcome. ConsentController::create is @NoAdminRequired + @NoCSRFRequired.
Exploit A — fabricate standing-consent to bypass the WOO objection deadline (privacy escalation):
GET /apps/docudesk/api/policy/standing-consents
→ returns 4 seed UUIDs (publicly readable to authenticated users)
POST /apps/docudesk/api/consents
body: {
"documentId": "<victim-doc-id>",
"entityType": "PERSON",
"entityText": "victim-name",
"policyMatch": "<real-standing-consent-uuid>",
"matchKind": "standing_consent",
"consentStatus": "consent_given",
"publicationDecision":"publish_with_consent",
"notificationStatus": "skipped",
"objectionDeadline": null
}
→ buildConsentData() returns WOO defaults (entity didn't actually match a policy)
→ array_merge($consentData, $extra) overwrites every field with attacker values
→ validatePublicationConsentData passes (scope=document, has documentId,
policyMatch UUID resolves to a valid standing-consent referent)
→ Record persists with consentStatus=consent_given, no objection deadline
→ The entity publishes unredacted, bypassing the WOO objection window that
should have protected it 💥
Exploit B — DOS publication of any document by stamping a fake prohibition: swap the $extra values to matchKind: "prohibition" + policyMatch: <real-prohibition-uuid> + consentStatus: anonymized + publicationDecision: anonymize. The new fourth-pass UPDATE guard now makes the record immutable from the legitimate owner's side, so this is a persistent lock rather than a one-shot edit.
This is the same security regression class as the fourth-pass UPDATE bypass — symmetrical, applied at the create path that doesn't go through guardPolicyPreemptedTransition at all. The threat model that motivated the fourth-pass fix applies equally here.
Suggested fix — strip server-controlled fields from $extra before the merge. The cleanest location is upstream in ConsentCrudService::createFromRequest:
public function createFromRequest(array $data, string $register, string $schema): array
{
$knownFields = ['documentId', 'entityType', 'entityText'];
$extra = array_diff_key($data, array_flip($knownFields));
unset($extra['_route'], $extra['_method']);
// Server-controlled fields — populated by ConsentService::buildConsentData
// from the policy match result. Caller cannot inject these without
// forging the policy outcome (see PR #147 fifth-pass review).
foreach (['policyMatch', 'matchKind', 'consentStatus', 'publicationDecision',
'notificationStatus', 'objectionDeadline'] as $serverOnlyField) {
unset($extra[$serverOnlyField]);
}
return $this->consentService->createConsentRequest(
$data['documentId'], $data['entityType'], $data['entityText'],
$register, $schema, $extra
);
}Alternatively, gate at the createConsentRequest level (defense in depth — $extra is a public API surface for the service) — pick whichever you prefer. The unset list could also throw InvalidArgumentException instead of silently stripping, matching the UPDATE guard's behaviour for symmetric error reporting.
Regression-test stub:
public function testCreateRejectsInjectedPolicyFields(): void
{
// arrange: POST body carries server-controlled fields the attacker shouldn't be able to set
$data = [
'documentId' => 'doc-1',
'entityType' => 'PERSON',
'entityText' => 'Jan Janssen',
'policyMatch' => 'attacker-supplied-uuid',
'matchKind' => 'standing_consent',
'consentStatus' => 'consent_given',
'publicationDecision' => 'publish_with_consent',
];
$this->policyMatcher->method('match')->willReturn(null); // no real match
// act
$result = $this->service->createConsentRequest(
documentId: 'doc-1', entityType: 'PERSON', entityText: 'Jan Janssen',
register: 'consent', schema: 'publicationConsent',
extra: ['policyMatch' => 'attacker-supplied-uuid', 'matchKind' => 'standing_consent',
'consentStatus' => 'consent_given', 'publicationDecision' => 'publish_with_consent']
);
// assert: the saved record carries the server-computed WOO defaults, not the injected values
self::assertNull($result['policyMatch']);
self::assertNull($result['matchKind']);
self::assertSame('pending', $result['consentStatus']);
self::assertSame('pending', $result['publicationDecision']);
}
🟢 Minor — testing belts-and-braces for the new immutability guardThe new
Neither is a security issue today — flagging only because of the policy-fields class of bug (this PR's recurring theme). |
WilcoLouwerse
left a comment
There was a problem hiding this comment.
Fifth-pass review — 1d408cb. The fourth-pass 2-step UPDATE bypass is correctly closed (immutability guard at ConsentUpdateHandler.php:190-205, three regression tests, all on the right side of the early-return — clean fix). Prior thread resolved.
One new 🔴 found in the same security regression class: the CREATE endpoint has the symmetric bypass — $extra overrides server-computed policyMatch / matchKind / status-trio via array_merge in ConsentService.php:130. Any authenticated user can fabricate a standing-consent on a victim document and bypass the WOO objection deadline, or DOS publication via a fake prohibition (now persistent thanks to the new immutability guard). Inline comment has the full exploit walk-through + suggested fix (strip in ConsentCrudService::createFromRequest) + regression-test stub.
Also flagged one 🟢 top-level comment about testing belts-and-braces on the new immutability guard. Not blocking.
Fix the create-time injection and we're done.
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.