Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions lib/AppInfo/Registrar/ImmutabilityListenerRegistrar.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php

/**
* Procest immutability listener registrar.
*
* The pre-persist immutability guards: REQ-SUB-007 (a bewijsstuk linked to a
* vaststelling is frozen) and REQ-IC-8 (a submitted inspectionChecklistRun is
* append-only). They live in their own registrar because
* `ObjectListenerRegistrar` is explicitly the home of the listeners that are
* NOT scoped to a single subsystem, and because keeping them here holds that
* class's object coupling inside the phpmd threshold.
*
* Both listeners subscribe to OpenRegister's PRE-persist, stoppable events.
* The post-persist pair cannot be used: OpenRegister dispatches
* `ObjectUpdatedEvent`/`ObjectDeletedEvent` after the row has already been
* written, with no surrounding transaction, so a listener there cannot stop
* the mutation it objects to.
*
* @category AppInfo
* @package OCA\Procest\AppInfo\Registrar
*
* @author Conduction Development Team <info@conduction.nl>
* @copyright 2026 Conduction B.V.
* @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
*
* @version GIT: <git-id>
*
* @link https://procest.nl
*
* SPDX-License-Identifier: EUPL-1.2
* SPDX-FileCopyrightText: 2026 Conduction B.V. <info@conduction.nl>
*
* @spec openspec/specs/subsidieverlening-keten/spec.md
*/

declare(strict_types=1);

namespace OCA\Procest\AppInfo\Registrar;

use OCA\OpenRegister\Event\ObjectDeletingEvent;
use OCA\OpenRegister\Event\ObjectUpdatingEvent;
use OCA\Procest\Listener\BewijsstukImmutabilityListener;
use OCA\Procest\Listener\ChecklistRunImmutabilityListener;
use OCP\AppFramework\Bootstrap\IRegistrationContext;

/**
* Registers the pre-persist immutability guards.
*
* @psalm-suppress UnusedClass
*
* @spec openspec/specs/subsidieverlening-keten/spec.md
*/
class ImmutabilityListenerRegistrar
{
/**
* Register the immutability listeners.
*
* @param IRegistrationContext $context The registration context.
*
* @return void
*
* @spec openspec/specs/subsidieverlening-keten/spec.md
*/
public function register(IRegistrationContext $context): void
{
// REQ-SUB-007: a bewijsstuk linked to a vaststelling is immutable.
// This is the production call site for
// BewijsstukService::assertMutable(), which previously had none.
$context->registerEventListener(
event: ObjectUpdatingEvent::class,
listener: BewijsstukImmutabilityListener::class
);
$context->registerEventListener(
event: ObjectDeletingEvent::class,
listener: BewijsstukImmutabilityListener::class
);

// REQ-IC-8: a submitted inspectionChecklistRun is append-only. The
// listener existed but was never referenced by any registrar, so the
// rule was not enforced by anything.
$context->registerEventListener(
event: ObjectUpdatingEvent::class,
listener: ChecklistRunImmutabilityListener::class
);
}//end register()
}//end class
1 change: 1 addition & 0 deletions lib/AppInfo/Registrar/ListenerRegistrar.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ class ListenerRegistrar
public function register(IRegistrationContext $context): void
{
(new ObjectListenerRegistrar())->register(context: $context);
(new ImmutabilityListenerRegistrar())->register(context: $context);
(new BezwaarListenerRegistrar())->register(context: $context);
(new WorkflowListenerRegistrar())->register(context: $context);
}//end register()
Expand Down
174 changes: 174 additions & 0 deletions lib/Listener/BewijsstukImmutabilityListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
<?php

/**
* Procest Bewijsstuk Immutability Listener.
*
* Enforces the REQ-SUB-007 rule that a bewijsstuk becomes immutable once it is
* linked to a vaststelling. `BewijsstukService::assertMutable()` implemented
* that rule and was unit-tested, but had ZERO production callers — an
* authorization check that is never invoked is identical to having no check at
* all (OWASP A01:2021). This listener is the call site.
*
* It hooks OpenRegister's PRE-persist `ObjectUpdatingEvent` /
* `ObjectDeletingEvent` pair, both of which implement
* `StoppableEventInterface`: `stopPropagation()` makes MagicMapper raise
* `HookStoppedException` BEFORE the row is written or removed. The
* post-persist `ObjectUpdatedEvent`/`ObjectDeletedEvent` pair is deliberately
* NOT used — by the time those fire the mutation has already landed in the
* database, so a listener there cannot prevent anything (the same reasoning
* `LocationBagValidationListener` records).
*
* The check always reads the STORED state (`getOldObject()` on update, the
* entity itself on delete), never the incoming payload. Reading the payload
* would let a caller clear `immutable` in the same request that mutates the
* document and walk straight through the guard.
*
* @category Listener
* @package OCA\Procest\Listener
*
* @author Conduction Development Team <info@conduction.nl>
* @copyright 2026 Conduction B.V.
* @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
*
* SPDX-License-Identifier: EUPL-1.2
* SPDX-FileCopyrightText: 2026 Conduction B.V. <info@conduction.nl>
*
* @version GIT: <git-id>
*
* @link https://procest.nl
*
* @spec openspec/specs/subsidieverlening-keten/spec.md
*/

declare(strict_types=1);

namespace OCA\Procest\Listener;

use OCA\OpenRegister\Db\ObjectEntity;
use OCA\OpenRegister\Event\ObjectDeletingEvent;
use OCA\OpenRegister\Event\ObjectUpdatingEvent;
use OCA\Procest\Service\SettingsService;
use OCA\Procest\Service\Subsidie\BewijsstukService;
use OCP\AppFramework\OCS\OCSBadRequestException;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use Psr\Log\LoggerInterface;
use Throwable;

/**
* Reject mutation/deletion of a bewijsstuk linked to a vaststelling.
*
* @implements IEventListener<Event>
*
* @spec openspec/specs/subsidieverlening-keten/spec.md
*/
class BewijsstukImmutabilityListener implements IEventListener
{
/**
* Constructor.
*
* @param SettingsService $settingsService Schema slug bridge.
* @param BewijsstukService $bewijsstukService Owns the REQ-SUB-007
* immutability rule.
* @param LoggerInterface $logger Structured logger.
*/
public function __construct(
private readonly SettingsService $settingsService,
private readonly BewijsstukService $bewijsstukService,
private readonly LoggerInterface $logger,
) {
}//end __construct()

/**
* Inspect a pre-persist bewijsstuk mutation and reject it when frozen.
*
* @param Event $event The dispatched event.
*
* @return void
*
* @spec openspec/specs/subsidieverlening-keten/spec.md
*/
public function handle(Event $event): void
{
if ($event instanceof ObjectUpdatingEvent === true) {
// The STORED state decides, not the incoming payload.
$this->inspect(event: $event, stored: $event->getOldObject());
return;
}

if ($event instanceof ObjectDeletingEvent === true) {
$this->inspect(event: $event, stored: $event->getObject());
return;
}
}//end handle()

/**
* Apply `BewijsstukService::assertMutable()` to the stored state and stop
* the save when it rejects.
*
* @param ObjectUpdatingEvent|ObjectDeletingEvent $event The pre-persist,
* stoppable event.
* @param ObjectEntity|null $stored The state
* currently in the
* database.
*
* @return void
*/
private function inspect(ObjectUpdatingEvent|ObjectDeletingEvent $event, ?ObjectEntity $stored): void
{
if ($stored === null) {
return;
}

try {
$payload = $stored->jsonSerialize();
} catch (Throwable $e) {
$this->logger->debug(
'Procest: bewijsstuk immutability listener could not read the stored payload: '.$e->getMessage()
);
return;
}

if ($this->isBewijsstukSchema(object: $payload) === false) {
return;
}

try {
$this->bewijsstukService->assertMutable(bewijsstuk: $payload);
} catch (OCSBadRequestException $rejection) {
$event->setErrors(
[
'message' => $rejection->getMessage(),
'code' => 'bewijsstuk.immutable',
]
);
$event->stopPropagation();
$this->logger->info(
'Procest: rejected a mutation on an immutable bewijsstuk (REQ-SUB-007)',
['uuid' => (string) $stored->getUuid()]
);
}
}//end inspect()

/**
* Whether the supplied payload belongs to the `bewijsstuk` schema.
*
* @param array<string, mixed> $object Object payload (incl. `@self`).
*
* @return bool True when this is a bewijsstuk.
*/
private function isBewijsstukSchema(array $object): bool
{
$expected = $this->settingsService->getConfigValue('bewijsstuk_schema');
if ($expected === '') {
return false;
}

$candidate = (string) ($object['@self']['schema'] ?? ($object['schema'] ?? ''));

return $candidate !== '' && (
$candidate === $expected
|| str_ends_with($candidate, '/'.$expected)
);
}//end isBewijsstukSchema()
}//end class
46 changes: 29 additions & 17 deletions lib/Listener/ChecklistRunImmutabilityListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,23 @@
*
* Enforces REQ-IC-8: once a `inspectionChecklistRun` reaches
* status = ingediend (or gearchiveerd), the object becomes append-only.
* Any UPDATE that mutates protected fields after submit is rejected with
* a RuntimeException whose message ("Checklist run is append-only") is the
* canonical spec error string surfaced via REQ-IC-4 / REQ-IC-8 scenarios.
* Any UPDATE that mutates protected fields after submit is rejected, with
* the canonical spec error string ("Checklist run is append-only")
* surfaced via REQ-IC-4 / REQ-IC-8 scenarios.
*
* The listener never blocks the initial create (ObjectCreatedEvent) and
* lets a status transition from `in_uitvoering → ingediend` through; only
* subsequent edits to a submitted run trigger the rejection.
* The listener never blocks the initial create and lets a status transition
* from `in_uitvoering → ingediend` through; only subsequent edits to a
* submitted run trigger the rejection.
*
* It hooks OpenRegister's PRE-persist `ObjectUpdatingEvent`, which
* implements `StoppableEventInterface`: `stopPropagation()` makes
* MagicMapper raise `HookStoppedException` BEFORE the row is written. The
* post-persist `ObjectUpdatedEvent` this listener previously declared is
* dispatched AFTER `updateObjectEntity()` has already committed the row and
* OpenRegister opens no transaction around it, so throwing from there could
* not undo anything — the mutation landed and the caller merely saw an
* error. That, combined with the class never having been registered in
* `ObjectListenerRegistrar`, meant REQ-IC-8 was not enforced at all.
*
* @category Listener
* @package OCA\Procest\Listener
Expand All @@ -32,12 +42,11 @@

namespace OCA\Procest\Listener;

use OCA\OpenRegister\Event\ObjectUpdatedEvent;
use OCA\OpenRegister\Event\ObjectUpdatingEvent;
use OCA\Procest\Service\SettingsService;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Throwable;

/**
Expand Down Expand Up @@ -69,36 +78,39 @@ public function __construct(
}//end __construct()

/**
* Inspect ObjectUpdatedEvent and reject illegal mutations.
* Inspect ObjectUpdatingEvent and reject illegal mutations before the
* row is written.
*
* @param Event $event The dispatched event
*
* @return void
*
* @throws RuntimeException When a submitted run is being mutated.

* @spec openspec/specs/inspection-checklists/spec.md
*/
public function handle(Event $event): void
{
if ($event instanceof ObjectUpdatedEvent === false) {
if ($event instanceof ObjectUpdatingEvent === false) {
return;
}

try {
if ($this->isFrozenRunMutation(event: $event) === false) {
return;
}

throw new RuntimeException('Checklist run is append-only');
} catch (RuntimeException $rejection) {
// Re-throw rejection so OpenRegister surfaces it to the caller.
throw $rejection;
} catch (Throwable $e) {
$this->logger->debug(
'Procest: checklist immutability listener swallowed exception: '.$e->getMessage(),
);
return;
}//end try

$event->setErrors(
[
'message' => 'Checklist run is append-only',
'code' => 'inspectionChecklistRun.appendOnly',
]
);
$event->stopPropagation();
}//end handle()

/**
Expand Down
13 changes: 9 additions & 4 deletions openspec/specs/subsidieverlening-keten/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,15 @@ status-note: |
run. Their capability methods are implemented and unit-tested yet have ZERO
callers, so no user or API path can reach them:

- REQ-SUB-007 (bewijsstukken): `BewijsstukService::verifyHash()` and
`::assertMutable()` are never invoked — no hash is verified on read, and no
document is locked once linked to a vaststelling. There is no bewijsstuk
route, no nightly archief-trigger, and no Docudesk PDF/A handover.
- REQ-SUB-007 (bewijsstukken): PARTIALLY CLOSED on 2026-08-05. The
immutability half now runs: `BewijsstukService::assertMutable()` is invoked
from `BewijsstukImmutabilityListener`, which subscribes to OpenRegister's
PRE-persist `ObjectUpdatingEvent`/`ObjectDeletingEvent` and stops the save,
so a bewijsstuk linked to a vaststelling can no longer be edited or deleted
through the generic object write path the frontend uses (ADR-022). The rest
of the requirement is STILL not running: `::verifyHash()` has no caller so
no hash is verified on read, and there is no bewijsstuk route, no nightly
archief-trigger and no Docudesk PDF/A handover.
- REQ-SUB-008 (staatssteun): `StaatssteunClassifier::requiresStaatssteunGrondslag()`
is never invoked — no de-minimis gate runs on assessment, and no AGVV/TAM
melding is emitted.
Expand Down
Loading
Loading