From f785b881dea5ae94f8887581f0974ffb896f0644 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Thu, 23 Jul 2026 20:02:33 +0200 Subject: [PATCH] =?UTF-8?q?docs(openspec):=20market-gap=20wave=202026-07-2?= =?UTF-8?q?3=20=E2=80=94=207=20ff=20spec=20changes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Evidence-backed by the Specter research wave (6 agents: inventory, competitors, VNG user wishes, NC ecosystem, tenders, external sources; logged to the intelligence DB): gemma-faceted-search, vendor-visibility-rbac (closes a live cross-org gebruik read leak found in discovery), organisation-merge, bio-compliance-assessment, portfolio-rationalization-time, sbom-import, eol-feed-integration (consumes openconnector endoflife-date-source leaf). All validate strict. Deferred wishes filed as #370-#375. --- .../bio-compliance-assessment/.openspec.yaml | 2 + .../context-brief.md | 26 ++ .../bio-compliance-assessment/design.md | 237 +++++++++++++ .../bio-compliance-assessment/proposal.md | 149 +++++++++ .../specs/bio-compliance-assessment/spec.md | 194 +++++++++++ .../module-compliance-assessment/spec.md | 91 +++++ .../bio-compliance-assessment/tasks.md | 99 ++++++ .../eol-feed-integration/.openspec.yaml | 2 + .../eol-feed-integration/context-brief.md | 26 ++ .../changes/eol-feed-integration/design.md | 242 ++++++++++++++ .../changes/eol-feed-integration/proposal.md | 85 +++++ .../specs/eol-feed-integration/spec.md | 213 ++++++++++++ .../changes/eol-feed-integration/tasks.md | 104 ++++++ .../gemma-faceted-search/.openspec.yaml | 2 + .../gemma-faceted-search/context-brief.md | 27 ++ .../changes/gemma-faceted-search/design.md | 105 ++++++ .../changes/gemma-faceted-search/proposal.md | 59 ++++ .../specs/gemma-faceted-search/spec.md | 214 ++++++++++++ .../changes/gemma-faceted-search/tasks.md | 160 +++++++++ .../changes/gemma-faceted-search/test-plan.md | 168 ++++++++++ .../changes/organisation-merge/.openspec.yaml | 2 + .../organisation-merge/context-brief.md | 25 ++ openspec/changes/organisation-merge/design.md | 133 ++++++++ .../changes/organisation-merge/proposal.md | 66 ++++ .../specs/organisatie-service/spec.md | 36 ++ .../specs/organisation-merge/spec.md | 180 ++++++++++ openspec/changes/organisation-merge/tasks.md | 92 +++++ .../changes/organisation-merge/test-plan.md | 187 +++++++++++ .../.openspec.yaml | 2 + .../context-brief.md | 29 ++ .../portfolio-rationalization-time/design.md | 162 +++++++++ .../proposal.md | 138 ++++++++ .../portfolio-rationalization-time/spec.md | 163 +++++++++ .../portfolio-rationalization-time/tasks.md | 98 ++++++ openspec/changes/sbom-import/.openspec.yaml | 2 + openspec/changes/sbom-import/context-brief.md | 28 ++ openspec/changes/sbom-import/design.md | 314 ++++++++++++++++++ openspec/changes/sbom-import/proposal.md | 155 +++++++++ .../sbom-import/specs/sbom-import/spec.md | 281 ++++++++++++++++ openspec/changes/sbom-import/tasks.md | 99 ++++++ .../vendor-visibility-rbac/.openspec.yaml | 2 + .../vendor-visibility-rbac/context-brief.md | 27 ++ .../changes/vendor-visibility-rbac/design.md | 74 +++++ .../vendor-visibility-rbac/discovery.md | 54 +++ .../vendor-visibility-rbac/proposal.md | 65 ++++ .../specs/vendor-visibility-rbac/spec.md | 156 +++++++++ .../changes/vendor-visibility-rbac/tasks.md | 86 +++++ .../vendor-visibility-rbac/test-plan.md | 151 +++++++++ 48 files changed, 5012 insertions(+) create mode 100644 openspec/changes/bio-compliance-assessment/.openspec.yaml create mode 100644 openspec/changes/bio-compliance-assessment/context-brief.md create mode 100644 openspec/changes/bio-compliance-assessment/design.md create mode 100644 openspec/changes/bio-compliance-assessment/proposal.md create mode 100644 openspec/changes/bio-compliance-assessment/specs/bio-compliance-assessment/spec.md create mode 100644 openspec/changes/bio-compliance-assessment/specs/module-compliance-assessment/spec.md create mode 100644 openspec/changes/bio-compliance-assessment/tasks.md create mode 100644 openspec/changes/eol-feed-integration/.openspec.yaml create mode 100644 openspec/changes/eol-feed-integration/context-brief.md create mode 100644 openspec/changes/eol-feed-integration/design.md create mode 100644 openspec/changes/eol-feed-integration/proposal.md create mode 100644 openspec/changes/eol-feed-integration/specs/eol-feed-integration/spec.md create mode 100644 openspec/changes/eol-feed-integration/tasks.md create mode 100644 openspec/changes/gemma-faceted-search/.openspec.yaml create mode 100644 openspec/changes/gemma-faceted-search/context-brief.md create mode 100644 openspec/changes/gemma-faceted-search/design.md create mode 100644 openspec/changes/gemma-faceted-search/proposal.md create mode 100644 openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md create mode 100644 openspec/changes/gemma-faceted-search/tasks.md create mode 100644 openspec/changes/gemma-faceted-search/test-plan.md create mode 100644 openspec/changes/organisation-merge/.openspec.yaml create mode 100644 openspec/changes/organisation-merge/context-brief.md create mode 100644 openspec/changes/organisation-merge/design.md create mode 100644 openspec/changes/organisation-merge/proposal.md create mode 100644 openspec/changes/organisation-merge/specs/organisatie-service/spec.md create mode 100644 openspec/changes/organisation-merge/specs/organisation-merge/spec.md create mode 100644 openspec/changes/organisation-merge/tasks.md create mode 100644 openspec/changes/organisation-merge/test-plan.md create mode 100644 openspec/changes/portfolio-rationalization-time/.openspec.yaml create mode 100644 openspec/changes/portfolio-rationalization-time/context-brief.md create mode 100644 openspec/changes/portfolio-rationalization-time/design.md create mode 100644 openspec/changes/portfolio-rationalization-time/proposal.md create mode 100644 openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md create mode 100644 openspec/changes/portfolio-rationalization-time/tasks.md create mode 100644 openspec/changes/sbom-import/.openspec.yaml create mode 100644 openspec/changes/sbom-import/context-brief.md create mode 100644 openspec/changes/sbom-import/design.md create mode 100644 openspec/changes/sbom-import/proposal.md create mode 100644 openspec/changes/sbom-import/specs/sbom-import/spec.md create mode 100644 openspec/changes/sbom-import/tasks.md create mode 100644 openspec/changes/vendor-visibility-rbac/.openspec.yaml create mode 100644 openspec/changes/vendor-visibility-rbac/context-brief.md create mode 100644 openspec/changes/vendor-visibility-rbac/design.md create mode 100644 openspec/changes/vendor-visibility-rbac/discovery.md create mode 100644 openspec/changes/vendor-visibility-rbac/proposal.md create mode 100644 openspec/changes/vendor-visibility-rbac/specs/vendor-visibility-rbac/spec.md create mode 100644 openspec/changes/vendor-visibility-rbac/tasks.md create mode 100644 openspec/changes/vendor-visibility-rbac/test-plan.md diff --git a/openspec/changes/bio-compliance-assessment/.openspec.yaml b/openspec/changes/bio-compliance-assessment/.openspec.yaml new file mode 100644 index 00000000..cf371d3a --- /dev/null +++ b/openspec/changes/bio-compliance-assessment/.openspec.yaml @@ -0,0 +1,2 @@ +schema: conduction +created: 2026-07-23 diff --git a/openspec/changes/bio-compliance-assessment/context-brief.md b/openspec/changes/bio-compliance-assessment/context-brief.md new file mode 100644 index 00000000..b156c448 --- /dev/null +++ b/openspec/changes/bio-compliance-assessment/context-brief.md @@ -0,0 +1,26 @@ +# Context Brief: bio-compliance-assessment + +## What +Extend the existing compliance model with the Dutch government security/privacy stack: **BIO maatregelen register** (measures from the Baseline Informatiebeveiliging Overheid), per-application **BBN level** (BBN1/BBN2/BBN3), **DPIA status** (required/executed/date/document ref), and a reference to the **register van verwerkingen** entry. Per-organisation BIO coverage report extending the existing compliance matrix. + +## Why (evidence) +- VNG Softwarecatalogus issues #44 (monitor BIO), #45/#47 (register measures), #46 (BBN), #67 (DPIA), #82 (register van verwerkingen) + 10 IBD-labelled issues. +- 561 compliance requirements in the 301 mapped tenders — the single largest demand theme. +- BIO 2.0 is mandatory for all Dutch government bodies; NIS2/BIO2 transition is active in 2026. +- Specter canonical feature: `bio-compliance-assessment` (must, demand 16). + +## Current state (read these specs first) +- `openspec/specs/module-compliance-assessment` — compliance records with evidence files, verified-vs-claimed matrix, per-org coverage report. THIS change extends that model; do not fork a parallel one. +- `openspec/specs/settings-admin-controller` / `repair-init` — schemas live in `lib/Settings/softwarecatalogus_register.json` (OpenAPI 3.0.0) imported via repair step. +- Standards register pages already exist (standards-conformance). + +## Scope +IN: new/extended OR schemas (bioMaatregel catalog entries seedable from the published BIO measure list; per-application compliance fields: bbnLevel, dpiaStatus, dpiaDate, dpiaDocumentRef, verwerkingsregisterRef), CRUD via manifest pages where possible, BIO coverage report per organisation (extends compliance matrix), filters (e.g. applications without DPIA at BBN2+), notifications rule for overdue DPIA reviews (declarative OR notification dialect — see softwarecatalog-notifications spec), i18n, tests, docs. +OUT: automated BIO evidence collection, ISMS workflows, NIS2 incident reporting, audit certification flows. + +## Design constraints +- ADR-001 OR storage only; schema additions to softwarecatalogus_register.json with schema.org type annotations where applicable. +- Union-merge trap: when editing the register JSON, diff against merge base — naive union merges drop modifications. +- Notification rules use the canonical x-openregister-notifications dialect (gate-18 checks; legacy dialect hard-fails). +- ADR-012 Cn components; ADR-005 i18n NL+EN; ADR-009 tests; ADR-010 docs. +- OpenSpec delta headers MUST be `### Requirement: `. diff --git a/openspec/changes/bio-compliance-assessment/design.md b/openspec/changes/bio-compliance-assessment/design.md new file mode 100644 index 00000000..d0c55237 --- /dev/null +++ b/openspec/changes/bio-compliance-assessment/design.md @@ -0,0 +1,237 @@ +# Design: bio-compliance-assessment + +## Architecture Overview +This change extends the existing `module-compliance-assessment` +architecture rather than adding a new one: + +``` +module ──────────────┬── compliancy ──── standaardversie (element, gemmaType=standaardversie) + (bbnLevel, │ (existing) + dpiaStatus, └── compliancy ──── bioMaatregel (NEW: parallel relation) + dpiaDate, (evidence: bewijs/bewijsReferentie/url — reused) + dpiaVolgendeBeoordeling, + dpiaDocumentRef, + verwerkingsregisterRef) +``` + +`compliancy` keeps its single-record shape and gains one optional +relation (`bioMaatregel`) alongside the existing `standaardversie` / +`standaardGemma` pair. A record links a module to a standard version OR +a BIO measure — never a third parallel schema. The matrix/coverage code +(`src/utils/complianceMatrix.js`) already treats "column key" as a +parameter; this change generalises it to accept either relation family +instead of forking a second mapper. + +BBN level, DPIA status, and the verwerkingsregister reference are +application-level attributes (they describe the module as a whole, not a +specific measure), so they live directly on `module`, following the same +placement as other application-level fields (`hostingJurisdictie`, +`licentietype`). + +## Goals / Non-Goals + +**Goals:** +- Reuse the `compliancy` verified/claimed/evidence model for BIO measure + compliance instead of forking a parallel assessment object. +- Make BBN level, DPIA status, and DPIA review due-dates filterable and + reportable per organisation. +- Ship a working, declarative overdue-DPIA notification using the + canonical `x-openregister-notifications` dialect. + +**Non-Goals:** +- Computing BBN level or DPIA requirement automatically from other data + (e.g. deriving BBN from `hostingJurisdictie`) — both are user-entered. +- A generic "review cadence" engine — `dpiaVolgendeBeoordeling` is a + single user-set date field, not a recurring-schedule primitive. +- Modelling the register van verwerkingen itself. + +## Decisions + +### Decision 1: Extend `compliancy` with a `bioMaatregel` relation, not a new `bioCompliancy` schema +**Why:** The context brief and the `module-compliance-assessment` spec +are explicit: this change extends that model, it does not fork a +parallel one. `compliancy` already carries the verified/claimed logic, +the evidence fields (`bewijs`, `bewijsReferentie`, `url`), and the +matrix/coverage machinery. Adding a `bioMaatregel` relation (mirroring +`standaardversie`) reuses all of it for zero new UI code paths beyond +column-source selection. +**Alternatives considered:** a dedicated `bioAssessment` schema — +rejected, duplicates `compliancy`'s evidence/verified-claimed shape and +would require a second matrix mapper, directly contradicting the "do not +fork a parallel model" instruction. + +### Decision 2: BBN/DPIA fields live on `module`, not on a per-measure record +**Why:** BBN level and DPIA status describe the application as a whole +("this application is classified BBN2 and had a DPIA executed on +2026-03-01"), not a per-standard or per-measure claim. Placing them on +`compliancy` would force one BBN/DPIA value per compliance record, which +is meaningless — an application has exactly one BBN level and one DPIA +status, independent of how many BIO measures or standards it claims. +**Alternatives considered:** a `bioBeoordeling` header record wrapping +BBN/DPIA plus a list of measure claims — rejected as unnecessary +indirection; `module` already is the one-per-application anchor object +every other per-application attribute (licence, hosting) hangs off. + +### Decision 3: DPIA overdue notification uses a stored `dpiaVolgendeBeoordeling` date + `scheduled`/`withinNext`, not a computed field +**Why:** Per ADR-031, "overdue" detection should default to declarative +schema metadata. `x-openregister-calculations` can derive an `isOverdue` +boolean at read time, but `x-openregister-notifications`' `scheduled` +trigger filters on **stored** object data — a calculated field computed +at read time is not evaluated during a scheduled sweep. A stored +`dpiaVolgendeBeoordeling` date (set by the user/vendor when a DPIA is +executed) lets the rule reuse the exact filter shape already proven +working in this register: `contract`'s `contract-expiry` and +`gebruik`'s `phaseout-approaching` both use `scheduled` + +`{ "operator": "withinNext", "value": "PD" }`. `withinNext` with a +0-day window (`"P0D"`) reads as "due on or before today" — i.e. due +today or already overdue — using the exact same operator family instead +of introducing an unproven "before"/"past-due" operator this register +has never used. +**Alternatives considered:** (a) an `x-openregister-calculations` +`dpiaOverdue` boolean + `calculatedChange` trigger — rejected because +`calculatedChange` only watches NUMERIC calculated fields for boundary +crossings, and a boolean derived at read time has no natural "change +event" to watch under a scheduled sweep; (b) a bespoke PHP background +job walking modules daily — rejected per ADR-031 (a scheduled sweep over +stored fields is exactly what the declarative engine already does; a +custom job would be the anti-pattern the ADR calls out). +**Declarative-vs-imperative decision (ADR-031):** declarative — no new +PHP service or job is introduced by this change. + +### Decision 4: BIO coverage report extends `ComplianceMatrixView`, not a new page +**Why:** The context brief frames this explicitly as "extends the +existing compliance matrix." `ComplianceMatrixView` is already a +`type: custom` manifest page whose whole reason for existing is that no +index/detail archetype can express a runtime-selected two-dimensional +grid. Adding a BIO scope/tab to the same component (module rows × +BIO-measure columns, plus a BBN/DPIA summary strip) reuses the filter- +first, URL-shareable-selection pattern instead of duplicating it. +**Alternatives considered:** a standalone `BioCoverageReportView` — +rejected as an unnecessary fork of the same filter-first +matrix/shareable-URL pattern; a toggle within the existing view is a +smaller diff and keeps one canonical "compliance view" surface. + +## Risks / Trade-offs +- [Risk] `dpiaVolgendeBeoordeling` is user-entered, not computed from a + fixed BIO/AVG review interval → some vendors may leave it blank, + silencing the overdue notification for that application. → + **Mitigation:** the "applications without a DPIA at BBN2+" filter + (in scope) surfaces missing DPIA data independently of the + notification, so the gap stays visible in the catalog UI even when + the notification is silent. +- [Risk] `withinNext P0D` semantics assume the OpenRegister notification + engine's `withinNext` operator is boundary-inclusive of past dates + (i.e. `date <= now`), matching how `contract-expiry` and + `phaseout-approaching` are already deployed in this register. → + **Mitigation:** this reuses the exact operator already live in + production rules in this same file; no new engine behaviour is + assumed. If verified otherwise during implementation, the fallback is + a small positive window (e.g. `"P1D"`) — documented here so the + builder does not need to guess. +- [Risk] Extending `compliancy` with a second optional relation + (`bioMaatregel`) means a record could theoretically carry both + `standaardversie` and `bioMaatregel` set. → **Mitigation:** the spec + requires records to link to exactly one of the two; UI form + validation and the matrix mapper both treat "both set" as a data + -quality issue to flag, not a new dual-purpose record type. + +## Migration Plan +No Nextcloud DB migration class is introduced — per ADR-001 this app +owns no custom database tables. Schema changes are register-JSON patches +to `lib/Settings/softwarecatalogus_register.json`, applied the same way +every prior schema change in this app was: `ConfigurationService::importFromApp()` +re-imports the register on the existing `InitializeSettings` repair step +(see `repair-init`), so the change ships and self-applies on the next +app upgrade — no separate `migration.md` artefact is produced for this +change (see Notes below). + +## Open Questions +- Should `bbnLevel` be `facetable: true` from day one (needed for the + "applications without a DPIA at BBN2+" filter) — yes, confirmed in + the spec; noted here so the register patch does not miss it. +- Fixed BIO/AVG review interval for a default `dpiaVolgendeBeoordeling` + — deferred to DEFERRED_QUESTIONS; out of scope for this change. + +## Nextcloud Integration +- Controllers: none new — this app queries OpenRegister's object API + directly from the frontend (no bespoke CRUD controller), per the + existing `settings-admin-controller` pattern. +- Services: none new — see Decision 3; the overdue-DPIA behaviour is + entirely declarative schema metadata, not a service class. +- Mappers/Entities: none — OpenRegister owns storage; `compliancy` and + `module` remain the only entities involved. +- Events/Hooks: the existing `InitializeSettings` repair step (see + `repair-init`) re-imports the extended register on upgrade; no new + hook is added. + +## Security Considerations +No new authorization surface. `bioMaatregel` is a read-mostly reference +catalog (`authorization.read: ["public"]`, matching `element`); write +access follows the existing `softwarecatalog-admins`/manage-ACL pattern +already used by `compliancy` and `module`. The new `module` fields +(`bbnLevel`, `dpiaStatus`, `dpiaDate`, `dpiaVolgendeBeoordeling`, +`dpiaDocumentRef`, `verwerkingsregisterRef`) inherit `module`'s existing +field-level authorization — no new read/write scope is introduced. +`dpiaDocumentRef` follows the established `bewijsReferentie` pattern +(link via NC Files, not store the file) so no new file-handling +authorization path is created. + +## NL Design System +New fields render through the existing `CnFormDialog`/`data`-widget +patterns already used for `module` and `compliancy` (see the manifest +`_note`s on `KompliantieDetail` / `ModuleDetail`); no new form or table +component is introduced. The BIO coverage report reuses +`ComplianceMatrixView`'s existing tri-state cell styling (verified / +claimed / none) via NL Design System CSS variables — no hardcoded +colors. + +## File Structure +``` +lib/ + Settings/ + softwarecatalogus_register.json # bioMaatregel schema; compliancy + module extensions; notification rule +src/ + manifest.json # bioMaatregel catalog pages; module form fields; matrix BIO scope; filters + views/ + ComplianceMatrixView.vue # extended with a BIO scope/tab + utils/ + complianceMatrix.js # generalised to key on bioMaatregel alongside standaardversie + l10n/ + nl.json / en.json (or equivalent) # new field labels, filter labels, notification subjects +tests/ + Unit/ # register import / schema validation, matrix mapper unit tests +``` + +## Seed Data + +### Schema: `bioMaatregel` +| Field | Object 1 | Object 2 | Object 3 | Object 4 | Object 5 | +|-------|----------|----------|----------|----------|----------| +| slug | `bio-5-1-1` | `bio-9-2-1` | `bio-10-1-1` | `bio-12-1-1` | `bio-13-2-1` | +| code | 5.1.1 | 9.2.1 | 10.1.1 | 12.1.1 | 13.2.1 | +| naam | Toegangsbeveiligingsbeleid | Beheer van gebruikerstoegang | Cryptografisch beleid | Netwerkbeveiligingsbeheer | Gegevensoverdracht­beleid | +| thema | Toegangsbeveiliging | Toegangsbeveiliging | Cryptografie | Communicatiebeveiliging | Communicatiebeveiliging | +| bioVersie | BIO 2.0 | BIO 2.0 | BIO 2.0 | BIO 2.0 | BIO 2.0 | +| bbnNiveau | [BBN1, BBN2, BBN3] | [BBN2, BBN3] | [BBN2, BBN3] | [BBN1, BBN2, BBN3] | [BBN2, BBN3] | +| bron | baseline­informatie­beveiligingoverheid.nl | (same) | (same) | (same) | (same) | + +**Related items per object:** none (reference catalog entries; no +files/notes/tasks/contacts attached). + +### Schema: `module` (existing objects gain new field values — no new seed objects) +Existing seed `module` objects (from `module-compliance-assessment`) +gain example values for the new fields on 3 of the existing records: +one BBN2 application with an executed DPIA and evidence document, one +BBN3 application with a required-but-not-yet-executed DPIA (for the +"without a DPIA at BBN2+" filter demo), and one BBN1 application with +DPIA not applicable. + +## Trade-offs +Reusing `compliancy` for BIO measures (Decision 1) means the schema now +serves two conceptually distinct catalogs (GEMMA standards, BIO +measures) through one relation-pair shape. This is a deliberate +trade-off: it costs a small amount of schema ambiguity (two optional +relations, "exactly one populated") in exchange for zero duplicated +evidence/verified-claimed logic and one matrix mapper instead of two — +judged worthwhile given the explicit "do not fork a parallel model" +constraint. diff --git a/openspec/changes/bio-compliance-assessment/proposal.md b/openspec/changes/bio-compliance-assessment/proposal.md new file mode 100644 index 00000000..66924372 --- /dev/null +++ b/openspec/changes/bio-compliance-assessment/proposal.md @@ -0,0 +1,149 @@ +# Proposal: bio-compliance-assessment + +## Summary +Extends the existing GEMMA compliance model with the Dutch government +security/privacy stack: a seedable **BIO maatregelen** (BIO 2.0 measures) +catalog, a per-application **BBN level** (BBN1/BBN2/BBN3), **DPIA** +tracking (status, execution date, next-review date, document reference), +a reference to the organisation's **register van verwerkingen** entry, a +per-organisation BIO coverage report that extends the existing compliance +matrix, catalog filters (e.g. applications without a DPIA at BBN2+), and a +declarative notification rule for overdue DPIA reviews. The change reuses +the `compliancy` record model (module ↔ standard/measure, evidence, +verified-vs-claimed) rather than inventing a parallel compliance +mechanism. + +## Motivation +BIO/BBN/DPIA/AVG compliance is the single largest demand theme in the +mapped tender corpus — 561 compliance requirements across 301 tenders — +and the current catalog has no way to record it. VNG Softwarecatalogus +issues #44 (monitor BIO), #45/#47 (register measures), #46 (BBN), #67 +(DPIA), #82 (register van verwerkingen), plus 10 IBD-labelled issues, all +ask for this. BIO 2.0 is mandatory for Dutch government bodies and the +NIS2/BIO2 transition is active in 2026, so buyers increasingly shortlist +on BIO/BBN/DPIA posture the same way they already do on GEMMA standards. +Building it now, as an extension of the proven `compliancy` model, avoids +a second parallel compliance mechanism from emerging later under time +pressure. + +## Affected Projects +- [x] Project: `softwarecatalog` — new/extended OR register schemas + (`bioMaatregel` catalog, `compliancy` extension, `module` BBN/DPIA + fields), BIO coverage report UI, DPIA/BBN catalog filters, a + `dpia-review-overdue` notification rule, i18n, tests, docs. + +## Scope + +### In Scope +- New `bioMaatregel` catalog schema (BIO 2.0 measures: code, title, + description, theme, applicable BBN level(s), source reference), + seedable from the published BIO measure list. +- `compliancy` schema extended with an optional `bioMaatregel` relation + (parallel to the existing `standaardversie` relation) so a compliance + record can assert measure-level BIO compliance using the same + verified/claimed/evidence model as standards compliance. +- `module` schema extended with `bbnLevel` (BBN1/BBN2/BBN3), + `dpiaStatus`, `dpiaDate`, `dpiaVolgendeBeoordeling` (next DPIA review + due date), `dpiaDocumentRef` (NC Files link, link-don't-store), and + `verwerkingsregisterRef` (reference to the register van verwerkingen + entry). +- CRUD for the new fields/schema via manifest pages, following existing + patterns (`ComplianceMatrixView`, the `element`/`compliancy` catalog + pages). +- A BIO coverage report per organisation, extending the existing + compliance matrix/coverage view, reporting per in-use application: + BBN level, DPIA status, and BIO measure compliance + (verified/claimed/none). +- Catalog filters on BBN level and DPIA status (e.g. "applications + without a DPIA at BBN2 or higher"). +- A declarative `x-openregister-notifications` rule on `module` for + overdue DPIA reviews (canonical dialect; scheduled trigger). +- i18n (NL + EN), tests (≥75% coverage for new code), docs with + screenshots. + +### Out of Scope +- Automated BIO evidence collection (scanning, tooling integration). +- ISMS workflows (risk registers, control testing schedules beyond the + DPIA review date). +- NIS2 incident reporting. +- Audit certification flows (ENSIA, DigiD assessments, etc.). +- Building a full register van verwerkingen module — this change only + stores a reference to an existing entry (a link/id), not the register + itself. + +## Approach +Follow the same pattern `module-compliance-assessment` already +established for GEMMA standards: `compliancy` records are the source of +truth for measure-level assertions (module ↔ `bioMaatregel`, optional +evidence, verified/claimed states), and the existing matrix/coverage +machinery is extended to also key on `bioMaatregel` rather than forking a +parallel "BIO assessment" object. Application-level attributes that are +not measure-specific (BBN level, DPIA status/dates/document, +verwerkingsregister reference) live directly on `module`, since they +describe the application as a whole rather than a per-measure claim. The +overdue-DPIA notification is declared on the `module` schema using the +canonical `x-openregister-notifications` dialect (scheduled trigger + +`withinNext`/`equals` filter operators, matching the working precedent +already in `contract`, `gebruik`, and `moduleVersie`), not a bespoke PHP +notification service (ADR-031). + +## New Dependencies +None. + +## Impact +- `lib/Settings/softwarecatalogus_register.json` — new `bioMaatregel` + schema; `compliancy` and `module` schema extensions; new + `x-openregister-notifications` rule on `module`. +- `src/manifest.json` — new catalog pages for `bioMaatregel`, extended + `module` create/edit form fields, extended `ComplianceMatrixView` + (or an added BIO scope on it) for the BIO coverage report, catalog + filter additions. +- `src/utils/complianceMatrix.js` (or a sibling util) — extended to key + on `bioMaatregel` alongside `standaardversie`. +- Seed data for the `bioMaatregel` catalog (repair-step import, per the + `repair-init` pattern). +- i18n resource files (`nl_NL`, `en_US`). + +## Cross-Project Dependencies +None. This change is self-contained within `softwarecatalog`; no other +Conduction app consumes these schemas or endpoints. + +## Risks + +### Risk 1: Register JSON union-merge can silently drop concurrent edits +**Severity:** Medium — **Mitigation:** `lib/Settings/softwarecatalogus_register.json` +is edited directly against its current merge base (not through a naive +JSON union-merge tool); every edit is diffed against the merge base +before committing, per the project's known union-merge trap. + +### Risk 2: Notification dialect drift +**Severity:** Medium — **Mitigation:** the `dpia-review-overdue` rule +uses only the trigger types and filter operators already proven working +in this register (`scheduled` + `equals`/`withinNext`) and is validated +against `hydra-gate-notification-dialect` before merge; the legacy +dialect is never used. + +### Risk 3: BIO measure catalog content is DIY-published, not a stable API +**Severity:** Low — **Mitigation:** the `bioMaatregel` catalog is seeded +from the published BIO measure list as static seed data (like the +GEMMA `element` catalog), not fetched live from an external source; +re-seeding on a new BIO version is a manual, reviewable data update. + +## Rollback Strategy +Revert the register JSON patch (schema fields, `bioMaatregel` schema, and +the notification rule) and the corresponding manifest/UI changes. Because +the new fields are additive and optional (no `required` constraints +added to `module` or `compliancy`), existing objects remain valid without +migration; a revert leaves previously-entered BIO/DPIA data orphaned in +the database but does not break existing GEMMA compliance functionality. + +## Open Questions +- What DPIA review interval should the catalog assume when computing + `dpiaVolgendeBeoordeling` defaults (BIO/AVG guidance suggests periodic + re-assessment, but no fixed interval is mandated) — left as a + user-set field rather than a computed default in this change; see + DEFERRED_QUESTIONS. +- Whether `verwerkingsregisterRef` should eventually become a typed + relation into a future register-van-verwerkingen module, versus + staying a free-text/URL reference — deferred until that module is + scoped. diff --git a/openspec/changes/bio-compliance-assessment/specs/bio-compliance-assessment/spec.md b/openspec/changes/bio-compliance-assessment/specs/bio-compliance-assessment/spec.md new file mode 100644 index 00000000..3882a57a --- /dev/null +++ b/openspec/changes/bio-compliance-assessment/specs/bio-compliance-assessment/spec.md @@ -0,0 +1,194 @@ +# bio-compliance-assessment Specification + +**Status**: planned +**Scope**: softwarecatalog +**OpenSpec changes**: +- `bio-compliance-assessment` + +## Purpose +Adds the Dutch government security/privacy compliance stack to the +catalog: a seedable BIO 2.0 measures catalog, per-application BBN level +and DPIA tracking, a reference to the organisation's register van +verwerkingen entry, a per-organisation BIO coverage report, catalog +filters, and a declarative notification for overdue DPIA reviews. +Measure-level compliance assertions (module ↔ `bioMaatregel`, with +evidence and verified/claimed states) are handled by the extended +`compliancy` model in `module-compliance-assessment` — this capability +covers the BIO-specific catalog, the application-level BBN/DPIA/ +verwerkingsregister fields, the composite coverage report, filters, and +the notification rule. + +## ADDED Requirements + +### Requirement: BIO measures form a seedable reference catalog + +A `bioMaatregel` object SHALL represent one BIO 2.0 measure, carrying a +`code`, `naam`, `omschrijving`, `thema`, `bioVersie`, the applicable +`bbnNiveau`(s), and a `bron` reference to the published measure. The +catalog SHALL be seedable from the published BIO measure list, following +the same reference-catalog pattern as the GEMMA `element` catalog. The +catalog SHALL be publicly readable so it can be used as a selection +source for `compliancy` records and the BIO coverage report. + +#### Scenario: Catalog is seeded with BIO measures + +- **WHEN** the app is installed or upgraded +- **THEN** the `bioMaatregel` catalog contains the seeded BIO 2.0 measure entries with code, title, theme, and applicable BBN level(s) + +#### Scenario: Measure catalog entry is browsable + +- **WHEN** a user opens the BIO measures catalog +- **THEN** each entry shows its code, title, theme, BIO version, and applicable BBN level(s) +- **AND** activating an entry shows the compliance claims (`compliancy` records) that reference it, mirroring how a GEMMA standard shows its compliance claims + +### Requirement: Each application records a BBN level + +The `module` schema SHALL gain an optional `bbnLevel` field with enum +values `BBN1`, `BBN2`, `BBN3` (the Baseline Informatiebeveiliging +Overheid basic security levels), facetable so it can drive catalog +filters and the BIO coverage report. + +#### Scenario: Vendor sets the BBN level for an application + +- **WHEN** a vendor edits an application and selects a BBN level +- **THEN** the module's `bbnLevel` is stored +- **AND** the application's detail view and catalog listing show the BBN level + +### Requirement: Each application tracks DPIA status and review dates + +The `module` schema SHALL gain `dpiaStatus` (enum: not required, +required, executed), `dpiaDate` (date the DPIA was executed), +`dpiaVolgendeBeoordeling` (date the DPIA is next due for review), and +`dpiaDocumentRef` (an NC Files reference to the DPIA document, following +the `compliancy.bewijsReferentie` link-don't-store pattern). All four +fields SHALL be optional and independently settable; `dpiaDate` and +`dpiaVolgendeBeoordeling` SHALL be meaningful only when `dpiaStatus` is +`executed` but the schema SHALL NOT hard-enforce that ordering (informational, not validated at write time). + +#### Scenario: Vendor records a completed DPIA with document evidence + +- **WHEN** a vendor sets `dpiaStatus` to executed, fills in `dpiaDate`, `dpiaVolgendeBeoordeling`, and links a DPIA document via NC Files +- **THEN** all four fields are stored on the module +- **AND** the application's detail view shows the DPIA status, dates, and a link to open the linked document via Nextcloud Files + +#### Scenario: Application has a required but not yet executed DPIA + +- **WHEN** a vendor sets `dpiaStatus` to required without filling in `dpiaDate` +- **THEN** the application's detail view and catalog listing show the DPIA as required-but-not-executed + +### Requirement: Application references its register van verwerkingen entry + +The `module` schema SHALL gain an optional `verwerkingsregisterRef` +field storing a reference (URL or identifier) to the entry for this +application in the organisation's register van verwerkingen. This +change stores only the reference; it does not model the register +itself. + +#### Scenario: Vendor links the register van verwerkingen entry + +- **WHEN** a vendor sets `verwerkingsregisterRef` on an application +- **THEN** the reference is stored on the module +- **AND** the application's detail view shows the reference as a link when it resolves to a URL + +### Requirement: Catalog can be filtered by BBN level and DPIA status + +Module listings and catalog search SHALL offer filters on `bbnLevel` +and `dpiaStatus`, including a compound filter for "applications without +a DPIA at BBN2 or higher" (i.e. `bbnLevel` in `[BBN2, BBN3]` AND +`dpiaStatus` is not `executed`). + +#### Scenario: Buyer filters modules lacking DPIA at BBN2 or higher + +- **WHEN** a user applies the "without DPIA at BBN2+" filter to the catalog +- **THEN** only modules with `bbnLevel` of BBN2 or BBN3 and a `dpiaStatus` other than executed are listed + +#### Scenario: Buyer filters modules by BBN level alone + +- **WHEN** a user filters the module catalog on a selected BBN level +- **THEN** only modules with that `bbnLevel` are listed + +### Requirement: Organisation BIO coverage is reportable + +For a selected organisation, the app SHALL report BIO coverage over the +organisation's in-use applications (gebruiken → modules), extending the +existing compliance matrix (see `module-compliance-assessment`): per +application, the report SHALL show the BBN level, the DPIA status, and +— for a selected set of BIO measures — whether the module's BIO +compliance is verified, claimed, or absent (reusing the `compliancy` +verified/claimed/none states with the `bioMaatregel` relation). +Applications with no BBN level, no DPIA data, or no BIO measure +compliance data SHALL be listed as such — never omitted. + +#### Scenario: Organisation sees its BIO compliance posture + +- **WHEN** a user selects their organisation and one or more BIO measures in the BIO coverage report +- **THEN** every in-use application is listed with its BBN level, DPIA status, and verified / claimed / none for each selected BIO measure +- **AND** applications without a BBN level, DPIA data, or BIO measure compliance data are visibly listed as having none, not omitted + +### Requirement: Overdue DPIA reviews trigger a notification + +The `module` schema SHALL declare a `dpia-review-overdue` +`x-openregister-notifications` rule using the canonical dialect (see +`softwarecatalog-notifications`): a `scheduled` trigger with a filter +matching modules whose `dpiaStatus` is `executed` and whose +`dpiaVolgendeBeoordeling` is on or before today (`withinNext` with a +zero-day window), dispatching to the `softwarecatalog-admins` group and +the module's manage-ACL holders on the `nc-notification` and `email` +channels, with `nl` and `en` subject strings naming the application and +the due date. + +#### Scenario: Overdue DPIA review notifies admins and record managers + +- **WHEN** a module has `dpiaStatus` executed and `dpiaVolgendeBeoordeling` on or before today, and the scheduled sweep runs +- **THEN** the engine dispatches `nc-notification` + `email` to the `softwarecatalog-admins` group and the module's manage-ACL holders +- **AND** the subject includes the application name and the review-due date in the recipient's locale (nl/en) + +#### Scenario: DPIA with a future review date does not notify + +- **WHEN** a module has `dpiaStatus` executed and `dpiaVolgendeBeoordeling` set to a date after today +- **THEN** the scheduled sweep does not dispatch the `dpia-review-overdue` notification for that module + +## Non-Functional Requirements + +- **Performance:** The BIO coverage report SHALL render for an + organisation with up to 200 in-use applications without a full-page + reload beyond the initial filter selection, consistent with the + existing compliance matrix's performance expectations. +- **Accessibility:** New form fields, filters, and the coverage report's + tri-state cells SHALL meet WCAG AA, reusing the existing compliance + matrix's verified/claimed/none visual distinction (not colour alone). +- **Internationalization:** Dutch and English MUST be supported for all + new labels, filters, and the notification subject strings (ADR-005). + +## Acceptance Criteria + +- [ ] `bioMaatregel` catalog schema exists, is seeded, and is browsable. +- [ ] `compliancy` supports linking a module to a `bioMaatregel` with + evidence, reusing the verified/claimed states (see + `module-compliance-assessment` delta). +- [ ] `module` carries `bbnLevel`, `dpiaStatus`, `dpiaDate`, + `dpiaVolgendeBeoordeling`, `dpiaDocumentRef`, and + `verwerkingsregisterRef`. +- [ ] The catalog can be filtered by BBN level and DPIA status, + including the compound "without DPIA at BBN2+" filter. +- [ ] The BIO coverage report shows BBN level, DPIA status, and BIO + measure compliance per in-use application for a selected + organisation, with no application omitted. +- [ ] The `dpia-review-overdue` notification rule is declared using the + canonical `x-openregister-notifications` dialect and passes + `hydra-gate-notification-dialect`. + +## Notes + +- This capability deliberately does not compute `bbnLevel` or + `dpiaStatus` automatically from other module data — both are + user-entered, matching how the rest of the catalog's compliance + fields work. +- The DPIA review interval is not fixed by this change; + `dpiaVolgendeBeoordeling` is a single user-set date. See + DEFERRED_QUESTIONS. +- `verwerkingsregisterRef` stores a reference only; modelling a full + register van verwerkingen is out of scope (see proposal.md). +- Related ADRs: ADR-001 (OpenRegister-only storage), ADR-005 (i18n), + ADR-009 (tests), ADR-010 (docs), ADR-031 (schema-declarative business + logic — governs the notification rule design). diff --git a/openspec/changes/bio-compliance-assessment/specs/module-compliance-assessment/spec.md b/openspec/changes/bio-compliance-assessment/specs/module-compliance-assessment/spec.md new file mode 100644 index 00000000..d626231c --- /dev/null +++ b/openspec/changes/bio-compliance-assessment/specs/module-compliance-assessment/spec.md @@ -0,0 +1,91 @@ +# module-compliance-assessment (delta) + +This change extends the `compliancy` record model — module ↔ standard +version, evidence, verified-vs-claimed — to also cover BIO 2.0 measures +(`bioMaatregel`), so BIO measure compliance reuses the exact same +mechanism instead of a parallel one. A `compliancy` record now links a +module to either a `standaardversie` or a `bioMaatregel` (never both). +The compliance matrix gains a BIO-measure column source alongside the +existing standard-version columns. Everything else in this capability — +the subscriber pipeline, the catalog standard filter, and the +organisation coverage report mechanics — is unchanged. + +## MODIFIED Requirements + +### Requirement: Compliance records link modules to standard versions with evidence + +A compliance assertion SHALL be a `compliancy` object linking one +`module` to exactly one of: a `standaardversie` (a GEMMA `element` with +`gemmaType=standaardversie`) or a `bioMaatregel` (a BIO 2.0 measure +catalog entry), optionally carrying evidence: the legacy `bewijs` file, +a `bewijsReferentie` NC Files reference (new, optional), or a `url`. The +`standaardversie` relation SHALL be the canonical key for standards +compliance views and the `bioMaatregel` relation SHALL be the canonical +key for BIO measure compliance views; the `standaardGemma` string SHALL +be used only as a fallback for standards records whose relation is +unresolved, and such records SHALL be marked unresolved rather than +merged silently. A record that carries both a `standaardversie` and a +`bioMaatregel` relation SHALL be treated as a data-quality issue and +flagged rather than matched to either column. + +#### Scenario: Supplier records compliance with evidence + +- **WHEN** a user creates a compliancy record linking a module to a standard version and attaches evidence via an NC Files reference +- **THEN** the record is stored in the register with the module and standaardversie relations and the evidence link +- **AND** the module's detail view lists the standard as supported with the evidence accessible + +#### Scenario: Unresolved standard reference is flagged + +@e2e exclude Legacy-data rendering edge; covered by unit tests on the matrix data mapper. + +- **WHEN** a compliancy record has only a `standaardGemma` string and no resolved `standaardversie` relation +- **THEN** compliance views show the record as unresolved instead of matching it to a standard column + +#### Scenario: Supplier records BIO measure compliance with evidence + +- **WHEN** a user creates a compliancy record linking a module to a `bioMaatregel` and attaches evidence via an NC Files reference or URL +- **THEN** the record is stored in the register with the module and `bioMaatregel` relations and the evidence link +- **AND** the module's detail view lists the BIO measure as supported with the evidence accessible + +#### Scenario: A record with both relations set is flagged, not matched + +- **WHEN** a compliancy record has both a `standaardversie` and a `bioMaatregel` relation populated +- **THEN** compliance views flag the record as a data-quality issue +- **AND** the record is not counted toward either the standards matrix or the BIO measure matrix + +### Requirement: Compliance matrix distinguishes verified from claimed + +The app SHALL provide a compliance matrix view of modules × standard +versions or BIO measures — column source selected by the user — in +which every cell shows one of three states: **verified** (a compliancy +record with evidence — `bewijs`, `bewijsReferentie`, or `url`), +**claimed** (a compliancy record without any evidence), or **none**. The +verified and claimed states SHALL be visually distinct, and a verified +or claimed cell SHALL open the underlying compliancy record with its +evidence. The matrix SHALL be filter-first: the user selects a column +source (standard versions or BIO measures) and the specific columns +(and optionally a module subset or organisation scope) before cells +render; the selection SHALL be encoded in the page URL so a comparison +is shareable. + +#### Scenario: Matrix renders the three cell states + +- **WHEN** a user selects standards in the matrix view covering modules with evidenced, unevidenced, and absent compliance records +- **THEN** the corresponding cells render as verified, claimed, and none respectively, with verified and claimed visually distinct + +#### Scenario: Cell opens the evidence + +- **WHEN** a user activates a verified cell +- **THEN** the underlying compliancy record is shown with its evidence link or file +- **AND** an NC Files-referenced evidence document opens via Nextcloud Files + +#### Scenario: Matrix selection is shareable + +- **WHEN** a user opens a matrix URL containing an encoded standards/module selection +- **THEN** the matrix renders that same selection without re-picking filters + +#### Scenario: Matrix renders BIO measure columns + +- **WHEN** a user switches the matrix's column source to BIO measures and selects one or more `bioMaatregel` entries covering modules with evidenced, unevidenced, and absent compliance records +- **THEN** the corresponding cells render as verified, claimed, and none respectively, using the same visual states as the standards matrix +- **AND** the BIO-measure selection is encoded in the page URL so it is shareable diff --git a/openspec/changes/bio-compliance-assessment/tasks.md b/openspec/changes/bio-compliance-assessment/tasks.md new file mode 100644 index 00000000..9a29a513 --- /dev/null +++ b/openspec/changes/bio-compliance-assessment/tasks.md @@ -0,0 +1,99 @@ +# Tasks: bio-compliance-assessment + +## Implementation Tasks + +### Task 1: Register schema — bioMaatregel catalog + compliancy extension +- **spec_ref**: `openspec/changes/bio-compliance-assessment/specs/bio-compliance-assessment/spec.md#requirement-bio-measures-form-a-seedable-reference-catalog`, `openspec/changes/bio-compliance-assessment/specs/module-compliance-assessment/spec.md#requirement-compliance-records-link-modules-to-standard-versions-with-evidence` +- **files**: `lib/Settings/softwarecatalogus_register.json` +- **acceptance_criteria**: + - GIVEN the register file WHEN a new `bioMaatregel` schema is added (code, naam, omschrijving, thema, bioVersie, bbnNiveau, bron; `authorization.read: ["public"]`) THEN it validates as OpenAPI 3.0.0 and imports cleanly + - GIVEN the `compliancy` schema WHEN an optional `bioMaatregel` relation is added (parallel to `standaardversie`, `objectConfiguration.handling: related-object`) THEN existing `compliancy` objects remain valid (no new `required` fields) + - Diff the edit against the current merge base before committing — a naive JSON union-merge can silently drop unrelated prior modifications to this file +- [ ] Implement +- [ ] Test + +### Task 2: Register schema — module BBN/DPIA/verwerkingsregister fields + overdue-DPIA notification rule +- **spec_ref**: `openspec/changes/bio-compliance-assessment/specs/bio-compliance-assessment/spec.md#requirement-each-application-records-a-bbn-level`, `openspec/changes/bio-compliance-assessment/specs/bio-compliance-assessment/spec.md#requirement-each-application-tracks-dpia-status-and-review-dates`, `openspec/changes/bio-compliance-assessment/specs/bio-compliance-assessment/spec.md#requirement-application-references-its-register-van-verwerkingen-entry`, `openspec/changes/bio-compliance-assessment/specs/bio-compliance-assessment/spec.md#requirement-overdue-dpia-reviews-trigger-a-notification` +- **files**: `lib/Settings/softwarecatalogus_register.json` +- **acceptance_criteria**: + - GIVEN the `module` schema WHEN `bbnLevel` (enum BBN1/BBN2/BBN3, `facetable: true`), `dpiaStatus`, `dpiaDate`, `dpiaVolgendeBeoordeling`, `dpiaDocumentRef`, `verwerkingsregisterRef` are added THEN all six are optional and existing `module` objects remain valid + - GIVEN the `module` schema WHEN the `dpia-review-overdue` `x-openregister-notifications` rule is added (`scheduled` trigger, filter `dpiaStatus: executed` + `dpiaVolgendeBeoordeling: {operator: withinNext, value: "P0D"}`, channels `nc-notification`+`email`, recipients `softwarecatalog-admins` group + object-acl manage, nl/en subjects) THEN it passes `hydra-gate-notification-dialect` + - Diff against the current merge base before committing (same union-merge trap as Task 1) +- [ ] Implement +- [ ] Test + +### Task 3: Seed the BIO measure catalog +- **spec_ref**: `openspec/changes/bio-compliance-assessment/specs/bio-compliance-assessment/spec.md#requirement-bio-measures-form-a-seedable-reference-catalog` +- **files**: `lib/Settings/softwarecatalogus_register.json` (or repair-step seed data alongside it, following the existing `element`/GEMMA seed pattern), `lib/Migration/InitializeSettings.php` (repair step, if seed objects are inserted there rather than via register defaults) +- **acceptance_criteria**: + - GIVEN a fresh install WHEN the repair step runs THEN the `bioMaatregel` catalog contains the seeded BIO 2.0 measures (see design.md Seed Data) + - GIVEN an upgrade on an existing install WHEN the repair step re-runs THEN seeding is idempotent (no duplicate entries) +- [ ] Implement +- [ ] Test + +### Task 4: Frontend — BBN/DPIA/verwerkingsregister fields on the module form +- **spec_ref**: `openspec/changes/bio-compliance-assessment/specs/bio-compliance-assessment/spec.md#requirement-each-application-records-a-bbn-level`, `openspec/changes/bio-compliance-assessment/specs/bio-compliance-assessment/spec.md#requirement-each-application-tracks-dpia-status-and-review-dates`, `openspec/changes/bio-compliance-assessment/specs/bio-compliance-assessment/spec.md#requirement-application-references-its-register-van-verwerkingen-entry` +- **files**: `src/manifest.json` (ModuleDetail data widget `include` list) +- **acceptance_criteria**: + - GIVEN a vendor editing an application WHEN they set `bbnLevel`, `dpiaStatus`, `dpiaDate`, `dpiaVolgendeBeoordeling`, a `dpiaDocumentRef` via NC Files, and `verwerkingsregisterRef` THEN all six persist and render on the application's detail view +- [ ] Implement +- [ ] Test + +### Task 5: Frontend — BIO measures catalog pages + BIO measure compliance on compliancy form +- **spec_ref**: `openspec/changes/bio-compliance-assessment/specs/bio-compliance-assessment/spec.md#requirement-bio-measures-form-a-seedable-reference-catalog`, `openspec/changes/bio-compliance-assessment/specs/module-compliance-assessment/spec.md#requirement-compliance-records-link-modules-to-standard-versions-with-evidence` +- **files**: `src/manifest.json` (new `BioMaatregelen` index + `BioMaatregelDetail` detail pages, following the `element`/`Standaarden` pattern; `KompliantieDetail` widget updates for the `bioMaatregel` relation) +- **acceptance_criteria**: + - GIVEN a user opens the BIO measures catalog THEN each entry shows code, title, theme, BIO version, applicable BBN level(s), and its linked compliance claims + - GIVEN a user creates or edits a compliancy record THEN they can link it to a `bioMaatregel` instead of a `standaardversie`, with the same evidence fields +- [ ] Implement +- [ ] Test + +### Task 6: Frontend — BIO coverage report (extends ComplianceMatrixView) +- **spec_ref**: `openspec/changes/bio-compliance-assessment/specs/bio-compliance-assessment/spec.md#requirement-organisation-bio-coverage-is-reportable`, `openspec/changes/bio-compliance-assessment/specs/module-compliance-assessment/spec.md#requirement-compliance-matrix-distinguishes-verified-from-claimed` +- **files**: `src/views/ComplianceMatrixView.vue`, `src/utils/complianceMatrix.js` +- **acceptance_criteria**: + - GIVEN a user selects a BIO column source and an organisation scope in the matrix THEN each in-use application row shows its BBN level, DPIA status, and verified/claimed/none for each selected BIO measure + - GIVEN an in-use application has no BBN level, DPIA data, or BIO measure compliance THEN it is listed with an explicit "none" state, never omitted + - GIVEN a matrix URL with an encoded BIO selection THEN opening it renders the same selection without re-picking filters +- [ ] Implement +- [ ] Test + +### Task 7: Frontend — catalog filter for BBN level / DPIA status +- **spec_ref**: `openspec/changes/bio-compliance-assessment/specs/bio-compliance-assessment/spec.md#requirement-catalog-can-be-filtered-by-bbn-level-and-dpia-status` +- **files**: `src/manifest.json` (Modules index filter config), module catalog/search filter component +- **acceptance_criteria**: + - GIVEN a user applies the "without DPIA at BBN2+" filter THEN only modules with `bbnLevel` BBN2/BBN3 and `dpiaStatus` not executed are listed + - GIVEN a user filters by `bbnLevel` alone THEN only modules with that level are listed +- [ ] Implement +- [ ] Test + +### Task 8: i18n — Dutch and English strings +- **spec_ref**: `openspec/changes/bio-compliance-assessment/specs/bio-compliance-assessment/spec.md` (all requirements — new labels), `openspec/changes/bio-compliance-assessment/specs/module-compliance-assessment/spec.md` +- **files**: `l10n/nl.json`, `l10n/en.json` (or the app's existing i18n resource files) +- **acceptance_criteria**: + - GIVEN the new field labels, filter labels, BIO measures catalog page, coverage report labels, and the `dpia-review-overdue` subject strings THEN both `nl_NL` and `en_US` translations exist and no new user-facing string is hardcoded +- [ ] Implement +- [ ] Test + +### Task 9: Tests and documentation +- **spec_ref**: all requirements in both delta specs +- **files**: `tests/Unit/` (register import / schema validation, `complianceMatrix.js` mapper tests for the `bioMaatregel` column source), `docs/features/bio-compliance-assessment.md` + screenshots +- **acceptance_criteria**: + - GIVEN the register import test suite WHEN it runs against the extended register THEN `bioMaatregel`, the `compliancy` extension, and the `module` field additions all validate + - GIVEN the matrix mapper unit tests WHEN a `bioMaatregel` column source is used THEN verified/claimed/none states compute identically to the `standaardversie` path + - GIVEN the notification rule WHEN validated against `hydra-gate-notification-dialect` THEN it passes + - GIVEN the feature docs WHEN published THEN they include Playwright MCP screenshots of the module BBN/DPIA fields, the BIO measures catalog, the BIO coverage report, and the DPIA filter + - New/changed business logic reaches ≥75% coverage (ADR-009) +- [ ] Implement +- [ ] Test + +## Quality checklist + +- All new/changed business logic covered by PHPUnit unit tests (`tests/Unit/`), minimum 75% coverage for new code (ADR-009) +- New/changed API surface covered by Newman/Postman tests if any backend endpoint changes (none expected — this app queries OpenRegister directly from the frontend) +- UI changes covered by Playwright browser tests +- All tests pass (`composer test`, `newman run`) +- Feature documentation updated in `docs/features/` with Playwright MCP screenshots (ADR-010) +- Dutch (`nl_NL`) and English (`en_US`) translation strings added for every new user-facing string (ADR-005) +- Register JSON edits (Tasks 1–3) are diffed against the merge base, not produced by a naive union-merge, before committing +- `openspec validate --change bio-compliance-assessment` passes diff --git a/openspec/changes/eol-feed-integration/.openspec.yaml b/openspec/changes/eol-feed-integration/.openspec.yaml new file mode 100644 index 00000000..cf371d3a --- /dev/null +++ b/openspec/changes/eol-feed-integration/.openspec.yaml @@ -0,0 +1,2 @@ +schema: conduction +created: 2026-07-23 diff --git a/openspec/changes/eol-feed-integration/context-brief.md b/openspec/changes/eol-feed-integration/context-brief.md new file mode 100644 index 00000000..b937b76f --- /dev/null +++ b/openspec/changes/eol-feed-integration/context-brief.md @@ -0,0 +1,26 @@ +# Context Brief: eol-feed-integration + +## What +Data-driven end-of-life dates: match catalog products/module versions to **endoflife.date** product cycles and stamp `eolDate` / `supportEndDate` on module versions automatically. Primary data path: an OpenRegister register populated by the **openconnector `endoflife-date-source`** (sibling change in the openconnector repo). Softwarecatalog owns: the product↔eol-product mapping config, the matcher service, a scheduled refresh job, and surfacing (approaching-EOL indicators become feed-driven instead of manual). + +## Why (evidence) +- End-of-life-tracking is a full Specter research domain (164); today EOL dates are manually entered. +- endoflife.date: 460+ products, public JSON API + iCal — the canonical open EOL source (logged in Specter external_sources). +- VNG #54 portfolio statistics include EOL exposure; lifecycle tracking spec has approaching-EOL filters that are only as good as their data. +- Specter canonical features: `eol-feed-integration` (softwarecatalog, should, 7) + `endoflife-date-source` (openconnector leaf, should, 7). + +## Current state (read these specs first) +- `openspec/specs/application-lifecycle-tracking` — EOL indicators + approaching-EOL filter + lifecycle notifications; this change feeds those fields. +- `openspec/specs/module-vulnerability-tracking` — "optional CVE feed via openconnector" is the established integration-leaf pattern; FOLLOW IT: integration transport lives in openconnector, consumption/matching lives here. +- `openspec/specs/settings-admin-controller` — sync config + cron patterns; `cronjob-context` spec for background jobs. + +## Scope +IN: eolProduct mapping config (per product: endoflife.date product slug, stored on the product object or a mapping schema), matcher service (map cycle rows → module versions by version prefix match, conservative: only stamp when unambiguous), scheduled background job + manual trigger endpoint (admin settings), read path from the EOL register (register/schema names configurable in settings, defaulting to what the openconnector change provisions), fallback state when openconnector/register absent (feature degrades gracefully to manual entry — NO direct HTTP fetching from this app), provenance on stamped fields (source: endoflife.date + fetched-at), approaching-EOL views unchanged but now populated, i18n, tests (matcher unit tests with fixture cycles), docs. +OUT: direct HTTP calls to endoflife.date from softwarecatalog (that is openconnector's job), CVE data, license data. + +## Design constraints +- ADR-001 OR storage; ADR-011 check OpenRegister core first; integrations belong in openconnector (do NOT embed an HTTP client for the feed here). +- OR saveObject PUT-semantic — stamping eolDate must carry all module-version fields forward. +- Background job: NC 34 background-job registration gotchas apply; follow cronjob-context spec. +- ADR-012 Cn components; ADR-005 i18n; ADR-009 tests; ADR-010 docs. +- OpenSpec delta headers MUST be `### Requirement: `. diff --git a/openspec/changes/eol-feed-integration/design.md b/openspec/changes/eol-feed-integration/design.md new file mode 100644 index 00000000..6c0263e8 --- /dev/null +++ b/openspec/changes/eol-feed-integration/design.md @@ -0,0 +1,242 @@ +# Design: eol-feed-integration + +## Architecture Overview + +``` +openconnector (sibling repo, optional) + endoflife-date-source: Source + Synchronization + Mapping + → fetches https://endoflife.date/api (all.json + per-product cycles) + → upserts OpenRegister objects: eolProduct, eolCycle + (in a register, name configurable; e.g. "eol-lifecycle") + │ + │ read-only, via ObjectService — NO HTTP here + ▼ +softwarecatalog (this change) + module.eolProductSlug ──────────┐ (mapping config, per product) + │ + EolSyncJob (background) ───► EolSyncService ───► EolMatcherService + SettingsController "sync now" ───┘ │ + ▼ + moduleVersie.datumEindeOndersteuning + moduleVersie.eolBron + moduleVersie.eolBijgewerktOp + │ + ▼ + application-lifecycle-tracking (unchanged): + EOL indicator, EOL-approaching filter, roadmap, + eol-approaching notification rule +``` + +The matcher never talks to endoflife.date. It reads two register/schemas that +openconnector's `endoflife-date-source` change provisions, through the same +`ObjectService`/`ConfigurationService` accessors this app already uses for +every other OR read (`Uses OpenRegister API directly` project rule extends to +backend service reads, not just the frontend). + +## Goals / Non-Goals + +**Goals**: data-driven `datumEindeOndersteuning`, conservative/safe matching, +graceful degradation with zero coupling to whether openconnector is +installed, provenance so stamped values are distinguishable from +hand-entered ones, no new outbound network surface in softwarecatalog. + +**Non-Goals**: fetching endoflife.date directly (openconnector's job); +changing how EOL state is derived/filtered/notified +(`application-lifecycle-tracking` is unmodified); auto-matching +`datumTeruggetrokken`; fuzzy/best-effort version matching (ambiguous cases are +always left for a human). + +## Decisions + +### Decision 1 — Mapping config lives on `module`, not a separate schema + +**Choice**: add one optional field, `eolProductSlug`, directly to the existing +`module` schema (the same object `moduleVersie.module` already points at). + +**Alternatives considered**: a standalone `eolMapping` schema linking +`module` → endoflife.date slug. Rejected — `module-vulnerability-tracking` +and `application-lifecycle-tracking` both established the pattern of adding a +narrow optional field to an existing schema rather than introducing a new +join schema for a 1:1 config value (ADR-011: check existing shape first). One +field, one admin action ("set the endoflife.date slug for this product"), no +extra CRUD surface. + +### Decision 2 — Matching is conservative: unambiguous single-candidate only + +**Choice**: `EolMatcherService::match(module)` fetches all `eolCycle` rows for +`module.eolProductSlug`, and for each `moduleVersie.versie` on that module, +selects candidate cycles whose `cycle` value is a version-prefix of (or +exactly equals) the module version string (e.g. version `21.3.1` matches +cycle `21.3` and `21`, ranked most-specific-first). A stamp is only written +when **exactly one** cycle at the most-specific matching level exists; any +tie or zero-candidate result is skipped and left untouched. + +**Alternatives considered**: best-effort "closest" match with a confidence +score. Rejected per the context brief's explicit "conservative, +unambiguous" requirement and Risk 1 in the proposal — a wrong EOL date +silently shown as authoritative (with provenance implying it's sourced) is +worse than no date. Skipped versions remain exactly as visible/editable as +they are today (no regression versus manual-only). + +### Decision 3 — Stamping preserves OR's PUT semantics + +**Choice**: `EolMatcherService` never constructs a partial payload. It reads +the full current `moduleVersie` object, sets +`datumEindeOndersteuning`/`eolBron`/`eolBijgewerktOp` on the in-memory copy, +and calls `saveObject()` with the complete object — every other field +(`versie`, `status`, `gebruiken`, etc.) is carried forward unchanged. + +**Rationale**: `reference_or-saveobject-put-semantic-nulls-omitted` — OR's +`saveObject` is PUT-semantic; omitted properties are nulled, not left alone. +This is the same discipline `application-lifecycle-tracking`'s replacement +fields and `module-vulnerability-tracking`'s manifest CRUD already rely on. +A regression test asserts an unrelated field (e.g. `beschrijvingKort`) +survives a stamp. + +### Decision 4 — Provenance fields, not a provenance schema + +**Choice**: two new optional fields on `moduleVersie`: `eolBron` (string, +default `"endoflife.date"` when stamped by this feature, absent when +hand-entered) and `eolBijgewerktOp` (date-time, set to the sync run's +timestamp). Both are only ever written by `EolMatcherService`; a user editing +`datumEindeOndersteuning` by hand does not set or clear them automatically +(so a subsequent overwrite by a human is visible as "no longer feed-sourced" +only if the admin also clears provenance — acceptable, since the fields exist +purely as an informational trail, not a lock). + +**Alternatives considered**: a generic `_provenance` envelope object. +Rejected — two flat fields are enough for this single source, consistent +with how `moduleVersie` already models dates as flat fields rather than +structured sub-objects, and avoids inventing new schema conventions for one +feature. + +### Decision 5 — Register/schema names are settings, not constants + +**Choice**: the EOL sync config domain (new, alongside the existing sync/ +cronjob domains in `settings-admin-controller`) stores the register slug and +the `eolProduct`/`eolCycle` schema slugs as configurable strings, with +defaults matching what `endoflife-date-source` provisions. `EolSyncService` +resolves these via `ConfigurationService` at run time, never a hardcoded +schema ID. + +**Rationale**: openconnector and softwarecatalog are separate repos/release +trains (ADR-011); hardcoding schema slugs would silently break if the +openconnector change ships with different names, with no recovery path short +of a code change. A settings field is the same recovery path the existing +sync config already uses for other cross-app register wiring. + +### Decision 6 — Absence is a first-class, silent-by-default state + +**Choice**: if the configured register/schema cannot be resolved (register +missing, schema missing, or the EOL sync toggle is off), `EolSyncService` +returns a status object (`available: false`, `reason`) and neither the +background job nor the manual trigger raises an error to the end user. The +settings status endpoint surfaces this so an admin can tell "not configured" +apart from "configured but nothing matched yet" apart from "ran, N matched, M +skipped". + +**Rationale**: mirrors `module-vulnerability-tracking`'s "core capability +works with no feed configured" requirement — manual `datumEindeOndersteuning` +entry, the EOL-approaching filter, the roadmap, and the notification rule all +already work with zero code from this change; this feature must never be a +precondition for them. + +## Risks / Trade-offs + +- [Prefix matching false-positive across products with identically-shaped + version strings, e.g. `1.0` matching an unrelated product's cycle] → + Mitigation: matching is always scoped to the module's own + `eolProductSlug`-selected cycle set, never cross-product; the ambiguity + guard (Decision 2) also catches shape collisions within one product. +- [Two new optional fields on a schema with existing production data] → + Mitigation: additive-only register change, same low-risk shape as + `application-lifecycle-tracking`'s `geplandeVervanging` addition; no + migration required (see Migration Plan below). +- [Background job load if a catalog maps hundreds of products] → Mitigation: + the job iterates modules with `eolProductSlug` set only (typically a small + subset), and per-module the register read is a single filtered query + (`eolCycle` where `product = slug`), not a full-register scan. + +## Migration Plan + +No Nextcloud `lib/Migration/` class is introduced — per ADR-001 this app owns +no custom database tables, and the new fields are additive optional +properties on the existing OpenRegister-backed `module`/`moduleVersie` +schemas. They ship in `lib/Settings/softwarecatalogus_register.json` and are +applied the same way every other schema change in this app is: imported via +`ConfigurationService::importFromApp()` in the repair step +(`repair-init` spec), which existing objects survive unchanged (both fields +optional, no default that would alter current records). Rollback is deleting +the two field definitions from the register JSON — existing stamped values on +already-saved objects are unaffected either way (OR does not retroactively +strip data on a schema-definition change). + +## Nextcloud Integration + +- **Controllers**: `SettingsController` gains `getEolSyncConfig()` / + `updateEolSyncConfig()`, `triggerEolSync()`, `getEolSyncStatus()` — + same pattern as the existing sync/cronjob endpoint pairs. +- **Services**: `EolSyncService` (orchestration: resolve config → call + matcher → aggregate status), `EolMatcherService` (pure matching + stamping + logic, unit-testable with fixture cycle arrays and no OCP dependencies). +- **Background job**: `EolSyncJob extends TimedJob`, registered in + `appinfo/info.xml` background-jobs section (NC 34 registration gotchas + apply — verify against `cronjob-context`'s existing job registration), + runs in system context (no RBAC), interval read from the EOL sync config. +- **Mappers/Entities**: none new — reads/writes go through OpenRegister's + `ObjectService`, not app-local entities/mappers (ADR-022, consistent with + `module-vulnerability-tracking` Decision 1). +- **Events/Hooks**: none introduced; this stays a pull (scheduled + + manual-trigger) design, not event-driven, matching `cronjob-context`. + +## Security Considerations + +The manual trigger endpoint requires the same admin/settings authorization as +every other `settings-admin-controller` endpoint (existing NC settings +auth — `#[AuthorizedAdminSetting]` / `NoAdminRequired` pattern already used by +the sync endpoints; no new auth pattern introduced). The matcher performs no +outbound HTTP and accepts no user-supplied URLs, eliminating SSRF risk by +construction — it only reads objects from OpenRegister via the standard +authorization already enforced by `ObjectService`. No new PII surface: cycle +data (versions, dates) carries no personal data. + +## File Structure + +``` +lib/ + Settings/ + softwarecatalogus_register.json (+ eolProductSlug on module, + eolBron/eolBijgewerktOp on moduleVersie) + Service/ + EolMatcherService.php (new — pure matching/stamping logic) + EolSyncService.php (new — orchestration + status) + SettingsService.php (+ EOL sync config get/update) + Controller/ + SettingsController.php (+ EOL sync endpoints) + BackgroundJob/ + EolSyncJob.php (new) +src/ + views/Settings/ (+ EOL source config panel: register/ + schema names, enable toggle, sync-now + button, last-run status) + store/ (+ settings store actions for the new + endpoints, following fe-stores pattern) +l10n/ + en.js / en.json / nl.js / nl.json (+ new settings + status strings) +tests/ + Unit/Service/EolMatcherServiceTest.php (fixture cycles: unambiguous match, + ambiguous/tie, no match, prefix + overlap across major versions) +docs/features/eol-feed-integration.md +``` + +## Trade-offs + +Considered building the matcher as a pure frontend computation (fetch both +registers client-side, match in Vue) instead of a backend service + job. +Rejected: a scheduled background job needs a backend entry point regardless +(`cronjob-context` pattern), and doing the match server-side keeps the +conservative-matching logic in one testable PHP unit rather than duplicated +between a job and a browser-triggered path — the manual "sync now" button +simply invokes the same backend service the job calls, avoiding drift between +the two trigger paths. diff --git a/openspec/changes/eol-feed-integration/proposal.md b/openspec/changes/eol-feed-integration/proposal.md new file mode 100644 index 00000000..26412018 --- /dev/null +++ b/openspec/changes/eol-feed-integration/proposal.md @@ -0,0 +1,85 @@ +--- +kind: feature +depends_on: [] +--- + +# softwarecatalog — EOL feed integration (endoflife.date matching) + +## Why + +End-of-life tracking is a full Specter research domain (164) and a recurring +competitor table-stake (Snipe-IT, GLPI, i-doit, Device42, ServiceNow, Flexera +all ship it), but today `moduleVersie.datumEindeOndersteuning` is entered by +hand — it is only as fresh and complete as whoever last edited it. +endoflife.date is a canonical, free, public source (460+ products, JSON API) +already logged in Specter `external_sources`, and the +`module-vulnerability-tracking` change already established the safe pattern +for consuming an external feed: transport lives in openconnector, matching +and consumption live in the leaf app. This change applies that same pattern +to EOL data, closing the gap between what `application-lifecycle-tracking`'s +EOL indicators, EOL-approaching filter, roadmap, and `eol-approaching` +notification rule *can* show and what data actually populates them. + +Specter canonical features: `eol-feed-integration` (softwarecatalog, should, +demand 7) + `endoflife-date-source` (openconnector leaf, should, demand 7). + +## What Changes + +1. **Mapping config.** `module` gains an optional `eolProductSlug` field — + the endoflife.date product identifier a catalog product corresponds to. +2. **Matcher.** A new `EolMatcherService` reads `eolProduct`/`eolCycle` + objects from a configurable OpenRegister register (defaults matching what + the sibling openconnector `endoflife-date-source` change provisions) via + `ObjectService` — no HTTP client in this app. For each mapped module's + `moduleVersie` records, it matches `versie` against cycle values by + version-prefix and **only stamps on an unambiguous single-candidate + match**; ties and no-matches are left untouched, never guessed. +3. **Stamping preserves PUT semantics.** A match sets + `datumEindeOndersteuning` plus two new provenance fields, `eolBron` + (source) and `eolBijgewerktOp` (fetched-at), while re-saving the complete + existing `moduleVersie` object so every other field survives (OR + `saveObject` is PUT-semantic). +4. **Scheduled job + manual trigger.** `EolSyncJob` (background job, system + context, `cronjob-context` pattern) re-runs the match on a configurable + interval; `SettingsController` gains a manual "sync now" endpoint that + invokes the same logic on demand, alongside get/update endpoints for the + EOL sync config and a status endpoint. +5. **Graceful degradation.** When the configured register/schema can't be + resolved (openconnector not installed, or sync disabled), the matcher + no-ops silently, status reports "unavailable" with a reason, and manual + `datumEindeOndersteuning` entry / the EOL-approaching filter / roadmap / + notification rule keep working exactly as today — none of them require + this feature. +6. **No direct HTTP.** Softwarecatalog performs no outbound call to + endoflife.date or any EOL feed; all fetching lives in the openconnector + `endoflife-date-source` change. + +`application-lifecycle-tracking` is **not modified** — this change only +improves what populates the field it already reads. + +## Impact + +- **New**: `EolMatcherService`, `EolSyncService`, `EolSyncJob`; EOL sync + config/status/manual-trigger endpoints on `SettingsController`/ + `SettingsService`; a settings panel + module-form field on the frontend. +- **Schema (additive only)**: `module.eolProductSlug`, + `moduleVersie.eolBron`, `moduleVersie.eolBijgewerktOp` — all optional; + existing objects load and save unchanged. No Nextcloud migration class + (ADR-001, no custom tables; applied via the existing register-JSON + + repair-step import path). +- **Unchanged**: `application-lifecycle-tracking`'s derivation, filters, + roadmap, and notification rule; `module-vulnerability-tracking`; license + data. No new outbound network dependency in softwarecatalog. +- **Risk**: low-medium — the only correctness risk is a wrong stamp from + ambiguous matching, mitigated by the conservative single-candidate-only + rule (fixture-tested); everything else is additive and degrades to the + current manual-only behaviour when the feed is absent. + +## Dependencies + +Depends at runtime (optionally) on the **openconnector** +`endoflife-date-source` change (sibling repo) for the `eolProduct`/ +`eolCycle` register — softwarecatalog's own capability (manual EOL entry, +filters, roadmap, notifications) is fully functional without it. ADR-001 (OR +storage), ADR-011 (check OR core / existing schema shape first), ADR-012 +(Cn components), ADR-005 (i18n), ADR-009 (tests), ADR-010 (docs). diff --git a/openspec/changes/eol-feed-integration/specs/eol-feed-integration/spec.md b/openspec/changes/eol-feed-integration/specs/eol-feed-integration/spec.md new file mode 100644 index 00000000..c406dfe3 --- /dev/null +++ b/openspec/changes/eol-feed-integration/specs/eol-feed-integration/spec.md @@ -0,0 +1,213 @@ +# eol-feed-integration Specification + +**Status**: planned +**Scope**: softwarecatalog +**OpenSpec changes**: +- eol-feed-integration + +## Purpose +Makes `moduleVersie.datumEindeOndersteuning` data-driven by matching catalog +products to endoflife.date product cycles ingested through an optional +openconnector source, so the existing EOL indicators, EOL-approaching filter, +roadmap, and notification rule declared in `application-lifecycle-tracking` +are populated from a maintained external feed instead of manual entry alone, +while softwarecatalog itself performs no outbound HTTP (ADR-011, +`module-vulnerability-tracking`'s "integration transport lives in +openconnector" pattern). + +## ADDED Requirements + +### Requirement: Products are mapped to endoflife.date via per-module config + +Each `module` SHALL gain an optional `eolProductSlug` field identifying its +corresponding endoflife.date product identifier. The EOL matcher SHALL only +process a module when `eolProductSlug` is set; modules without it SHALL be +left entirely alone (no read, no write). The register and schema names used +to read `eolProduct`/`eolCycle` data SHALL be configurable in settings, +defaulting to the names the openconnector `endoflife-date-source` change +provisions. + +#### Scenario: A mapped module is eligible for matching + +- **WHEN** an admin sets `eolProductSlug` on a `module` (e.g. `postgresql`) +- **THEN** the EOL matcher includes that module in its next run +- **AND** modules with no `eolProductSlug` set are skipped without any read + or write against them + +#### Scenario: Register and schema names are configurable, not hardcoded + +- **WHEN** an admin opens the EOL sync settings panel +- **THEN** the register slug and the `eolProduct`/`eolCycle` schema slugs are + editable fields, pre-filled with the defaults matching the openconnector + `endoflife-date-source` change's provisioned names +- **AND** changing them takes effect on the next sync without a code change + +### Requirement: Version matching is conservative and unambiguous only + +The matcher SHALL compare a `moduleVersie.versie` string against the `cycle` +values of the mapped module's `eolCycle` rows using version-prefix matching +(most-specific level first) and SHALL stamp a value **only** when exactly one +cycle matches at the most-specific level. When zero cycles match, or more +than one cycle matches at the same most-specific level (an ambiguous tie), +the matcher SHALL skip that `moduleVersie` and leave its existing fields +untouched. + +#### Scenario: Unambiguous match stamps the version + +- **WHEN** a `moduleVersie` with `versie` `21.3.1` is matched against + `eolCycle` rows containing exactly one cycle `21.3` for the mapped product +- **THEN** that `moduleVersie` is stamped from the `21.3` cycle's `eol` date + +#### Scenario: Ambiguous match is skipped, not guessed + +- **WHEN** a `moduleVersie` with `versie` `2` matches two candidate cycles + (`2.0` and `2.1`) at the same most-specific level with no single + most-specific winner +- **THEN** the matcher does not stamp that `moduleVersie` +- **AND** its existing `datumEindeOndersteuning` (or absence thereof) is + unchanged + +#### Scenario: No match leaves the version untouched + +- **WHEN** a `moduleVersie`'s `versie` matches no cycle for the mapped + product +- **THEN** the matcher does not stamp that `moduleVersie` +- **AND** the version remains available for manual `datumEindeOndersteuning` + entry exactly as before this feature existed + +### Requirement: Stamping preserves every other field and records provenance + +When the matcher stamps a `moduleVersie`, it SHALL read the complete current +object, set `datumEindeOndersteuning` from the matched cycle's `eol` date +together with `eolBron` (source identifier, e.g. `endoflife.date`) and +`eolBijgewerktOp` (the sync run's timestamp), and save the complete object — +every other existing field on that `moduleVersie` (including but not limited +to `versie`, `status`, `gebruiken`) SHALL be carried forward unchanged, per +OpenRegister's PUT-semantic `saveObject`. + +#### Scenario: An unrelated field survives a stamp + +- **WHEN** a `moduleVersie` with an existing `beschrijvingKort` value is + matched and stamped +- **THEN** the saved object's `datumEindeOndersteuning`, `eolBron`, and + `eolBijgewerktOp` reflect the match +- **AND** `beschrijvingKort` and every other previously-set field are + unchanged on the saved object + +#### Scenario: Provenance distinguishes feed-sourced dates from manual entry + +- **WHEN** a user views a `moduleVersie` whose `datumEindeOndersteuning` was + set by the matcher +- **THEN** `eolBron` and `eolBijgewerktOp` are present and identify the value + as feed-sourced +- **AND** a `moduleVersie` whose `datumEindeOndersteuning` was entered by + hand has no `eolBron`/`eolBijgewerktOp` set + +### Requirement: EOL sync runs on a schedule with a manual trigger + +The matcher SHALL run as a Nextcloud background job on a configurable +interval, operating in system (non-RBAC) context per the `cronjob-context` +pattern, and SHALL also be runnable on demand via a manual "sync now" +endpoint on the settings admin controller. Both trigger paths SHALL invoke +the same underlying sync/match logic. + +#### Scenario: The scheduled job runs the match + +- **WHEN** the EOL background job's configured interval elapses +- **THEN** it runs the matcher across all modules with `eolProductSlug` set +- **AND** it records a status summary (matched count, skipped count, + last-run timestamp) + +#### Scenario: An admin triggers a sync manually + +- **WHEN** an admin calls the manual EOL sync trigger from settings +- **THEN** the same match logic runs immediately, outside the scheduled + interval +- **AND** the resulting status is returned and reflected in the settings + status view + +### Requirement: The feature degrades gracefully when the feed is unavailable + +The matcher SHALL make no changes and SHALL NOT raise an error to the end +user when the configured EOL register or schema cannot be resolved +(openconnector not installed, register/schema missing, or the sync is +disabled in settings). The settings status SHALL report the feed as +unavailable with a reason, distinct from "configured but zero matches yet". +Manual entry of `datumEindeOndersteuning`, the EOL-approaching filter, the +roadmap, and the `eol-approaching` notification rule (all declared in +`application-lifecycle-tracking`) SHALL continue to function fully +regardless of feed availability. + +#### Scenario: Missing register degrades to manual-only, not an error + +- **WHEN** the EOL sync runs (scheduled or manual) and the configured + register/schema cannot be found +- **THEN** no `moduleVersie` is modified and no error is surfaced to the user +- **AND** the settings status shows the feed as unavailable with a reason + +#### Scenario: Core lifecycle capability is unaffected by feed absence + +- **WHEN** the openconnector `endoflife-date-source` change is not installed +- **THEN** users can still enter `datumEindeOndersteuning` manually, the + EOL-approaching filter and roadmap still work, and the + `eol-approaching` notification rule still evaluates existing dates + +### Requirement: Softwarecatalog performs no direct HTTP to the EOL feed + +All fetching of endoflife.date data SHALL happen in the openconnector +`endoflife-date-source` source/synchronization; softwarecatalog SHALL only +read already-ingested `eolProduct`/`eolCycle` objects via OpenRegister's +`ObjectService`/`ConfigurationService`. No HTTP client, URL configuration +field, or outbound network call to endoflife.date (or any other EOL feed) +SHALL exist in softwarecatalog code. + +#### Scenario: The matcher's data source is OpenRegister, not HTTP + +- **WHEN** the EOL matcher is inspected +- **THEN** its data access is limited to `ObjectService`/`ConfigurationService` + calls against the configured register/schema +- **AND** no HTTP client or outbound URL to endoflife.date exists anywhere in + softwarecatalog's codebase + +## Non-Functional Requirements + +- **Performance:** the scheduled job iterates only modules with + `eolProductSlug` set (not a full-catalog scan), and each module's cycle + read is a single filtered query against `eolCycle` (`product = slug`). +- **Accessibility:** the new settings panel (register/schema fields, enable + toggle, sync-now button, status) follows the same `@conduction/nextcloud-vue` + form components and NL Design System tokens as the rest of the settings UI + (ADR-003, ADR-012). +- **Internationalization:** Dutch and English MUST be supported for all new + settings labels and status text (ADR-005). + +## Acceptance Criteria + +- [ ] `module.eolProductSlug` and `moduleVersie.eolBron`/`eolBijgewerktOp` + are declared as optional fields; existing objects load and save unchanged. +- [ ] The matcher stamps only on an unambiguous single-candidate match, + verified by fixture-based unit tests covering match, ambiguous-tie, and + no-match cases. +- [ ] A stamp carries forward every other field on the `moduleVersie` + (PUT-semantic regression test). +- [ ] The background job and the manual trigger both invoke the same sync + logic and both report a status summary. +- [ ] With the openconnector register/schema absent or the sync disabled, + no error occurs and manual EOL entry / the EOL-approaching filter / + roadmap / notification rule all continue to work. +- [ ] No HTTP client or outbound URL to endoflife.date exists in + softwarecatalog code. + +## Notes + +- Builds on `application-lifecycle-tracking` (EOL indicator, EOL-approaching + filter, roadmap, `eol-approaching` notification rule) without modifying it + — this change only improves what populates `datumEindeOndersteuning`. +- Follows the integration-leaf pattern established by + `module-vulnerability-tracking`'s "External CVE enrichment routes through + openconnector" requirement. +- Depends on the sibling openconnector change `endoflife-date-source` for the + `eolProduct`/`eolCycle` register; that dependency is optional at runtime + (see "feature degrades gracefully" requirement above). +- Related specs: `settings-admin-controller` (sync/status endpoint + conventions), `cronjob-context` (background job context pattern). diff --git a/openspec/changes/eol-feed-integration/tasks.md b/openspec/changes/eol-feed-integration/tasks.md new file mode 100644 index 00000000..d332f812 --- /dev/null +++ b/openspec/changes/eol-feed-integration/tasks.md @@ -0,0 +1,104 @@ +# Tasks: eol-feed-integration + +## Implementation Tasks + +### Task 1: Register schema additions — mapping + provenance fields +- **spec_ref**: `openspec/changes/eol-feed-integration/specs/eol-feed-integration/spec.md#requirement-products-are-mapped-to-endoflifedate-via-per-module-config` +- **files**: `lib/Settings/softwarecatalogus_register.json` +- **acceptance_criteria**: + - GIVEN the current `module` and `moduleVersie` schemas WHEN the register definition is updated THEN `module.eolProductSlug` and `moduleVersie.eolBron`/`eolBijgewerktOp` exist as optional fields + - GIVEN existing `module`/`moduleVersie` objects WHEN the updated register is imported via the repair step THEN they load and save unchanged (no new required field, no default value change) +- [ ] Implement +- [ ] Test + +### Task 2: EolMatcherService — conservative version-prefix matching +- **spec_ref**: `openspec/changes/eol-feed-integration/specs/eol-feed-integration/spec.md#requirement-version-matching-is-conservative-and-unambiguous-only` +- **files**: `lib/Service/EolMatcherService.php`, `tests/Unit/Service/EolMatcherServiceTest.php` +- **acceptance_criteria**: + - GIVEN one `eolCycle` candidate at the most-specific matching level WHEN the matcher runs THEN that `moduleVersie` is selected for stamping + - GIVEN two `eolCycle` candidates tied at the most-specific level, or zero candidates, WHEN the matcher runs THEN the `moduleVersie` is skipped and unchanged +- [ ] Implement +- [ ] Test + +### Task 3: PUT-semantic stamping with provenance +- **spec_ref**: `openspec/changes/eol-feed-integration/specs/eol-feed-integration/spec.md#requirement-stamping-preserves-every-other-field-and-records-provenance` +- **files**: `lib/Service/EolMatcherService.php`, `tests/Unit/Service/EolMatcherServiceTest.php` +- **acceptance_criteria**: + - GIVEN a `moduleVersie` with an existing `beschrijvingKort` value WHEN it is matched and stamped THEN `datumEindeOndersteuning`, `eolBron`, `eolBijgewerktOp` are set AND `beschrijvingKort` and every other previously-set field remain unchanged on the saved object + - GIVEN a hand-entered `datumEindeOndersteuning` with no `eolBron` WHEN it is inspected THEN `eolBron`/`eolBijgewerktOp` remain absent (never fabricated for manual entries) +- [ ] Implement +- [ ] Test + +### Task 4: EolSyncService — orchestration, status, graceful degradation +- **spec_ref**: `openspec/changes/eol-feed-integration/specs/eol-feed-integration/spec.md#requirement-the-feature-degrades-gracefully-when-the-feed-is-unavailable` +- **files**: `lib/Service/EolSyncService.php`, `tests/Unit/Service/EolSyncServiceTest.php` +- **acceptance_criteria**: + - GIVEN the configured EOL register/schema cannot be resolved WHEN a sync runs THEN no `moduleVersie` is modified, no error is raised, and status reports the feed unavailable with a reason + - GIVEN a successful run WHEN status is queried THEN it reports matched count, skipped count, and last-run timestamp +- [ ] Implement +- [ ] Test + +### Task 5: EolSyncJob — scheduled background job +- **spec_ref**: `openspec/changes/eol-feed-integration/specs/eol-feed-integration/spec.md#requirement-eol-sync-runs-on-a-schedule-with-a-manual-trigger` +- **files**: `lib/BackgroundJob/EolSyncJob.php`, `appinfo/info.xml` +- **acceptance_criteria**: + - GIVEN the EOL job's configured interval elapses WHEN it runs THEN `EolSyncService` executes in system (non-RBAC) context per the `cronjob-context` pattern + - GIVEN the job is registered WHEN `appinfo/info.xml` is inspected THEN it lists the job under background-jobs following NC 34 registration conventions +- [ ] Implement +- [ ] Test + +### Task 6: SettingsController/SettingsService — EOL sync config, manual trigger, status +- **spec_ref**: `openspec/changes/eol-feed-integration/specs/eol-feed-integration/spec.md#requirement-eol-sync-runs-on-a-schedule-with-a-manual-trigger` +- **files**: `lib/Controller/SettingsController.php`, `lib/Service/SettingsService.php`, `appinfo/routes.php` +- **acceptance_criteria**: + - GIVEN an admin calls `getEolSyncConfig()`/`updateEolSyncConfig()` WHEN invoked THEN the register/schema names and enabled toggle are read/persisted via `SettingsService` + - GIVEN an admin calls the manual sync-trigger endpoint WHEN invoked THEN `EolSyncService` runs immediately and the resulting status is returned as JSON +- [ ] Implement +- [ ] Test + +### Task 7: Frontend — module mapping field + EOL sync settings panel +- **spec_ref**: `openspec/changes/eol-feed-integration/specs/eol-feed-integration/spec.md#requirement-products-are-mapped-to-endoflifedate-via-per-module-config` +- **files**: `src/views/Settings/EolSyncSettings.vue`, `src/store/settingsStore.js`, module edit form component +- **acceptance_criteria**: + - GIVEN a user edits a `module` WHEN they set `eolProductSlug` THEN the value persists via the existing OpenRegister object save path (no app-local controller) + - GIVEN an admin opens the EOL sync settings panel WHEN the feed is unavailable THEN the panel shows "unavailable" status instead of an error, using `@conduction/nextcloud-vue` form components +- [ ] Implement +- [ ] Test + +### Task 8: i18n — NL/EN strings for settings and status +- **spec_ref**: `openspec/changes/eol-feed-integration/specs/eol-feed-integration/spec.md#non-functional-requirements` +- **files**: `l10n/en.js`, `l10n/en.json`, `l10n/nl.js`, `l10n/nl.json` +- **acceptance_criteria**: + - GIVEN the new settings panel and status labels WHEN the app locale is `nl_NL` or `en_US` THEN every new user-facing string renders translated (English i18n keys per project convention) +- [ ] Implement +- [ ] Test + +### Task 9: Docs — feature page with screenshots +- **spec_ref**: `openspec/changes/eol-feed-integration/specs/eol-feed-integration/spec.md#purpose` +- **files**: `docs/features/eol-feed-integration.md`, `docs/images/eol-feed-integration/*` +- **acceptance_criteria**: + - GIVEN the feature is implemented WHEN `docs/features/eol-feed-integration.md` is published THEN it documents the mapping field, sync settings, manual trigger, and the degraded/unavailable state, with Playwright-captured screenshots (ADR-010) +- [ ] Implement +- [ ] Test + +## Quality checklist + + + +- All new/changed business logic covered by PHPUnit unit tests (`tests/Unit/`), + including `EolMatcherServiceTest` fixture cycles: single unambiguous match, + ambiguous tie, no match, prefix-overlap across major versions, and the + PUT-semantic field-preservation regression test +- New/changed API endpoints (EOL sync config, manual trigger, status) covered + by Newman/Postman tests +- UI changes (module mapping field, EOL sync settings panel) covered by + Playwright browser tests +- All tests pass (`composer test`, `newman run`); minimum 75% coverage for + new code (ADR-009) +- Feature documentation updated in `docs/features/` with screenshots (ADR-010) +- Dutch (`nl_NL`) and English (`en_US`) translation strings added for every + new user-facing string, i18n keys in English (ADR-005 / ADR-007) +- No HTTP client, outbound URL, or SSRF-capable input is introduced anywhere + in softwarecatalog for the EOL feed — verify by search before marking done +- `openspec validate` passes diff --git a/openspec/changes/gemma-faceted-search/.openspec.yaml b/openspec/changes/gemma-faceted-search/.openspec.yaml new file mode 100644 index 00000000..cf371d3a --- /dev/null +++ b/openspec/changes/gemma-faceted-search/.openspec.yaml @@ -0,0 +1,2 @@ +schema: conduction +created: 2026-07-23 diff --git a/openspec/changes/gemma-faceted-search/context-brief.md b/openspec/changes/gemma-faceted-search/context-brief.md new file mode 100644 index 00000000..c106d957 --- /dev/null +++ b/openspec/changes/gemma-faceted-search/context-brief.md @@ -0,0 +1,27 @@ +# Context Brief: gemma-faceted-search + +## What +Faceted search & filtering across the catalog's application/voorziening listing pages on GEMMA architecture dimensions: **referentiecomponent, standaard (open standards), applicatieservice, domein**. Facet value counts, combinable with existing text search, deep-linkable filter URLs, and integration with the existing saved-views API. + +## Why (evidence) +- VNG Softwarecatalogus issues #146 and #70 (top open user wish: "zoeken/filteren op GEMMA architectuur"), plus 20 `Zoeken`-labelled issues total. +- 281 usability requirements in the 301 mapped tenders (Specter DB, tender_app_relevance app_slug='softwarecatalog'). +- Competitor gap: GEMMA Softwarecatalogus (incumbent) offers only basic search; no OSS competitor offers GEMMA-dimension facets. +- Specter canonical feature: `gemma-faceted-search` (must, demand 24). + +## Current state (read these specs first) +- `openspec/specs/view-enrichment-api` — GEMMA views + module overlays; referentiecomponent relationships already resolvable server-side. +- `openspec/specs/dashboard-views-api` — saved views + per-user preferences (facet selections should be saveable as views). +- `openspec/changes/bound-unbounded-searchobjects-scans` (pending change on development) — ALL OR searchObjects calls must stay bounded; facet aggregation must not introduce unbounded scans. +- Frontend: manifest v1 pages (`src/manifest.json`), Pinia stores querying OR API directly. + +## Scope +IN: facet aggregation endpoint (Controller → Service, bounded OR queries, cached), facet sidebar UI on the main catalog index page(s), URL-encoded filter state, counts per facet value, i18n NL+EN, tests, docs. +OUT: free-text relevance ranking changes, new GEMMA data imports, cross-app search. + +## Design constraints +- ADR-001: no custom tables — facets aggregate over OpenRegister objects (registers: voorzieningen + vng-gemma). +- ADR-008 Controller → Service; ADR-012 use @conduction/nextcloud-vue Cn components (CnIndexPage sidebar filter patterns); ADR-003 NL Design tokens; ADR-005 i18n (translation keys in ENGLISH). +- Facet counts must respect the caller's RBAC/tenant context (no count-leak of objects the user cannot see). +- OpenSpec delta format: spec delta headers MUST be `### Requirement: ` (nothing else parses). +- Manifest schema refs use SLUGS, not PascalCase. diff --git a/openspec/changes/gemma-faceted-search/design.md b/openspec/changes/gemma-faceted-search/design.md new file mode 100644 index 00000000..68da30d2 --- /dev/null +++ b/openspec/changes/gemma-faceted-search/design.md @@ -0,0 +1,105 @@ +# Design: gemma-faceted-search + +## Architecture Overview +Softwarecatalog is a thin client over OpenRegister (ADR-001) — no custom tables. This change adds one new aggregation slice (`FacetController` → `FacetService`) that reads from the existing `voorzieningen` register's `module`, `dienst`, and `element` schemas, following the same `Controller → Service` layering (ADR-008) and the same distributed-cache pattern `ViewService` already uses for `view-enrichment-api` (`ICacheFactory::createDistributed`, TTL-based, cache-key includes all query-affecting parameters). + +``` +Frontend (module/dienst CnIndexPage) + │ facet selection change / text search change + ▼ +FacetSidebar.vue ──┬── GET /apps/softwarecatalog/api/facets/{schema}? + └── existing object-list query (CnIndexPage), same + ▼ +FacetController::getFacets($schema) + ▼ +FacetService::getFacets($schema, $filters, $rbacContext) + │ 1. resolve RBAC-scoped, filter-scoped object ID set for $schema (bounded) + │ 2. for each GEMMA dimension, aggregate distinct values + counts over that set + │ 3. cache result keyed on (schema, filters, rbacContext) + ▼ +OpenRegister ObjectService::searchObjects() / searchObjectsPaginated() + (module/dienst schema, `_limit` always set — bound-unbounded-searchobjects-scans pattern) +``` + +The `module` schema already carries the GEMMA links directly (`referentieComponenten`, `standaarden`, `standaardenGemma`, `standaardVersies` — all arrays of `element` refs or element identifiers). `dienst` carries GEMMA links only transitively via its `modules` relation. `domein` and `applicatieservice` are not first-class module fields; they live on the linked `element` object's `domein` / `gemmaType`/`gemmaThema` fields, so aggregating those two dimensions requires resolving each module's linked `element` objects and reading their `domein` field / filtering `gemmaType === 'Applicatieservice'`. This mirrors the relationship-resolution `view-enrichment-api`'s `ViewService` already performs for referentiecomponent overlays — `FacetService` reuses that resolution logic rather than re-implementing element lookups. + +## API Design + +### `GET /apps/softwarecatalog/api/facets/{schema}` +**Path parameter:** `schema` — one of `module`, `dienst`. + +**Query parameters:** +- `search` (optional) — free-text query, same semantics as the index page's existing search box. +- `referentiecomponent[]`, `standaard[]`, `applicatieservice[]`, `domein[]` (optional, repeatable) — currently-selected facet values per dimension (OR within a dimension, AND across dimensions). +- `organization` (optional) — overrides active organisation context, mirroring `view-enrichment-api`'s existing `organization` parameter convention. + +**Response:** +```json +{ + "referentiecomponent": [ + { "value": "Zaakregistratiecomponent", "label": "Zaakregistratiecomponent", "count": 12 }, + { "value": "Klantcontactcomponent", "label": "Klantcontactcomponent", "count": 7 } + ], + "standaard": [ + { "value": "StUF-ZKN", "label": "StUF-ZKN", "count": 5 } + ], + "applicatieservice": [], + "domein": [ + { "value": "Bedrijfsvoering", "label": "Bedrijfsvoering", "count": 9 } + ], + "_meta": { + "totalMatched": 12, + "processingTimeMs": 42, + "cached": false + } +} +``` +**Errors:** 400 for an unsupported `schema` path segment or a malformed facet query parameter; 503/500 with a logged, descriptive error if `ObjectService` is unavailable — same error contract `view-enrichment-api` already uses. + +## Nextcloud Integration +- **Controllers:** `lib/Controller/FacetController.php` — one action, `getFacets(string $schema)`, `#[NoAdminRequired]` (facets are a read operation available to any authenticated catalog user; RBAC scoping happens inside the service, not at the controller boundary — same posture as `ViewController`). +- **Services:** `lib/Service/FacetService.php` — new. Depends on `OCA\OpenRegister\Service\ObjectService` (bounded `searchObjects`/`searchObjectsPaginated` calls only) and reuses `ArchiMateService`/`ViewService`'s existing element-relationship resolution helpers rather than duplicating them (extract a shared helper if resolution logic would otherwise be copy-pasted — decided at implementation time, called out in tasks.md). +- **Mappers/Entities:** None new — no custom tables (ADR-001). +- **Events/Hooks:** None new. Cache invalidation is triggered from the same object-mutation path `ViewService`'s module-mapping cache invalidation already hooks (module/element save/delete on the `voorzieningen` register) — extend that existing hook rather than adding a parallel listener. + +## Security Considerations +- **AuthZ:** Facet aggregation MUST run through the identical RBAC/tenant-scoped `ObjectService` query path the module/dienst index page's own object list already uses. No separate, unscoped counting query is permitted (see spec requirement "Facet counts MUST respect the caller's RBAC/tenant context"). This is the same class of risk flagged in the proposal (Risk 2) and is a first-class acceptance criterion, not an afterthought. +- **Input validation:** `schema` path parameter validated against an allowlist (`module`, `dienst`); facet value query parameters are treated as opaque strings matched against known `element`/module field values — never interpolated into a raw query string (OpenRegister's parameterized query builder handles this, consistent with existing `ViewService` usage). +- **CSRF:** Standard Nextcloud CSRF protection applies (GET request, no state mutation) — no `#[NoCSRFRequired]` needed. +- **DoS / resource exhaustion:** Every `searchObjects()` call in `FacetService` sets an explicit `_limit` (Risk 1 in the proposal); the 30-minute-scale distributed cache further bounds repeated-request cost, matching `ViewService`'s existing cache TTL choice. + +## NL Design System +- Facet panel is a new `CnAppSidebar`/`CnIndexPage`-slot component (final placement — sidebar tab vs. inline filter panel — decided against `CnIndexPage`'s existing filter slot API at implementation time; ADR-012 requires reusing `CnIndexPage` machinery rather than a bespoke panel from scratch). +- Facet value chips/checkboxes use standard `NcCheckboxRadioSwitch` / `NcCounterBubble` (for the count badge) rather than custom-styled equivalents. +- All colors via Nextcloud CSS variables (`--color-*`) per ADR-003 — no hardcoded hex values in the new facet components. +- WCAG 2.2 AA: facet checkboxes carry accessible labels (dimension + value, e.g. "Referentiecomponent: Zaakregistratiecomponent, 12 results") so screen readers announce the count; the "clear all facets" action is keyboard-reachable and has a visible focus state (SC 2.4.11 Focus Not Obscured); facet checkbox hit targets meet the 24×24px minimum (SC 2.5.8 Target Size). + +## File Structure +``` +lib/ + Controller/ + FacetController.php (new) + Service/ + FacetService.php (new) +src/ + sidebars/ + facets/ + FacetSideBar.vue (new) — or CnIndexPage filter-slot component, TBD at implementation + services/ + facets.js (new) — API client, mirrors existing view-enrichment fetch pattern + manifest.json (modified) — add `facets` config block to the module/dienst index entries +tests/ + Unit/ + Service/ + FacetServiceTest.php (new) + Integration/ + (Newman/Postman collection entry for GET /api/facets/{schema}) +``` + +## Seed Data +Not applicable — this change introduces no new schemas or data entities. Facets aggregate over the existing `module`, `dienst`, and `element` objects already seeded by prior changes (GEMMA/ArchiMate import + existing module/dienst seed data). Manual verification during implementation SHOULD confirm the dev-environment register already has enough `element` objects with populated `gemmaType`/`domein` values across at least two of each dimension to exercise the facet UI meaningfully; if not, augmenting the existing GEMMA import fixture is a task, not a schema change. + +## Trade-offs +- **New `facets` manifest config block vs. extending `quickFilters`:** `quickFilters` is a flat list of exact-match, single-select filter presets (see `Contracten` index in `src/manifest.json`) — it has no concept of a dimension with many dynamic values and live counts. Extending it to cover facets would overload a simple config shape with multi-select, count-aware semantics it wasn't designed for. A new, purpose-built `facets` config block (parallel to, not replacing, `quickFilters`) keeps both simple. Both remain available on the same index page. +- **Resolving `domein`/`applicatieservice` via linked `element` lookups vs. denormalizing onto `module`:** Denormalizing would mean writing GEMMA metadata onto every module at import/save time — an ADR-001-adjacent smell (duplicating data that already lives on `element`) and a data-consistency risk if the source `element` changes later. Resolving on read (same pattern `view-enrichment-api` already uses for referentiecomponent overlays) keeps `element` as the single source of truth at the cost of an extra bounded lookup per facet request — acceptable given the response is cached. +- **Cache invalidation via existing module/element mutation hook vs. a new event listener:** Reusing `ViewService`'s existing invalidation hook avoids a second listener reacting to the same underlying object-save events (the "orphaned capability" failure class this fleet has hit before — a second, independently-wired listener is an easy place for cache staleness to silently regress). Confirmed as the intended approach; final wiring point identified during implementation. diff --git a/openspec/changes/gemma-faceted-search/proposal.md b/openspec/changes/gemma-faceted-search/proposal.md new file mode 100644 index 00000000..2d364377 --- /dev/null +++ b/openspec/changes/gemma-faceted-search/proposal.md @@ -0,0 +1,59 @@ +# Proposal: gemma-faceted-search + +## Summary +Adds faceted search and filtering to the catalog's application/voorziening listing pages, letting users narrow the module and dienst indexes by GEMMA architecture dimension — referentiecomponent, standaard, applicatieservice, and domein — with live per-facet-value counts, combinable free-text search, deep-linkable filter state, and the ability to save a facet selection as an existing dashboard view. + +## Motivation +"Zoeken/filteren op GEMMA architectuur" is the top open user wish in the VNG Softwarecatalogus issue tracker (#146, #70, plus 18 other `Zoeken`-labelled issues) and appears as a usability requirement in 281 of the 301 tenders mapped against softwarecatalog in the Specter intelligence database. No OSS competitor in the space offers GEMMA-dimension facets, and the GEMMA Softwarecatalogus incumbent itself only supports basic keyword search — this is a clear, evidenced market gap (Specter canonical feature `gemma-faceted-search`, demand score 24, priority `must`). The underlying data is already in place: `view-enrichment-api` resolves referentiecomponent relationships server-side, and the `element` schema (register `voorzieningen`) already carries every GEMMA dimension (`gemmaType`, `domein`) the facets need. What's missing is an aggregation endpoint and a filter UI wired to it. + +## Affected Projects +- [ ] Project: `softwarecatalog` — new facet aggregation endpoint (Controller → Service), facet sidebar UI on the module/dienst index pages, URL-encoded filter state, integration with the existing saved-views API. + +## Scope + +### In Scope +- A bounded, cached facet aggregation endpoint that counts `element`-linked GEMMA dimension values (referentiecomponent, standaard, applicatieservice, domein) across the module and dienst listings, scoped to the caller's RBAC/tenant context. +- A facet sidebar/filter panel on the catalog's application (`module`) and service (`dienst`) index pages, built from `@conduction/nextcloud-vue` `CnIndexPage` filter patterns. +- Combinability with the existing free-text search box already present on these index pages (AND semantics — text query narrows the facet-counted set, not the other way round). +- URL-encoded filter state so a filtered view is shareable/deep-linkable and survives a page reload. +- Per-facet-value counts that update as other facets are applied (counts reflect the currently filtered set, not the unfiltered universe). +- Saving a facet selection as a saved view via the existing `dashboard-views-api` `ViewService`. +- Dutch + English i18n for all new facet labels and UI strings. +- Unit tests (PHPUnit, ≥75% new code) and Playwright/browser tests for the facet UI. +- User-facing documentation with screenshots. + +### Out of Scope +- Changes to free-text relevance ranking or search algorithm — this change only adds structured facets alongside existing text search. +- New GEMMA data imports or changes to the ArchiMate/GGM import pipeline — facets aggregate over `element` objects already present in the register. +- Cross-app / federated search (searching another Conduction app's catalog from softwarecatalog) — deferred, tracked separately if raised again. + +## Approach +Add a `FacetController` → `FacetService` pair (ADR-008 layering) that issues bounded, `_limit`-respecting `searchObjects()`/aggregate queries against the `voorzieningen` register's `module`/`dienst`/`element` schemas, grouping by GEMMA dimension and returning value→count pairs plus the RBAC-filtered object IDs needed to drive the index page's existing list query. Cache aggregation results per (schema, active filter-set, user/tenant) similar in shape to `view-enrichment-api`'s existing cache. The frontend adds a facet sidebar component to the module/dienst `CnIndexPage` configs, translates facet selections to query parameters and to the URL (via the Vue router, mirroring the enrichment API's query-parameter pattern), and re-fetches both the object list and the facet counts on every selection change. Full technical detail (query shape, cache key, RBAC scoping mechanism) is worked out in design.md. + +## New Dependencies +None. + +## Impact +- New: `lib/Controller/FacetController.php`, `lib/Service/FacetService.php`, `src/sidebars/facets/` (or equivalent), facet-aware additions to the `module`/`dienst` entries in `src/manifest.json`. +- Modified: module and dienst index page configs (add `facets` config block, mirroring the existing `quickFilters` pattern); frontend endpoint constants (new `FACETS` endpoint group, alongside the existing `GEMMA` group in the endpoints file used by `view-enrichment-api`). +- No changes to existing controllers/services outside the new facet slice; `ViewService`/`ViewController` (dashboard-views-api) gain no new endpoints, only a consumer (facet-selection-as-view uses the existing save-view call). + +## Cross-Project Dependencies +None — self-contained within softwarecatalog. The facet endpoint is consumed only by the softwarecatalog frontend. + +## Risks + +### Risk 1: Unbounded aggregation queries reintroduce the full-table-scan defect class +**Severity:** High — **Mitigation:** The pending `bound-unbounded-searchobjects-scans` change establishes the pattern (explicit `_limit` on every `searchObjects()` call, or `searchObjectsPaginated()`/documented ceiling for index-building queries). `FacetService` MUST follow that pattern from day one — every facet-count query sets an explicit `_limit` or uses a paginated/aggregate query path; this is called out explicitly as an acceptance criterion in design.md and tasks.md so it is not an afterthought bolted on after the fact. + +### Risk 2: Facet counts leak the existence of objects the user cannot see +**Severity:** Medium — **Mitigation:** Facet aggregation MUST run through the same RBAC/tenant-scoped query path the index page's own object list already uses (no separate unscoped counting query). Covered by a dedicated spec scenario and a test task that asserts counts for a restricted user never include out-of-scope objects. + +### Risk 3: Facet sidebar duplicates rather than reuses the existing `quickFilters` / `CnIndexPage` filter machinery +**Severity:** Low — **Mitigation:** design.md evaluates extending the existing `quickFilters` config shape before introducing a parallel `facets` config block, and the tasks explicitly reference `CnIndexPage`'s existing filter slot per ADR-012. + +## Rollback Strategy +The facet endpoint and sidebar are additive — the existing module/dienst index pages, `quickFilters`, and free-text search continue to work unchanged if the facet UI is hidden or the manifest `facets` config block is removed. Rollback is: remove the `facets` config from the affected manifest entries (hides the sidebar), and/or unregister the `FacetController` route. No data migration, no schema change, nothing to reverse in OpenRegister. + +## Open Questions +- Should facet selections persist per-user as a default (via the existing `PreferencesController`) the way list columns already do, or reset on every visit? Deferred to design.md — default assumption is "reset on navigation, restorable only via a saved view or a shared URL" unless design.md finds a cheap way to reuse `PreferencesController`. diff --git a/openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md b/openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md new file mode 100644 index 00000000..14571077 --- /dev/null +++ b/openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md @@ -0,0 +1,214 @@ +# gemma-faceted-search + +## ADDED Requirements + +### Requirement: Facet aggregation endpoint returns GEMMA dimension counts + +The system SHALL expose a facet aggregation endpoint (`GET /apps/softwarecatalog/api/facets/{schema}`, `schema` in `module`, `dienst`) that returns, for each supported GEMMA dimension (`referentiecomponent`, `standaard`, `applicatieservice`, `domein`), the list of distinct facet values present in the currently-filtered result set together with the count of matching objects for each value. + +#### Scenario: Facet counts returned for the module listing + +- GIVEN the `module` register contains 40 modules, 12 of which link to referentiecomponent "Zaakregistratiecomponent" +- WHEN `GET /apps/softwarecatalog/api/facets/module` is called with no filters applied +- THEN the response MUST include a `referentiecomponent` facet +- AND that facet MUST contain an entry `{ "value": "Zaakregistratiecomponent", "count": 12 }` + +#### Scenario: Facet response covers all four GEMMA dimensions + +- GIVEN a request to the facet aggregation endpoint for the `module` schema +- WHEN the response is generated +- THEN the response MUST contain top-level keys for `referentiecomponent`, `standaard`, `applicatieservice`, and `domein` +- AND a dimension with no matching objects MUST be present as an empty array, not omitted + +#### Scenario: Unsupported schema is rejected + +- GIVEN `GET /apps/softwarecatalog/api/facets/contract` is called +- WHEN `contract` is not one of the supported facet schemas (`module`, `dienst`) +- THEN the response MUST have status 400 +- AND the response body MUST contain an error message naming the supported schemas + +### Requirement: Facet counts reflect the currently filtered set, not the unfiltered universe + +Selecting a facet value SHALL narrow both the object list and the counts shown for every other facet dimension, so counts always describe "how many more results if I also select this value" rather than the totals across the whole register. + +#### Scenario: Selecting one facet value narrows counts for other dimensions + +- GIVEN the module listing has 40 modules total, of which 12 link to referentiecomponent "Zaakregistratiecomponent" and 5 of those 12 also link to standaard "StUF-ZKN" +- WHEN the facet endpoint is called with `referentiecomponent=Zaakregistratiecomponent` already selected +- THEN the `standaard` facet's count for "StUF-ZKN" MUST be 5, not the unfiltered total +- AND the `referentiecomponent` facet's own count for "Zaakregistratiecomponent" MUST reflect the same 12-object filtered set (self-count is not narrowed by its own selection) + +#### Scenario: Multiple values within one dimension combine with OR semantics + +- GIVEN a user selects both "Zaakregistratiecomponent" and "Klantcontactcomponent" under the `referentiecomponent` facet +- WHEN the object list and facet counts are requested +- THEN the result set MUST include modules linking to either referentiecomponent (union, not intersection) + +#### Scenario: Selections across different dimensions combine with AND semantics + +- GIVEN a user selects "Zaakregistratiecomponent" under `referentiecomponent` and "StUF-ZKN" under `standaard` +- WHEN the object list and facet counts are requested +- THEN the result set MUST include only modules that link to that referentiecomponent AND that standaard + +### Requirement: Facets combine with free-text search + +The facet aggregation and the existing free-text search on the module/dienst index pages SHALL be combinable: text search narrows the candidate set before facet counts are computed. + +#### Scenario: Text query narrows facet counts + +- GIVEN a free-text search for "zaak" is active on the module index +- WHEN the facet endpoint is called with the same search query parameter +- THEN facet counts MUST only reflect modules matching "zaak" +- AND the returned facet values MUST NOT include values that only occur on non-matching modules + +#### Scenario: No text query returns facets over the full (RBAC-scoped) set + +- GIVEN no free-text search is active +- WHEN the facet endpoint is called +- THEN facet counts MUST be computed over all objects the caller can see, unfiltered by text + +### Requirement: Facet aggregation queries MUST be bounded + +Every OpenRegister `searchObjects()` (or equivalent aggregate) call issued by the facet aggregation service MUST set an explicit `_limit`, or use `searchObjectsPaginated()`/an explicit documented ceiling, consistent with the `bound-unbounded-searchobjects-scans` change. Facet aggregation MUST NOT introduce a new unbounded full-table scan. + +#### Scenario: Facet aggregation query sets an explicit limit + +- GIVEN `FacetService` builds a query to aggregate `referentiecomponent` values across the `module` schema +- WHEN the query array is constructed +- THEN it MUST include an explicit `_limit` value +- AND the value MUST NOT be silently omitted or left to default + +#### Scenario: A register too large for one bounded page pages instead of scanning unbounded + +- GIVEN the `module` register has more objects than fit in one bounded facet aggregation page +- WHEN facet counts are computed +- THEN the service MUST page through results via `searchObjectsPaginated()` (or a documented `_limit` ceiling) to reach a complete count +- AND MUST NOT issue a single unbounded `searchObjects()` call to cover the whole table + +### Requirement: Facet counts MUST respect the caller's RBAC/tenant context + +Facet aggregation SHALL count only objects the requesting user is authorized to read. The facet endpoint MUST NOT expose the existence of, or count, objects a restricted user cannot see via the equivalent object list query. + +#### Scenario: Restricted user sees only their own scope reflected in counts + +- GIVEN a tenant-restricted user who can see 8 of the register's 40 modules +- WHEN that user requests facet counts for the module listing +- THEN every facet value's count MUST be computed only from that user's visible 8 modules +- AND no facet value that exists only among the other 32 (invisible) modules MUST appear + +#### Scenario: Facet aggregation uses the same authorization path as the object list + +- GIVEN the module index page's own object list query is scoped by RBAC/organisation context +- WHEN the facet aggregation query is built +- THEN it MUST apply the identical RBAC/tenant scoping as the object list query +- AND MUST NOT use a separate, unscoped counting code path + +### Requirement: Filter state is URL-encoded and deep-linkable + +The selected facet values and active free-text query on the module/dienst index pages SHALL be reflected in the browser URL as query parameters, so a filtered view can be shared, bookmarked, or reloaded without losing the selection. + +#### Scenario: Applying a facet updates the URL + +- GIVEN a user on the module index page selects "Zaakregistratiecomponent" under the `referentiecomponent` facet +- WHEN the selection is applied +- THEN the browser URL MUST include a query parameter encoding that selection (e.g. `?referentiecomponent=Zaakregistratiecomponent`) + +#### Scenario: Loading a filtered URL restores the facet selection + +- GIVEN a URL `.../modules?referentiecomponent=Zaakregistratiecomponent&standaard=StUF-ZKN` +- WHEN the module index page loads +- THEN the `referentiecomponent` and `standaard` facets MUST show those values as pre-selected +- AND the object list and facet counts MUST reflect that filter state on first render, without requiring an additional user action + +#### Scenario: Clearing all facets removes filter parameters from the URL + +- GIVEN a filtered URL is active +- WHEN the user clears all facet selections +- THEN the facet-related query parameters MUST be removed from the URL +- AND the object list MUST return to the unfiltered (RBAC-scoped) view + +### Requirement: Facet sidebar UI on the module and dienst index pages + +The module (`Applications`) and dienst (`Services`) `CnIndexPage`-based index pages SHALL present a facet filter panel listing the four GEMMA dimensions (referentiecomponent, standaard, applicatieservice, domein), each showing its available values with counts, using `@conduction/nextcloud-vue` components per ADR-012. + +#### Scenario: Facet panel renders alongside the existing index page toolbar + +- GIVEN a user navigates to the module index page +- WHEN the page renders +- THEN a facet filter panel MUST be visible showing all four GEMMA dimensions +- AND the existing free-text search box and any `quickFilters` MUST continue to render and function unchanged + +#### Scenario: Selecting a facet value updates the object list without a full page reload + +- GIVEN the facet panel is visible on the module index page +- WHEN the user selects a facet value +- THEN the object list MUST re-fetch and display only matching modules +- AND the facet panel's counts for the other dimensions MUST update to reflect the new filter state +- AND no full browser page reload MUST occur + +#### Scenario: A facet dimension with zero available values is visibly disabled + +- GIVEN the currently filtered set has no objects linking to any `applicatieservice` value +- WHEN the facet panel renders +- THEN the `applicatieservice` facet section MUST indicate it has no available values (e.g. empty state or disabled state) +- AND MUST NOT be selectable + +### Requirement: A facet selection can be saved as a view + +A user SHALL be able to save the currently active facet selection (and free-text query, if any) as a saved view via the existing dashboard-views-api `ViewService`, so it can be recalled later without re-selecting each facet. + +#### Scenario: Saving the current filter state as a view + +- GIVEN a user has selected `referentiecomponent=Zaakregistratiecomponent` and `standaard=StUF-ZKN` on the module index page +- WHEN they choose "Save as view" and provide a name +- THEN a saved view MUST be created via the existing `ViewService` save-view call +- AND the saved view's stored filter state MUST reproduce the same facet selection when loaded + +#### Scenario: Loading a saved view restores its facet selection + +- GIVEN a saved view exists with a stored facet selection +- WHEN a user opens that saved view from the module index page +- THEN the facet panel MUST pre-select the stored values +- AND the object list and URL MUST reflect that filter state + +### Requirement: Facet aggregation results are cached + +The facet aggregation endpoint SHALL cache computed facet results per unique combination of schema, filter state, free-text query, and caller RBAC/tenant context, and SHALL invalidate the cache when underlying module/dienst/element data changes. + +#### Scenario: Repeated identical facet request is served from cache + +- GIVEN a facet request was made 10 seconds ago with a given filter combination +- AND no relevant module, dienst, or element data has changed +- WHEN the same request is made again by the same user +- THEN the response MUST be served from cache +- AND the response time MUST be significantly faster than the first request + +#### Scenario: Cache is invalidated when a module's GEMMA links change + +- GIVEN a cached facet result exists for the module listing +- WHEN a module's `referentieComponenten`, `standaarden`, `standaardenGemma`, or related GEMMA link field is created, updated, or removed +- THEN the cache for affected facet queries MUST be invalidated +- AND the next facet request MUST recompute the aggregation + +#### Scenario: Cache key includes RBAC/tenant context + +- GIVEN two users with different RBAC scopes request facets for the same filter combination +- WHEN both requests are served +- THEN each user's response MUST come from (or populate) a cache entry keyed to include their own RBAC/tenant context +- AND the two users MUST NOT receive each other's cached counts + +### Requirement: Facet labels and UI strings are translated + +All facet dimension labels, facet value display strings sourced from the UI layer (not raw data values), empty-state text, and the "Save as view" action SHALL be available in Dutch and English, with translation keys written in English per ADR-005. + +#### Scenario: Facet panel renders in the user's selected language + +- GIVEN a user's Nextcloud locale is set to `nl_NL` +- WHEN the facet panel renders +- THEN the dimension labels (e.g. "Referentiecomponent", "Standaard", "Applicatieservice", "Domein") and the "Save as view" action MUST render in Dutch + +#### Scenario: Translation keys are in English + +- GIVEN the softwarecatalog `l10n` translation files +- WHEN the facet panel's translation keys are inspected +- THEN each key MUST be an English identifier (e.g. `facetSaveAsView`), not a Dutch string, with the Dutch translation supplied as the `nl` value diff --git a/openspec/changes/gemma-faceted-search/tasks.md b/openspec/changes/gemma-faceted-search/tasks.md new file mode 100644 index 00000000..a16e39cb --- /dev/null +++ b/openspec/changes/gemma-faceted-search/tasks.md @@ -0,0 +1,160 @@ +# Tasks: gemma-faceted-search + +## Implementation Tasks + +### Task 1: `FacetService` — bounded aggregation over direct module GEMMA fields +- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-endpoint-returns-gemma-dimension-counts` +- **files**: `lib/Service/FacetService.php` +- **acceptance_criteria**: + - GIVEN the `module` schema WHEN `FacetService::getFacets('module', [])` is called THEN it returns `referentiecomponent` and `standaard` facet arrays with `{value, label, count}` entries derived from `referentieComponenten`/`standaarden`/`standaardenGemma` + - GIVEN a dimension with no matching values THEN it is returned as an empty array, not omitted + - Every `ObjectService::searchObjects()` call in this task sets an explicit `_limit` (per `bound-unbounded-searchobjects-scans`) or uses `searchObjectsPaginated()` +- [ ] Implement +- [ ] Test + +### Task 2: `FacetService` — resolve `domein`/`applicatieservice` via linked `element` lookups +- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-endpoint-returns-gemma-dimension-counts` +- **files**: `lib/Service/FacetService.php` +- **acceptance_criteria**: + - GIVEN modules linking to `element` objects with `domein` set THEN the `domein` facet reflects those values with correct counts + - GIVEN modules linking to `element` objects where `gemmaType === 'Applicatieservice'` THEN the `applicatieservice` facet reflects those values with correct counts + - Element-resolution reuses `ViewService`/`ArchiMateService`'s existing relationship-resolution helper rather than duplicating lookup logic (extract a shared helper if none is directly reusable) + - Every lookup query sets an explicit `_limit` or uses `searchObjectsPaginated()` +- [ ] Implement +- [ ] Test + +### Task 3: `FacetService` — filtered-set narrowing (facet-on-facet AND/OR semantics) +- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facet-counts-reflect-the-currently-filtered-set-not-the-unfiltered-universe` +- **files**: `lib/Service/FacetService.php` +- **acceptance_criteria**: + - GIVEN one facet dimension is pre-selected THEN counts for every other dimension are computed only over the resulting narrowed set + - GIVEN multiple values are selected within one dimension THEN the result set is their union (OR) + - GIVEN values are selected across different dimensions THEN the result set is their intersection (AND) +- [ ] Implement +- [ ] Test + +### Task 4: `FacetService` — combine free-text search with facet filters +- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facets-combine-with-free-text-search` +- **files**: `lib/Service/FacetService.php` +- **acceptance_criteria**: + - GIVEN a `search` query parameter THEN facet counts are computed only over objects matching that text query + - GIVEN no `search` parameter THEN facet counts cover the full RBAC-scoped set +- [ ] Implement +- [ ] Test + +### Task 5: `FacetService` — RBAC/tenant scoping parity with the object list query +- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facet-counts-must-respect-the-callers-rbactenant-context` +- **files**: `lib/Service/FacetService.php` +- **acceptance_criteria**: + - GIVEN a restricted user THEN facet counts never include objects that user's own object-list query would not return + - Facet aggregation uses the identical RBAC/tenant-scoped `ObjectService` query path as the index page's object list — no separate unscoped counting path is introduced +- [ ] Implement +- [ ] Test + +### Task 6: `FacetService` — distributed caching + invalidation +- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-results-are-cached` +- **files**: `lib/Service/FacetService.php` +- **acceptance_criteria**: + - GIVEN an identical facet request within the cache TTL THEN the response is served from cache and is measurably faster + - GIVEN a module's GEMMA link fields (`referentieComponenten`, `standaarden`, `standaardenGemma`, `standaardVersies`) change THEN affected cached facet entries are invalidated + - GIVEN two users with different RBAC/tenant context THEN their cache entries are keyed separately and never cross + - Cache invalidation extends the existing module/element mutation hook `ViewService` already uses — no duplicate/parallel event listener is added +- [ ] Implement +- [ ] Test + +### Task 7: `FacetController` + route registration +- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-endpoint-returns-gemma-dimension-counts` +- **files**: `lib/Controller/FacetController.php`, `appinfo/routes.php` +- **acceptance_criteria**: + - GIVEN `GET /apps/softwarecatalog/api/facets/module` or `/dienst` THEN the controller returns the `FacetService` response as JSON with status 200 + - GIVEN `GET /apps/softwarecatalog/api/facets/{other}` THEN the controller returns 400 with an error naming the supported schemas + - GIVEN `ObjectService` is unavailable THEN the controller returns 503/500 with a logged, descriptive error message + - Controller carries the correct Nextcloud auth attribute (`#[NoAdminRequired]`) — verified against `hydra-gate-route-auth`/`hydra-gate-semantic-auth` +- [ ] Implement +- [ ] Test + +### Task 8: PHPUnit unit tests for `FacetService` +- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-endpoint-returns-gemma-dimension-counts` +- **files**: `tests/Unit/Service/FacetServiceTest.php` +- **acceptance_criteria**: + - Covers Tasks 1–6's acceptance criteria (dimension aggregation, narrowing, text-search combination, RBAC scoping, caching, bounded queries) + - Achieves ≥75% coverage of `FacetService`'s new code (ADR-009) +- [ ] Implement +- [ ] Test + +### Task 9: Newman/Postman collection entries for the facets endpoint +- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-endpoint-returns-gemma-dimension-counts` +- **files**: `postman/` (add to existing softwarecatalog collection) +- **acceptance_criteria**: + - GIVEN the collection is run against a seeded dev instance THEN requests cover happy-path facet retrieval, filtered narrowing, unsupported-schema 400, and free-text combination +- [ ] Implement +- [ ] Test + +### Task 10: Frontend `facets.js` API client +- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-endpoint-returns-gemma-dimension-counts` +- **files**: `src/services/facets.js` +- **acceptance_criteria**: + - GIVEN a schema and a set of active filters THEN the client requests `GET /apps/softwarecatalog/api/facets/{schema}` with the correct query parameters, mirroring the `view-enrichment-api` fetch pattern +- [ ] Implement +- [ ] Test + +### Task 11: Facet sidebar/filter panel component +- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facet-sidebar-ui-on-the-module-and-dienst-index-pages` +- **files**: `src/sidebars/facets/FacetSideBar.vue` (or `CnIndexPage` filter-slot equivalent — final placement decided against ADR-012's existing filter-slot API), `src/manifest.json` +- **acceptance_criteria**: + - GIVEN the module or dienst index page THEN a facet panel renders all four GEMMA dimensions with per-value counts, using `@conduction/nextcloud-vue` components (`NcCheckboxRadioSwitch`, `NcCounterBubble`) per ADR-012 + - GIVEN a facet value is selected THEN the object list re-fetches without a full page reload and other dimensions' counts update + - GIVEN a dimension has zero available values under the current filter THEN it renders a disabled/empty state, not a selectable empty list + - Existing free-text search box and `quickFilters` on the same index page continue to render and function unchanged + - All colors use Nextcloud CSS variables (ADR-003) — no hardcoded hex values +- [ ] Implement +- [ ] Test + +### Task 12: URL-encoded, deep-linkable filter state +- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-filter-state-is-url-encoded-and-deep-linkable` +- **files**: `src/sidebars/facets/FacetSideBar.vue`, `src/router/index.js` (or equivalent router query-sync logic) +- **acceptance_criteria**: + - GIVEN a facet selection is applied THEN the browser URL query string reflects it + - GIVEN a URL with facet query parameters is loaded directly THEN the facet panel, object list, and counts reflect that state on first render + - GIVEN all facets are cleared THEN the facet-related query parameters are removed from the URL +- [ ] Implement +- [ ] Test + +### Task 13: Save facet selection as a view (dashboard-views-api integration) +- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-a-facet-selection-can-be-saved-as-a-view` +- **files**: `src/sidebars/facets/FacetSideBar.vue`, existing view-save UI/store (reuse, do not duplicate `ViewService`/`ViewController`) +- **acceptance_criteria**: + - GIVEN an active facet selection THEN "Save as view" creates a saved view via the existing save-view call with the filter state stored + - GIVEN a saved view with a stored facet selection is opened THEN the facet panel, object list, and URL reflect that state + - No new `ViewController`/`ViewService` endpoint is introduced — this task is a consumer of the existing API only +- [ ] Implement +- [ ] Test + +### Task 14: Browser (Playwright MCP) tests for the facet UI +- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facet-sidebar-ui-on-the-module-and-dienst-index-pages` +- **files**: `tests/e2e/` (or `tests/vitest/` per existing frontend test layout) +- **acceptance_criteria**: + - Covers Tasks 11–13's acceptance criteria end-to-end through the browser (facet selection, URL deep link, save-as-view, zero-value disabled state) +- [ ] Implement +- [ ] Test + +## Verification +- [ ] All tasks checked off +- [ ] `openspec validate --change gemma-faceted-search` passes +- [ ] Manual testing against acceptance criteria (module and dienst index pages) +- [ ] Code review against spec requirements +- [ ] Hydra mechanical gates pass — in particular `route-auth`, `route-reachability`, `spdx-headers`, `spec-coverage` for the new `FacetController`/`FacetService` + +## Tests (company-wide ADR-009) +- [ ] PHPUnit unit tests for `FacetService` (`tests/Unit/Service/FacetServiceTest.php`) — ≥75% coverage of new code +- [ ] Newman/Postman tests for `GET /apps/softwarecatalog/api/facets/{schema}` added to the softwarecatalog collection +- [ ] Browser tests (Playwright MCP) for the facet panel, URL deep-linking, and save-as-view flow +- [ ] All tests pass (`composer test`, `newman run`, browser MCP verification) + +## Documentation (company-wide ADR-010) +- [ ] Feature documentation added at `docs/features/gemma-faceted-search.md` describing the facet panel, dimensions, and save-as-view flow +- [ ] Screenshots captured via Playwright MCP showing: the facet panel on the module index page, a narrowed selection with updated counts, and the "Save as view" dialog — committed to `docs/images/` + +## i18n (company-wide ADR-005) +- [ ] Dutch (`nl_NL`) and English (`en_US`) translation strings added for all new facet UI strings (dimension labels, empty-state text, "Save as view" action, clear-filters action) +- [ ] Translation keys are English identifiers (e.g. `facetSaveAsView`, `facetDimensionReferentiecomponent`, `facetClearAll`) with Dutch/English values supplied per key — no Dutch text used as a key diff --git a/openspec/changes/gemma-faceted-search/test-plan.md b/openspec/changes/gemma-faceted-search/test-plan.md new file mode 100644 index 00000000..f73a70f4 --- /dev/null +++ b/openspec/changes/gemma-faceted-search/test-plan.md @@ -0,0 +1,168 @@ +# Test Plan: gemma-faceted-search + +## Test Cases + +### TC-1: Facet endpoint returns counts for all four GEMMA dimensions +- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-endpoint-returns-gemma-dimension-counts` +- **type**: api +- **persona**: N/A +- **preconditions**: Module register seeded with modules linking to at least 2 referentiecomponenten, 1 standaard, 1 domein, 0 applicatieservice values. +- **steps**: `GET /apps/softwarecatalog/api/facets/module` with no filters. +- **expected result**: Response contains `referentiecomponent`, `standaard`, `applicatieservice`, `domein` keys; `applicatieservice` is an empty array (present, not omitted); counts match seeded data. +- **test command**: `/test-api` + +### TC-2: Unsupported schema returns 400 +- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-endpoint-returns-gemma-dimension-counts` +- **type**: api +- **preconditions**: None. +- **steps**: `GET /apps/softwarecatalog/api/facets/contract`. +- **expected result**: HTTP 400, error message names supported schemas (`module`, `dienst`). +- **test command**: `/test-api` + +### TC-3: Selecting a facet value narrows counts for other dimensions +- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facet-counts-reflect-the-currently-filtered-set-not-the-unfiltered-universe` +- **type**: api +- **preconditions**: Seeded modules where a subset of referentiecomponent-A modules also carry standaard-B. +- **steps**: `GET /apps/softwarecatalog/api/facets/module?referentiecomponent[]=A`. +- **expected result**: `standaard` facet's count for B equals the narrowed subset count, not the full-register count. +- **test command**: `/test-api` + +### TC-4: Multi-select within one dimension uses OR; across dimensions uses AND +- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facet-counts-reflect-the-currently-filtered-set-not-the-unfiltered-universe` +- **type**: api +- **preconditions**: Seeded modules covering two distinct referentiecomponent values and one standaard value. +- **steps**: (a) `GET .../facets/module?referentiecomponent[]=A&referentiecomponent[]=B` (b) `GET .../facets/module?referentiecomponent[]=A&standaard[]=C`. +- **expected result**: (a) result set is the union of A and B; (b) result set is the intersection of A and C. +- **test command**: `/test-api` + +### TC-5: Free-text search narrows facet counts +- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facets-combine-with-free-text-search` +- **type**: api +- **preconditions**: Seeded modules where only some match the text query "zaak". +- **steps**: `GET /apps/softwarecatalog/api/facets/module?search=zaak`. +- **expected result**: Facet values that only occur on non-matching modules are absent or zero-count; counts reflect only matching modules. +- **test command**: `/test-api` + +### TC-6: Every facet aggregation query sets an explicit `_limit` +- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-queries-must-be-bounded` +- **type**: regression +- **preconditions**: `FacetService` implemented. +- **steps**: Code review / static check of every `searchObjects()`/`searchObjectsPaginated()` call site in `lib/Service/FacetService.php` (mirrors the audit method used in `bound-unbounded-searchobjects-scans`: grep the lines preceding each call for `_limit`). +- **expected result**: 100% of call sites set an explicit `_limit` or use `searchObjectsPaginated()`. +- **test command**: `/test-regression` + +### TC-7: Facet aggregation performs acceptably on a realistic register size +- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-queries-must-be-bounded` +- **type**: performance +- **preconditions**: Dev/test register seeded with several hundred module objects. +- **steps**: Request facet counts (cold cache) for the module listing. +- **expected result**: Response completes without timing out and without unbounded memory growth (compare against the `bound-unbounded-searchobjects-scans` baseline expectations). +- **test command**: `/test-performance` + +### TC-8: Restricted user's facet counts never include out-of-scope objects +- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facet-counts-must-respect-the-callers-rbactenant-context` +- **type**: security +- **preconditions**: Two users with different RBAC/tenant scopes; register contains objects visible only to one of them. +- **steps**: Each user requests `GET /apps/softwarecatalog/api/facets/module`. +- **expected result**: Facet values/counts for the restricted user never reflect objects outside their visible scope; matches what their own object-list query would show. +- **test command**: `/test-security` + +### TC-9: Applying a facet updates the URL and reloading restores it +- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-filter-state-is-url-encoded-and-deep-linkable` +- **type**: functional +- **persona**: Noor Yilmaz (Municipal CISO / Functional Admin) — browses the catalog to assess which modules implement a given standard. +- **preconditions**: Module index page loaded in browser. +- **steps**: Select a `referentiecomponent` facet value; copy the resulting URL; open it in a fresh tab. +- **expected result**: URL contains the facet selection as a query parameter; the fresh tab loads with the same facet pre-selected and the same filtered object list/counts. +- **test command**: `/test-functional` + +### TC-10: Facet panel renders on module and dienst index pages alongside existing search/quickFilters +- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facet-sidebar-ui-on-the-module-and-dienst-index-pages` +- **type**: functional +- **persona**: Mark Visser (MKB Software Vendor) — checks which GEMMA standards his own modules are catalogued under. +- **preconditions**: Module and dienst index pages exist with facet config applied. +- **steps**: Navigate to each index page. +- **expected result**: Facet panel visible with all four dimensions; existing free-text search box and any `quickFilters` still render and function. +- **test command**: `/test-functional` + +### TC-11: Selecting a facet value updates the list without a full page reload +- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facet-sidebar-ui-on-the-module-and-dienst-index-pages` +- **type**: functional +- **preconditions**: Facet panel visible. +- **steps**: Click a facet value checkbox. +- **expected result**: Object list re-fetches and updates in place; other dimensions' counts update; no full browser navigation/reload occurs (verify via network panel — single XHR, no document navigation). +- **test command**: `/test-functional` + +### TC-12: Facet dimension with zero available values is visibly disabled +- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facet-sidebar-ui-on-the-module-and-dienst-index-pages` +- **type**: accessibility +- **preconditions**: A filtered state exists where `applicatieservice` has zero matching values. +- **steps**: Inspect the `applicatieservice` facet section under that filter state. +- **expected result**: Section shows an empty/disabled state and is not focusable/selectable via keyboard or screen reader (no false affordance). +- **test command**: `/test-accessibility` + +### TC-13: Saving current facet selection as a view, then reloading it +- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-a-facet-selection-can-be-saved-as-a-view` +- **type**: functional +- **persona**: Mark Visser (MKB Software Vendor) +- **preconditions**: Facet selection active on module index page. +- **steps**: Click "Save as view", provide a name, confirm; navigate away; reopen the saved view. +- **expected result**: View created via existing `ViewService` save-view call; reopening restores the identical facet selection, object list, and URL state. +- **test command**: `/test-functional` + +### TC-14: Repeated identical facet request is served from cache +- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-results-are-cached` +- **type**: performance +- **preconditions**: No relevant data changes between requests. +- **steps**: Issue the same facet request twice within the cache TTL. +- **expected result**: Second request is measurably faster (served from cache); `_meta.cached` (or equivalent) reflects cache hit. +- **test command**: `/test-performance` + +### TC-15: Cache invalidates when a module's GEMMA links change +- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-results-are-cached` +- **type**: regression +- **preconditions**: Cached facet result exists. +- **steps**: Update a module's `referentieComponenten` field; re-request facets. +- **expected result**: Response reflects the updated link, not stale cached data. +- **test command**: `/test-regression` + +### TC-16: Two RBAC-distinct users never share cached counts +- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facet-aggregation-results-are-cached` +- **type**: security +- **preconditions**: Two users with different RBAC/tenant scopes. +- **steps**: Both request facets for the same filter combination in quick succession. +- **expected result**: Each receives counts scoped to their own visibility; no cross-tenant cache bleed (combines with TC-8). +- **test command**: `/test-security` + +### TC-17: Facet panel renders in Dutch and English +- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facet-labels-and-ui-strings-are-translated` +- **type**: functional +- **persona**: Noor Yilmaz (Municipal CISO / Functional Admin) — Dutch-locale user. +- **preconditions**: Nextcloud locale set to `nl_NL` for one test run, `en_US` for another. +- **steps**: Load the module index page in each locale. +- **expected result**: Dimension labels and "Save as view" render in the active locale; no untranslated English strings leak into the `nl_NL` run. +- **test command**: `/test-functional` + +### TC-18: Translation keys are English identifiers +- **spec_ref**: `openspec/changes/gemma-faceted-search/specs/gemma-faceted-search/spec.md#requirement-facet-labels-and-ui-strings-are-translated` +- **type**: regression +- **preconditions**: `l10n` translation source files for the new facet strings exist. +- **steps**: Inspect the translation key names added for this change. +- **expected result**: All new keys are English identifiers (e.g. `facetSaveAsView`, `facetDimensionReferentiecomponent`); Dutch/English values supplied per key. +- **test command**: `/test-regression` + +## Coverage Summary +- Facet aggregation endpoint returns GEMMA dimension counts — covered (TC-1, TC-2) +- Facet counts reflect the currently filtered set — covered (TC-3, TC-4) +- Facets combine with free-text search — covered (TC-5) +- Facet aggregation queries MUST be bounded — covered (TC-6, TC-7) +- Facet counts MUST respect the caller's RBAC/tenant context — covered (TC-8, TC-16) +- Filter state is URL-encoded and deep-linkable — covered (TC-9) +- Facet sidebar UI on the module and dienst index pages — covered (TC-10, TC-11, TC-12) +- A facet selection can be saved as a view — covered (TC-13) +- Facet aggregation results are cached — covered (TC-14, TC-15, TC-16) +- Facet labels and UI strings are translated — covered (TC-17, TC-18) + +## Out of Scope +- Free-text relevance ranking behavior — unchanged by this feature (out of scope per proposal); no new test cases beyond confirming existing search still functions alongside facets (TC-5, TC-10). +- Cross-app/federated facet search — deferred per proposal's Out of Scope; not tested here. diff --git a/openspec/changes/organisation-merge/.openspec.yaml b/openspec/changes/organisation-merge/.openspec.yaml new file mode 100644 index 00000000..cf371d3a --- /dev/null +++ b/openspec/changes/organisation-merge/.openspec.yaml @@ -0,0 +1,2 @@ +schema: conduction +created: 2026-07-23 diff --git a/openspec/changes/organisation-merge/context-brief.md b/openspec/changes/organisation-merge/context-brief.md new file mode 100644 index 00000000..dfd29823 --- /dev/null +++ b/openspec/changes/organisation-merge/context-brief.md @@ -0,0 +1,25 @@ +# Context Brief: organisation-merge + +## What +Admin-triggered merge of organisation A into organisation B (gemeentelijke herindeling or leveranciersovername): re-point all relations — gebruik, contracts, contactpersoon role records, koppelingen, compliance records, aanbod ownership — to the target org; soft-retire the source org with a tombstone/redirect reference; dry-run preview with per-type counts before execution; full audit trail. + +## Why (evidence) +- VNG Softwarecatalogus issue #141 (merge organisations after herindeling/leveranciersovername with all relations) — a municipal-specific lifecycle no competitor supports. +- Dutch municipal reality: herindelingen happen nearly every year (gemeente count shrinks annually). +- Specter canonical feature: `organisation-merge` (should, demand 5). + +## Current state (read these specs first) +- `openspec/specs/organisatie-service`, `openspec/specs/organization-sync`, `openspec/specs/softwarecatalog-contacts-to-nc` (role records keyed by contactsUid), `openspec/specs/sc-handlers` (org groups + manager hierarchy), `openspec/specs/contract-administration`. +- `openspec/changes/organisation-parent-hierarchy-rbac-fix` (pending) — org hierarchy semantics; stay consistent. +- Progress: `openspec/specs/progress-tracking` — long-running merge should report progress via the existing SSE progress mechanism. + +## Scope +IN: merge service (Controller → Service), dry-run endpoint returning per-relation-type counts, execute endpoint (admin-only, idempotent, resumable or transactional-per-type), tombstone on source org, NC group membership migration, audit log entries, tests incl. dry-run/execute parity, docs. +OUT: undo/rollback of a completed merge (audit trail only), bulk multi-org merges, UI wizard beyond a simple confirm dialog on the organisation detail page. + +## Design constraints +- **OR saveObject is PUT-semantic** — when re-pointing objects you MUST carry ALL existing fields forward; omitting a property nulls it. Test that an untouched field survives the merge. +- OR DELETE is soft-delete; the tombstoned source org must be excluded from listings via its own status field, not via deletion. +- Admin-only: `#[NoAdminRequired]` methods need explicit per-object authorization guards (no-admin-idor gate). +- ADR-001 OR storage; ADR-008 layering; ADR-005 i18n; ADR-009 tests ≥75%. +- OpenSpec delta headers MUST be `### Requirement: `. diff --git a/openspec/changes/organisation-merge/design.md b/openspec/changes/organisation-merge/design.md new file mode 100644 index 00000000..e53225e8 --- /dev/null +++ b/openspec/changes/organisation-merge/design.md @@ -0,0 +1,133 @@ +# Design: organisation-merge + +## Architecture Overview +The merge feature is a new `MergeOrganisatieService`, sitting alongside the existing `OrganisatieService` (organisatie-service spec), invoked by a new `MergeController` per ADR-008's Controller → Service → Mapper layering. It performs no new persistence mechanism: every write is an OpenRegister `saveObject` (via `ObjectService`/mappers already used by `organisatie-service`, `sc-handlers`, and `contract-administration`), and every relation type it walks is one already modeled by an existing schema (`gebruik`, `contract`, `contactpersoon`, `aanbod`/`koppeling`, `compliancy`, `organisatie`). Long-running execute runs report through the existing `ProgressTracker` (progress-tracking spec) and NC group membership is migrated through the existing `GroupHandler`/`OrganizationHandler` (sc-handlers spec). + +``` +OrganisatieDetail.vue (confirm dialog) + │ POST /merge/dry-run, POST /merge + ▼ +MergeController (admin-only, per-object auth guard) + │ + ▼ +MergeOrganisatieService + ├── walkRelations(sourceUuid, targetUuid, commit: bool) ─┬─ per relation type: + │ │ gebruik (afnemer/deelnemers) + │ │ contract (organisation refs) + │ │ contactpersoon (organisatie) + │ │ aanbod/koppeling (aanbieder) + │ │ compliancy (@self.organisation) + ├── migrateGroupMembership(sourceUuid, targetUuid) ── sc-handlers GroupHandler/OrganizationHandler + ├── ProgressTracker (progress-tracking) ── phase/percentage per relation type + ├── AuditLogService (existing audit mechanism) ── one entry per type + one summary entry + └── tombstoneSource(sourceUuid, targetUuid) ── PUT-semantic full-object re-save +``` + +## API Design + +### `POST /api/organisaties/{sourceUuid}/merge/dry-run` +**Request:** +```json +{ + "targetUuid": "b2c3d4e5-...-target-org-uuid" +} +``` +**Response:** +```json +{ + "sourceUuid": "a1b2c3d4-...", + "targetUuid": "b2c3d4e5-...", + "counts": { + "gebruik": 12, + "contract": 4, + "contactpersoon": 7, + "aanbod": 3, + "compliancy": 9, + "groupMembers": 5 + }, + "blockers": [] +} +``` +`blockers` is a non-empty array (each `{type, message}`) when the merge cannot proceed — e.g. `sourceUuid === targetUuid`, target already tombstoned, or source has children per the pending parent-hierarchy change (blocked, not auto-reparented, per proposal Open Questions). A non-empty `blockers` array means execute MUST be refused with the same validation, so dry-run and execute can never structurally disagree on whether a merge is legal. + +### `POST /api/organisaties/{sourceUuid}/merge` +**Request:** +```json +{ + "targetUuid": "b2c3d4e5-...-target-org-uuid", + "confirm": true +} +``` +**Response:** +```json +{ + "operationId": "org_merge_6f9a...", + "sourceUuid": "a1b2c3d4-...", + "targetUuid": "b2c3d4e5-...", + "status": "completed", + "counts": { + "gebruik": 12, + "contract": 4, + "contactpersoon": 7, + "aanbod": 3, + "compliancy": 9, + "groupMembers": 5 + } +} +``` +`operationId` is the `ProgressTracker` operation id (progress-tracking `startOperation('org_merge', ...)`); the SSE progress endpoint already exposed by progress-tracking is reused to poll/stream phase updates for this id. On a resumed/re-run call against a partially-completed merge, `status` is `"completed"` once all types finish, and per-type counts reflect only what that call itself re-pointed (already-completed types are skipped and reported from the stored audit record, not re-counted). + +## Database Changes +No custom database tables (ADR-001). Two additive, non-breaking schema changes on the existing `organisatie` schema (softwarecatalogus register, per `lib/Settings/softwarecatalogus_register.json`): +- `status` gains an additional allowed value `samengevoegd` ("merged") alongside existing values, used as the tombstone marker. +- New optional field `mergedInto` (string, organisation UUID) — set on the source organisation once merge completes; absent/`null` on every organisation that has never been a merge source. + +No fields are removed from any schema; `gebruik`, `contract`, `contactpersoon`, `aanbod`/`koppeling`, and `compliancy` are re-pointed using their existing organisation-reference fields (`afnemer`/`deelnemers`, contract's organisation-referencing fields, `organisatie`, `aanbieder`, `@self.organisation`) — no new fields on those schemas. + +## Nextcloud Integration +- Controllers: `MergeController` (new) — `#[NoAdminRequired]` on both endpoints with an explicit admin-group authorization guard in the method body (no-admin-idor gate; route-level annotation alone is insufficient). +- Services: `MergeOrganisatieService` (new, softwarecatalog `lib/Service/`), reusing `OrganisatieService`/`OrganisationMapper` (organisatie-service), `ProgressTracker` (progress-tracking), `GroupHandler`/`OrganizationHandler` (sc-handlers), and the existing audit-log service used elsewhere in the app. +- Mappers/Entities: no new OR schemas; existing mappers for `gebruik`, `contract`, `contactpersoon`, `aanbod`/`koppeling`, `compliancy`, `organisatie` via `ObjectService`. +- Events/Hooks: none new — the merge is a synchronous (or SSE-tracked long-running) service call, not an OpenRegister save/update/delete event handler. It does not hook into `sc-handlers`' lifecycle handlers; it calls their public group/membership methods directly. + +## Security Considerations +- Both `merge/dry-run` and `merge` are admin-only: `#[NoAdminRequired]` route annotation plus an explicit `IGroupManager::isInGroup($uid, 'admin')`-style guard in the controller/service, satisfying the no-admin-idor and semantic-auth gates (annotation alone does not imply the check ran). +- `sourceUuid`/`targetUuid` are validated as existing, non-tombstoned organisations before any read of relation objects; a non-existent or already-tombstoned UUID on either side is a `blockers` entry (dry-run) or a 400/409 (execute), never a silent no-op. +- Execute is guarded against `sourceUuid === targetUuid` (would otherwise silently no-op-tombstone a live organisation). +- All writes go through OpenRegister's own RBAC/ACL layer (ObjectService/saveObject) — the merge service does not bypass OR-level authorization on the objects it re-points; it only supplies the elevated context an admin action requires (consistent with the `SystemOperationContext` pattern used elsewhere in the fleet for admin-initiated cross-organisation writes). +- Audit log entries record actor, timestamp, source/target UUIDs, and per-type counts for every dry-run and execute call, satisfying traceability for a destructive-adjacent operation. + +## NL Design System +The confirm dialog on the organisation detail page uses `CnFormDialog` (ADR-012 — no custom modal), NL Design System tokens via NC CSS variables for warning/destructive styling (ADR-003 — no hardcoded colors), and reuses the existing SSE progress UI pattern (if one exists in fe-settings-ui/fe-organizations for sync operations) to show the per-type progress bar during execute rather than introducing a new progress-UI component. + +## File Structure +``` +lib/ + Controller/ + MergeController.php (new) + Service/ + MergeOrganisatieService.php (new) +src/ + modals/ + MergeOrganisationDialog.vue (new — confirm dialog, CnFormDialog-based) + store/ + organisationsStore.js (modified — dry-run/execute actions) +tests/ + phpunit/ + MergeOrganisatieServiceTest.php (new) + MergeControllerTest.php (new) + vitest/ + MergeOrganisationDialog.spec.js (new) +docs/ + features/ + organisation-merge.md (new, with Playwright screenshots per ADR-010) +``` + +## Seed Data +No new schema is introduced (only additive fields on the existing `organisatie` schema), so no new seed objects are required. Existing `organisatie` seed data is sufficient to exercise dry-run/execute in dev; one existing seed organisation SHOULD be exercised as a merge source in manual/dev testing, but no seed data ships pre-tombstoned (a tombstoned seed organisation would need to be excluded from every other feature's seed-data assumptions, which is unnecessary complexity for a demo dataset). + +## Trade-offs +- **Shared dry-run/execute walk vs. separate implementations**: chosen a single `walkRelations(..., commit: bool)` routine over two separate code paths specifically to make dry-run/execute parity structural rather than a testing convention (Risk 3 in proposal.md). Trade-off: the routine is slightly more complex (branches on `commit`) than two simple functions would be. +- **Transactional-per-relation-type vs. one big transaction**: chosen per-type transactions (with resumability) over a single all-or-nothing transaction because OpenRegister's object store does not offer cross-object-type multi-row transactions, and a merge can touch hundreds of objects across 5+ types — an all-or-nothing approach would make any single failure (e.g. one malformed `contract` object) block re-pointing everything else, including types that succeeded. Trade-off: a merge can be observed in a partially-completed state (mitigated by per-type audit + resumability, and the source is only tombstoned once every type is confirmed complete). +- **Tombstone via status field vs. hard delete**: chosen per OR's existing soft-delete convention and the proposal's explicit design constraint — deletion loses the audit-visible "this org still exists, just merged" fact the tombstone `mergedInto` reference preserves. +- **Blocking merges on organisations with children vs. auto-reparenting**: chosen to block (Open Question in proposal.md) rather than guess a reparenting policy while `organisation-parent-hierarchy-rbac-fix` is still pending — reparenting semantics should be decided once parent/child creation itself is confirmed working end-to-end. diff --git a/openspec/changes/organisation-merge/proposal.md b/openspec/changes/organisation-merge/proposal.md new file mode 100644 index 00000000..6074f513 --- /dev/null +++ b/openspec/changes/organisation-merge/proposal.md @@ -0,0 +1,66 @@ +# Proposal: organisation-merge + +## Summary +Adds an admin-triggered organisation-merge capability: re-point every relation that references a source organisation (gebruik, contracts, contactpersoon role records, aanbod/koppelingen ownership, compliance records, NC group membership) onto a target organisation, soft-retire the source with a tombstone rather than deleting it, and expose a dry-run preview (per-relation-type counts) before an idempotent, audited execute step. This gives municipalities a supported path through gemeentelijke herindeling (municipal mergers) and leveranciersovername (supplier takeovers) without hand-editing dozens of OpenRegister objects. + +## Motivation +VNG Softwarecatalogus issue #141 asks for organisation merge with all relations preserved — a municipal-specific lifecycle event no competitor product supports. Dutch gemeente count shrinks nearly every year through herindeling, and supplier consolidations happen independently; today an admin has no supported way to fold organisation A into organisation B — every gebruik, contract, contactpersoon role record, aanbod/koppeling ownership record, compliance record, and NC group membership referencing A becomes orphaned or must be hand-edited object by object, which is error-prone and leaves no audit trail. Specter has flagged `organisation-merge` as a canonical "should" feature (demand 5). + +## Affected Projects +- [ ] Project: `softwarecatalog` — new merge service (Controller → Service), dry-run + execute REST endpoints, tombstone status on the source organisation, NC group-membership migration, audit log entries, a confirm dialog on the organisation detail page, progress reporting via the existing SSE mechanism + +## Scope + +### In Scope +- A `MergeOrganisatieService` (Controller → Service layering per ADR-008) that, given a source and target organisation UUID, re-points: `gebruik` (afnemer/deelnemers), `contract` (all organisation-referencing fields), `contactpersoon` role records (`organisatie`), `aanbod`/`koppeling` ownership (`aanbieder`), `compliancy` records owned by the source (`@self.organisation`), and any other object whose `@self.organisation` equals the source UUID. +- A dry-run endpoint that returns per-relation-type counts of what *would* change, without writing anything. +- An execute endpoint, admin-only, that performs the re-pointing per relation type, is idempotent (safe to re-run/resume after partial failure — transactional-per-type), and reports progress via the existing SSE progress-tracking mechanism for long-running merges. +- Soft-retiring the source organisation with a tombstone: a status field (not deletion) that excludes it from normal listings and carries a reference to the target organisation it was merged into. +- NC group-membership migration: users in the source organisation's NC group are added to the target organisation's NC group (per sc-handlers `OrganizationHandler` conventions). +- Audit log entries for the merge operation (who, when, source, target, per-type counts). +- A confirm dialog on the organisation detail page to trigger a merge (no multi-step wizard). +- Preserving every carried-forward field on re-pointed objects (OR `saveObject` is PUT-semantic — omitted fields are nulled). +- Tests (dry-run/execute parity, idempotency, PUT-semantics field preservation, tombstone exclusion, admin-only authorization) and documentation. + +### Out of Scope +- Undo/rollback of a completed merge — the audit trail is the only record; a botched merge is corrected manually or via a follow-up merge, not an automated undo. +- Bulk multi-organisation merges (merging more than one source into one target in a single operation). +- A multi-step UI wizard beyond a simple confirm dialog on the organisation detail page. + +## Approach +Introduce a `MergeOrganisatieService` alongside the existing `OrganisatieService`, invoked by a new `MergeController` (or a merge action on the existing organisation controller) with two endpoints: `POST /api/organisaties/{uuid}/merge/dry-run` and `POST /api/organisaties/{uuid}/merge`, both admin-only and both taking a `targetUuid`. The dry-run and execute paths share one internal relation-walking routine parameterised by a `commit: bool` flag, guaranteeing dry-run/execute parity by construction. Execute processes relation types one at a time inside per-type transactions (via OpenRegister's object store), recording progress through `ProgressTracker` (`progress-tracking` spec) and writing an audit log entry per relation type plus a final summary entry. On completion the source organisation is updated (PUT-semantic: full payload re-saved) with a tombstone status and a `mergedInto` reference to the target; it is excluded from listings via that status field. Details land in design.md. + +## New Dependencies +None. + +## Impact +- Backend: new `MergeOrganisatieService`, new controller endpoints, reuse of `ProgressTracker` (progress-tracking), `OrganisatieService` / `OrganisationMapper` (organisatie-service), `sc-handlers` `OrganizationHandler`/`GroupHandler` for NC group migration, and the OpenRegister object store for every re-pointed schema (`gebruik`, `contract`, `contactpersoon`, `aanbod`/`koppeling`, `compliancy`, `organisatie`). +- Frontend: a confirm dialog on the organisation detail page (fe-organizations) and a dry-run preview surface (counts per relation type) before the admin confirms execute. +- Audit: new audit log entries for merge dry-run and execute actions. +- Data: source organisation objects gain a tombstone status + `mergedInto` reference; no schema field is removed from any existing schema. + +## Cross-Project Dependencies +None outside softwarecatalog. Builds entirely on existing softwarecatalog specs (`organisatie-service`, `organization-sync`, `softwarecatalog-contacts-to-nc`, `sc-handlers`, `contract-administration`, `progress-tracking`) and stays consistent with the organisation-hierarchy semantics defined in the pending `organisation-parent-hierarchy-rbac-fix` change (a merged source organisation that has children keeps its parent/child links pointing at whatever UUID is still valid — the merge does not currently re-parent children; see Open Questions). + +## Risks + +### Risk 1: Partial-failure mid-merge leaves the catalog in a mixed state +**Severity:** High — **Mitigation:** execute is transactional-per-relation-type and idempotent: each type is fully re-pointed and audited before the next begins, and re-running execute against a partially-merged pair only re-processes relation types not yet marked complete (a per-merge-operation progress/audit record tracks which types finished). The source organisation is only tombstoned after all relation types report complete. + +### Risk 2: PUT-semantic saves silently null out fields on re-pointed objects +**Severity:** High — **Mitigation:** every re-point reads the full existing object, mutates only the organisation-reference field(s), and re-saves the complete payload; a dedicated test asserts an untouched field on a re-pointed object survives the merge unchanged. + +### Risk 3: Dry-run and execute drift apart over time (dry-run undercounts or overcounts what execute actually changes) +**Severity:** Medium — **Mitigation:** dry-run and execute share one internal relation-walking implementation gated by a `commit` flag, so they cannot structurally diverge; a parity test asserts dry-run counts equal the number of objects execute actually re-points for the same input. + +### Risk 4: A non-admin triggers or observes a merge on organisations they don't manage +**Severity:** Medium — **Mitigation:** both endpoints are `#[NoAdminRequired]` with an explicit admin-group authorization guard in the method body (per the no-admin-idor gate), not just route-level annotation. + +### Risk 5: Child organisations of a tombstoned source become unreachable via hierarchy navigation +**Severity:** Low — **Mitigation:** out of scope for this change (see Open Questions); the tombstone carries a `mergedInto` forward-reference so any hierarchy-aware UI can redirect, and a follow-up change can re-parent children if `organisation-parent-hierarchy-rbac-fix` ships first. + +## Rollback Strategy +This change is additive (new service, new endpoints, one new status value + one new field on the `organisatie` schema for the tombstone). Reverting the code removes the merge endpoints and UI entry point without touching existing data. Because there is no automated undo, any merge already executed before a rollback remains applied — the mitigation is to disable the feature (route removal) rather than data rollback; already-tombstoned source organisations and already-repointed relation objects stay as-is, which is the documented (out-of-scope-undo) behaviour, not a bug introduced by the rollback. + +## Open Questions +- Should a merge on a source organisation that has children (per `organisation-parent-hierarchy-rbac-fix`) be blocked, or should children be re-parented to the target as part of the merge? This change assumes blocked-with-a-clear-error for now — deferred to the parent-hierarchy change landing first. diff --git a/openspec/changes/organisation-merge/specs/organisatie-service/spec.md b/openspec/changes/organisation-merge/specs/organisatie-service/spec.md new file mode 100644 index 00000000..cb8bd6d8 --- /dev/null +++ b/openspec/changes/organisation-merge/specs/organisatie-service/spec.md @@ -0,0 +1,36 @@ +## MODIFIED Requirements + +### Requirement: The system SHALL update the active flag of an OpenRegister organisation from a SoftwareCatalog status (REQ-002) + +`updateOrganizationStatus(organizationUuid, objectData)` MUST find the OpenRegister `Organisation` by SC UUID via `OrganisationMapper::findByUuid`, map `objectData['beoordeling']` (default `'actief'`) through `mapStatus` to a boolean, call `setActive` + `save`. On success it MUST return `true`; on any exception it MUST log + return `false` (never propagate). + +`mapStatus(status)` MUST normalise its input (lowercase + trim) and return: `true` for `actief` / `active`; `false` for `inactief` / `inactive` / `deactief`; `false` for `samengevoegd` (the organisation-merge tombstone status — a merged-away organisation MUST NOT be reported as active); `true` for any other unrecognised value (default-active for unknown statuses). + +#### Scenario: Active status maps to true +- WHEN `mapStatus('Actief')` is called +- THEN the return value MUST be `true` + +#### Scenario: Inactive variants map to false +- WHEN `mapStatus(' inactief ')` is called +- THEN the return value MUST be `false` +- AND `mapStatus('deactief')` MUST also return `false` + +#### Scenario: Merged (tombstoned) status maps to false +- WHEN `mapStatus('samengevoegd')` is called +- THEN the return value MUST be `false` + +#### Scenario: Unknown status defaults to active +- WHEN `mapStatus('pending')` is called +- THEN the return value MUST be `true` + +#### Scenario: Update success +- GIVEN an organisation exists in OR with the supplied UUID +- WHEN `updateOrganizationStatus('uuid-1', ['beoordeling' => 'inactief'])` is called +- THEN the OR organisation's `active` flag MUST be `false` after the call +- AND the method MUST return `true` + +#### Scenario: Tombstoning via merge also deactivates the OR entity +- GIVEN an organisation exists in OR with the supplied UUID +- WHEN `updateOrganizationStatus('uuid-1', ['beoordeling' => 'samengevoegd'])` is called (as part of `organisation-merge` tombstoning the source) +- THEN the OR organisation's `active` flag MUST be `false` after the call +- AND the method MUST return `true` diff --git a/openspec/changes/organisation-merge/specs/organisation-merge/spec.md b/openspec/changes/organisation-merge/specs/organisation-merge/spec.md new file mode 100644 index 00000000..11210b9b --- /dev/null +++ b/openspec/changes/organisation-merge/specs/organisation-merge/spec.md @@ -0,0 +1,180 @@ +# organisation-merge Specification + +**Status**: planned +**Scope**: softwarecatalog +**OpenSpec changes**: +- [organisation-merge](../../changes/organisation-merge/) + +## Purpose +Lets an admin fold organisation A (gemeentelijke herindeling or leveranciersovername) into organisation B: re-pointing gebruik, contracts, contactpersoon role records, aanbod/koppeling ownership, compliance records, and NC group membership onto the target, then soft-retiring the source with a tombstone. Provides a dry-run preview before any write and an idempotent, audited execute step, per VNG Softwarecatalogus issue #141 and ADR-001/ADR-008/ADR-009. + +## ADDED Requirements + +### Requirement: The system SHALL preview a merge with per-relation-type counts before any write +`MergeOrganisatieService::dryRun(sourceUuid, targetUuid)` MUST enumerate every object referencing `sourceUuid` across `gebruik` (`afnemer`, `deelnemers`), `contract`, `contactpersoon` (`organisatie`), `aanbod`/`koppeling` (`aanbieder`), and `compliancy` (`@self.organisation`) plus the count of NC group members who would be migrated, and MUST return a count per relation type without writing, saving, or otherwise mutating any object. The dry-run MUST use the same relation-enumeration logic execute uses (see the parity requirement below), gated by a `commit: false` flag rather than a separate implementation. + +#### Scenario: Dry-run reports counts without writing +- GIVEN organisation A has 12 gebruik, 4 contract, 7 contactpersoon, 3 aanbod, and 9 compliancy objects referencing it, and 5 NC group members +- WHEN `dryRun('A-uuid', 'B-uuid')` is called +- THEN the response MUST report `{gebruik: 12, contract: 4, contactpersoon: 7, aanbod: 3, compliancy: 9, groupMembers: 5}` +- AND no object referencing A MUST have been modified +- AND organisation A's `status` MUST remain unchanged + +#### Scenario: Dry-run on organisations with no relations reports all zeros +- GIVEN organisation A has no objects referencing it and no group members +- WHEN `dryRun('A-uuid', 'B-uuid')` is called +- THEN every count in the response MUST be `0` +- AND `blockers` MUST be empty (a merge with zero relations is still a legal, executable merge) + +### Requirement: Dry-run and execute MUST report structurally identical counts for the same unchanged input +Because dry-run and execute share one relation-walking routine gated by `commit`, the per-type counts `dryRun` reports for a given `(sourceUuid, targetUuid)` pair MUST equal the number of objects `execute` actually re-points for that same pair, provided no relation objects are created, deleted, or re-pointed by another process between the two calls. + +#### Scenario: Execute re-points exactly what dry-run counted +- GIVEN `dryRun('A-uuid', 'B-uuid')` reported `{gebruik: 12, contract: 4, contactpersoon: 7, aanbod: 3, compliancy: 9}` +- AND no relation object is created, deleted, or modified between the dry-run and the execute call +- WHEN `execute('A-uuid', 'B-uuid')` is called +- THEN exactly 12 `gebruik`, 4 `contract`, 7 `contactpersoon`, 3 `aanbod`, and 9 `compliancy` objects MUST be re-pointed +- AND the execute response's `counts` MUST equal the dry-run response's `counts` + +### Requirement: Execute MUST re-point every relation type while preserving every unrelated field on each object +`MergeOrganisatieService::execute(sourceUuid, targetUuid)` MUST, for every object identified by the relation walk, read the object's full current payload, replace only the organisation-reference field(s) that equal `sourceUuid` with `targetUuid` (including array fields such as `deelnemers` where only the matching entry is replaced), and re-save the complete payload — because OpenRegister's `saveObject` is PUT-semantic, omitting any existing field would null it. + +#### Scenario: An untouched field survives re-pointing +- GIVEN a `contract` object owned by organisation A with `contractNummer: "C-100"`, `kosten: 5000`, and `documentReferentie` set to an NC Files link +- WHEN `execute('A-uuid', 'B-uuid')` re-points that contract +- THEN the contract's organisation-reference field MUST equal `B-uuid` +- AND `contractNummer`, `kosten`, and `documentReferentie` MUST be unchanged from their pre-merge values + +#### Scenario: A gebruik object with the source as one of several deelnemers only replaces the matching entry +- GIVEN a `gebruik` object with `deelnemers: ['A-uuid', 'C-uuid', 'D-uuid']` +- WHEN `execute('A-uuid', 'B-uuid')` re-points that gebruik object +- THEN `deelnemers` MUST equal `['B-uuid', 'C-uuid', 'D-uuid']` +- AND `C-uuid` and `D-uuid` MUST be unaffected + +### Requirement: Execute MUST be idempotent and resumable per relation type +Execute MUST process relation types one at a time, each inside its own transactional unit, and MUST record which types have completed for a given merge operation. Re-invoking `execute` for a merge operation that already completed some relation types MUST NOT re-process or double-move already-completed types, and MUST NOT fail the objects that were never touched by re-pointing them twice (no duplicate re-point, no double-count in the audit log). + +#### Scenario: Re-running execute after a partial failure only finishes remaining types +- GIVEN a prior `execute('A-uuid', 'B-uuid')` call completed the `gebruik` and `contract` types then failed before processing `contactpersoon` +- WHEN `execute('A-uuid', 'B-uuid')` is called again +- THEN `gebruik` and `contract` objects MUST NOT be re-pointed a second time +- AND `contactpersoon`, `aanbod`, and `compliancy` MUST be processed to completion +- AND the final audit summary MUST report each relation type's count exactly once + +#### Scenario: Re-running a fully completed merge is a safe no-op +- GIVEN `execute('A-uuid', 'B-uuid')` previously completed all relation types and tombstoned A +- WHEN `execute('A-uuid', 'B-uuid')` is called again +- THEN no relation object MUST be modified +- AND the response MUST report the merge as already completed rather than erroring + +### Requirement: The source organisation MUST be tombstoned, never hard-deleted +On successful completion of all relation types, `execute` MUST update the source organisation (via a full, PUT-semantic re-save preserving all other fields) to set `status = 'samengevoegd'` and `mergedInto = targetUuid`. The source organisation MUST NOT be deleted. Listing queries for organisations MUST exclude organisations whose `status` equals `'samengevoegd'` by filtering on that status field, not by relying on soft-delete. + +#### Scenario: Source organisation is tombstoned after a successful merge +- GIVEN `execute('A-uuid', 'B-uuid')` completes all relation types successfully +- WHEN organisation A is subsequently read +- THEN A's `status` MUST equal `'samengevoegd'` +- AND A's `mergedInto` MUST equal `'B-uuid'` +- AND A MUST still exist as a readable object (not deleted) +- AND every other pre-existing field on A MUST be unchanged + +#### Scenario: Tombstoned organisation is excluded from the default organisation listing +- GIVEN organisation A has `status = 'samengevoegd'` +- WHEN the default organisation index listing is queried +- THEN A MUST NOT appear in the results +- AND A MUST still be resolvable by direct UUID lookup (e.g. for the redirect the tombstone's `mergedInto` supports) + +#### Scenario: The tombstone is applied only after every relation type completes +- GIVEN `execute('A-uuid', 'B-uuid')` has completed `gebruik` and `contract` but not yet `contactpersoon`, `aanbod`, or `compliancy` +- WHEN organisation A is read at that point +- THEN A's `status` MUST NOT yet equal `'samengevoegd'` + +### Requirement: NC group membership MUST be migrated from source to target +Execute MUST add every Nextcloud user who is a member of the source organisation's NC group (per `sc-handlers` `OrganizationHandler`/`GroupHandler`) to the target organisation's NC group, without removing them from the source group during execute (group cleanup, if any, happens as part of the tombstone step, not as a data-loss risk mid-merge). + +#### Scenario: Source group members gain target group membership +- GIVEN organisation A's NC group has members `[alice, bob]` and organisation B's NC group has member `[carol]` +- WHEN `execute('A-uuid', 'B-uuid')` completes +- THEN organisation B's NC group MUST contain `[alice, bob, carol]` +- AND no error MUST occur if `alice` or `bob` was already a member of B's group + +### Requirement: Both merge endpoints MUST be admin-only with an explicit per-object authorization guard +`POST /api/organisaties/{uuid}/merge/dry-run` and `POST /api/organisaties/{uuid}/merge` MUST require the calling user to be a member of the Nextcloud `admin` group, verified by an explicit guard in the controller/service method body — the `#[NoAdminRequired]` route annotation alone MUST NOT be treated as sufficient authorization (no-admin-idor gate). + +#### Scenario: Non-admin user is rejected +- GIVEN a user who is not a member of the `admin` group +- WHEN that user calls `POST /api/organisaties/{uuid}/merge` with any `targetUuid` +- THEN the response MUST have status 403 +- AND no relation object MUST be modified +- AND no audit log entry for a merge MUST be written + +#### Scenario: Admin user is authorized +- GIVEN a user who is a member of the `admin` group +- WHEN that user calls `POST /api/organisaties/{uuid}/merge/dry-run` with a valid `targetUuid` +- THEN the response MUST have status 200 with the per-type counts + +### Requirement: Merge requests MUST be validated and rejected with blockers before any write +Both endpoints MUST reject a merge (dry-run returns non-empty `blockers`; execute returns HTTP 400/409 and performs no write) when: `sourceUuid` equals `targetUuid`; either UUID does not resolve to an existing organisation; the source organisation already has `status = 'samengevoegd'`; or the target organisation already has `status = 'samengevoegd'`. + +#### Scenario: Self-merge is rejected +- WHEN `execute('A-uuid', 'A-uuid')` is called +- THEN the response MUST be an error (400/409) and no object MUST be modified + +#### Scenario: Merging into an already-tombstoned target is rejected +- GIVEN organisation B has `status = 'samengevoegd'` (B was itself merged into another organisation) +- WHEN `execute('A-uuid', 'B-uuid')` is called +- THEN the response MUST be an error and no object MUST be modified + +#### Scenario: Re-merging an already-tombstoned source is rejected as a validation error, not a silent success +- GIVEN organisation A has `status = 'samengevoegd'` with `mergedInto = 'B-uuid'` +- WHEN `execute('A-uuid', 'C-uuid')` is called with a different target C +- THEN the response MUST be an error and A's `mergedInto` MUST remain `'B-uuid'` + +### Requirement: Execute MUST report progress via the existing SSE progress-tracking mechanism +Execute MUST call `ProgressTracker::startOperation('org_merge', ...)` at the start of the merge, `setPhase`/`incrementProgress`/`updateStatistics` as each relation type is processed, and `completeOperation` on success, so the operation is observable through the existing progress-tracking SSE surface without a new progress mechanism (no app-local notification/progress dispatch, per ADR-031 precedent). + +#### Scenario: A long-running merge is observable via the existing progress endpoint +- GIVEN `execute('A-uuid', 'B-uuid')` is in flight and has completed 2 of 5 relation types +- WHEN `getProgress(operationId)` is called (per the progress-tracking spec) +- THEN the returned snapshot's `processed_items`/`statistics` MUST reflect the 2 completed types +- AND `phase` MUST NOT be `'completed'` until all types finish + +### Requirement: Every dry-run and execute call MUST produce an audit log entry +`dryRun` and `execute` MUST each write an audit log entry recording the acting user, timestamp, `sourceUuid`, `targetUuid`, and (for execute) per-relation-type counts; execute MUST additionally write one entry per relation type as it completes plus a final summary entry, so a partially-completed merge is traceable from the audit log alone. + +#### Scenario: Execute writes a summary audit entry +- GIVEN `execute('A-uuid', 'B-uuid')` completes successfully as user `admin1` +- WHEN the audit log is queried for this operation +- THEN it MUST contain a summary entry with actor `admin1`, `sourceUuid = 'A-uuid'`, `targetUuid = 'B-uuid'`, and the final per-type counts + +#### Scenario: Dry-run writes an audit entry without a completion count +- GIVEN `dryRun('A-uuid', 'B-uuid')` is called as user `admin1` +- WHEN the audit log is queried +- THEN it MUST contain an entry recording the dry-run call with actor `admin1` and the reported counts + +## Non-Functional Requirements + +- **Performance:** Dry-run over a source organisation with up to a few hundred referencing objects across all five relation types MUST complete within a few seconds; execute over the same volume runs as a tracked long-running operation (progress-tracking) rather than being required to complete synchronously within a request timeout. +- **Accessibility:** The confirm dialog (CnFormDialog) and progress display MUST meet WCAG 2.2 AA — destructive-adjacent action confirmation MUST be keyboard-operable and screen-reader announced (dialog role, focus management), and the progress indicator MUST expose textual percentage/phase, not color alone. +- **Internationalization:** Dutch and English MUST be supported (ADR-005) for the confirm dialog, dry-run preview counts labels, and any merge-related error/blocker messages. + +## Acceptance Criteria + +- [ ] Dry-run returns per-relation-type counts (`gebruik`, `contract`, `contactpersoon`, `aanbod`, `compliancy`, `groupMembers`) without writing any object +- [ ] Execute re-points all five relation types, preserving every untouched field on each re-pointed object (PUT-semantics test) +- [ ] Dry-run and execute report identical counts for the same unchanged input (parity test) +- [ ] Execute is idempotent: re-running against a partially or fully completed merge does not double-process any relation type +- [ ] Source organisation is tombstoned (`status = 'samengevoegd'`, `mergedInto` set) only after all relation types complete, never deleted, and excluded from default listings +- [ ] NC group membership is migrated from source to target +- [ ] Both endpoints reject non-admin callers (403) with an explicit per-object guard, not just route annotation +- [ ] Self-merge, already-tombstoned-source, and already-tombstoned-target requests are rejected with blockers/errors and no writes +- [ ] Execute reports progress via the existing `ProgressTracker`/SSE mechanism +- [ ] Every dry-run and execute call is captured in the audit log +- [ ] Test coverage ≥75% for new code (ADR-009) +- [ ] i18n: Dutch and English translations for all new user-facing strings (ADR-005) +- [ ] Documentation with Playwright screenshots in `docs/features/organisation-merge.md` (ADR-010) + +## Notes +- Out of scope (see proposal.md): undo/rollback of a completed merge, bulk multi-organisation merges, a multi-step UI wizard. +- Open question (see proposal.md): whether a source organisation with children (per the pending `organisation-parent-hierarchy-rbac-fix` change) should be blocked from merging or have its children re-parented; this change blocks it via the validation requirement above until that question is resolved. +- Related ADRs: ADR-001 (OpenRegister storage, no custom tables), ADR-008 (Controller → Service → Mapper layering), ADR-005 (i18n), ADR-009 (test coverage ≥75%), ADR-031 (no app-local notification dispatch — progress uses the existing mechanism). diff --git a/openspec/changes/organisation-merge/tasks.md b/openspec/changes/organisation-merge/tasks.md new file mode 100644 index 00000000..0fd346df --- /dev/null +++ b/openspec/changes/organisation-merge/tasks.md @@ -0,0 +1,92 @@ +# Tasks: organisation-merge + +## Implementation Tasks + +### Task 1: Schema fields + shared relation-walk (dry-run enumeration) +- **spec_ref**: `openspec/changes/organisation-merge/specs/organisation-merge/spec.md#requirement-the-system-shall-preview-a-merge-with-per-relation-type-counts-before-any-write` +- **files**: `lib/Settings/softwarecatalogus_register.json`, `lib/Service/MergeOrganisatieService.php` +- **acceptance_criteria**: + - GIVEN the register config is imported THEN `organisatie.status` accepts the additional value `samengevoegd` and a new optional `mergedInto` (string, UUID) field is defined, additive and non-breaking for existing objects + - GIVEN a source organisation with objects referencing it across gebruik/contract/contactpersoon/aanbod/compliancy WHEN `dryRun` runs THEN it returns per-type counts and writes nothing + - GIVEN a source organisation with zero relations WHEN `dryRun` runs THEN all counts are 0 and `blockers` is empty +- [ ] Implement +- [ ] Test + +### Task 2: Execute re-pointing with PUT-semantic field preservation and dry-run/execute parity +- **spec_ref**: `openspec/changes/organisation-merge/specs/organisation-merge/spec.md#requirement-execute-must-re-point-every-relation-type-while-preserving-every-unrelated-field-on-each-object` +- **files**: `lib/Service/MergeOrganisatieService.php` +- **acceptance_criteria**: + - GIVEN a contract owned by the source WHEN execute re-points it THEN only the organisation-reference field changes and every other field (e.g. `contractNummer`, `kosten`, `documentReferentie`) survives unchanged + - GIVEN a gebruik object with the source as one of several `deelnemers` WHEN execute re-points it THEN only the matching entry is replaced + - GIVEN the same input dry-run counted WHEN execute runs THEN execute's counts equal dry-run's counts +- [ ] Implement +- [ ] Test + +### Task 3: Per-type transactional processing, idempotency/resumability, and tombstoning +- **spec_ref**: `openspec/changes/organisation-merge/specs/organisation-merge/spec.md#requirement-execute-must-be-idempotent-and-resumable-per-relation-type` +- **files**: `lib/Service/MergeOrganisatieService.php`, `openspec/changes/organisation-merge/specs/organisatie-service/spec.md` (mapStatus) +- **acceptance_criteria**: + - GIVEN execute completed gebruik and contract then failed before contactpersoon WHEN execute is re-invoked THEN gebruik/contract are not re-pointed again and remaining types complete + - GIVEN all relation types complete WHEN execute finishes THEN the source's `status` becomes `samengevoegd`, `mergedInto` is set, the object is not deleted, and `mapStatus('samengevoegd')` returns `false` + - GIVEN not all relation types have completed WHEN the source organisation is read THEN `status` is not yet `samengevoegd` +- [ ] Implement +- [ ] Test + +### Task 4: Migrate NC group membership from source to target +- **spec_ref**: `openspec/changes/organisation-merge/specs/organisation-merge/spec.md#requirement-nc-group-membership-must-be-migrated-from-source-to-target` +- **files**: `lib/Service/MergeOrganisatieService.php` (integrates `sc-handlers` `GroupHandler`/`OrganizationHandler`) +- **acceptance_criteria**: + - GIVEN source group members `[alice, bob]` and target group member `[carol]` WHEN execute completes THEN the target group contains `[alice, bob, carol]` with no error on pre-existing membership +- [ ] Implement +- [ ] Test + +### Task 5: MergeController endpoints with admin-only guard and validation/blockers +- **spec_ref**: `openspec/changes/organisation-merge/specs/organisation-merge/spec.md#requirement-both-merge-endpoints-must-be-admin-only-with-an-explicit-per-object-authorization-guard` +- **files**: `lib/Controller/MergeController.php`, `appinfo/routes.php` +- **acceptance_criteria**: + - GIVEN a non-admin user WHEN they call either endpoint THEN the response is 403 and no object or audit entry is written + - GIVEN `sourceUuid == targetUuid`, an unresolved UUID, or an already-tombstoned source/target WHEN either endpoint is called THEN it returns blockers (dry-run) or a 400/409 (execute) with no writes +- [ ] Implement +- [ ] Test + +### Task 6: Wire progress tracking and audit log entries into execute +- **spec_ref**: `openspec/changes/organisation-merge/specs/organisation-merge/spec.md#requirement-execute-must-report-progress-via-the-existing-sse-progress-tracking-mechanism` +- **files**: `lib/Service/MergeOrganisatieService.php` +- **acceptance_criteria**: + - GIVEN execute is in flight WHEN `getProgress(operationId)` is polled THEN phase/statistics reflect completed relation types and `phase` is not `completed` until all finish + - GIVEN dry-run or execute runs WHEN the audit log is queried THEN it contains an entry per call (execute: per-type + summary) with actor, timestamps, source/target UUIDs and counts +- [ ] Implement +- [ ] Test + +### Task 7: Organisation-detail confirm dialog, store actions, and i18n +- **spec_ref**: `openspec/changes/organisation-merge/specs/organisation-merge/spec.md#non-functional-requirements` +- **files**: `src/modals/MergeOrganisationDialog.vue`, `src/store/organisationsStore.js`, `src/views/organisaties/OrganisatieDetail.vue`, `l10n/nl_NL.js`, `l10n/en_US.js` +- **acceptance_criteria**: + - GIVEN an admin opens the merge dialog on an organisation detail page WHEN they pick a target and confirm THEN the dry-run preview counts render before execute is triggered, and progress streams live via the existing SSE surface + - GIVEN the Nextcloud locale is `nl_NL` or `en_US` WHEN the dialog, preview, and blocker/error messages render THEN all strings are translated (no raw keys or English fallback in `nl_NL`) +- [ ] Implement +- [ ] Test + +### Task 8: Document the feature with Playwright screenshots +- **spec_ref**: `openspec/changes/organisation-merge/specs/organisation-merge/spec.md#acceptance-criteria` +- **files**: `docs/features/organisation-merge.md`, `docs/images/organisation-merge-*.png` +- **acceptance_criteria**: + - GIVEN the feature is implemented WHEN `docs/features/organisation-merge.md` is reviewed THEN it documents the dry-run preview, confirm dialog, and tombstone behaviour with Playwright MCP screenshots +- [ ] Implement +- [ ] Test + +## Verification +- [ ] All tasks checked off +- [ ] `openspec validate` passes +- [ ] Manual testing against acceptance criteria +- [ ] Code review against spec requirements + +## Quality checklist + +- All new/changed business logic covered by PHPUnit unit tests (`tests/Unit/`), minimum 75% coverage for new code (ADR-009) +- New/changed API endpoints (`/merge/dry-run`, `/merge`) covered by Newman/Postman tests +- UI changes (confirm dialog, dry-run preview, progress display) covered by Playwright browser tests +- All tests pass (`composer test`, `newman run`) +- Feature documentation updated in `docs/features/organisation-merge.md` with Playwright screenshots (ADR-010) +- Dutch (`nl_NL`) and English (`en_US`) translation strings added for all new user-facing strings (ADR-005) +- `openspec validate` passes diff --git a/openspec/changes/organisation-merge/test-plan.md b/openspec/changes/organisation-merge/test-plan.md new file mode 100644 index 00000000..c94c0875 --- /dev/null +++ b/openspec/changes/organisation-merge/test-plan.md @@ -0,0 +1,187 @@ +# Test Plan: organisation-merge + +## Test Cases + +### TC-1: Dry-run reports per-relation-type counts without writing +- **spec_ref**: `openspec/changes/organisation-merge/specs/organisation-merge/spec.md#requirement-the-system-shall-preview-a-merge-with-per-relation-type-counts-before-any-write` +- **type**: api +- **persona**: n/a (admin-only backend behaviour) +- **preconditions**: organisation A has 12 gebruik, 4 contract, 7 contactpersoon, 3 aanbod, 9 compliancy objects referencing it, and 5 NC group members +- **steps**: `POST /api/organisaties/{A}/merge/dry-run` with `{targetUuid: B}` +- **expected result**: response reports `{gebruik:12, contract:4, contactpersoon:7, aanbod:3, compliancy:9, groupMembers:5}`; none of those objects or organisation A are modified +- **test command**: `/test-api` + +### TC-2: Dry-run on a relation-free organisation reports all zeros, no blockers +- **spec_ref**: `openspec/changes/organisation-merge/specs/organisation-merge/spec.md#requirement-the-system-shall-preview-a-merge-with-per-relation-type-counts-before-any-write` +- **type**: api +- **preconditions**: organisation A has no referencing objects and no group members +- **steps**: `POST /api/organisaties/{A}/merge/dry-run` with `{targetUuid: B}` +- **expected result**: every count is `0`; `blockers` is empty +- **test command**: `/test-api` + +### TC-3: Execute re-points exactly what dry-run counted (parity) +- **spec_ref**: `openspec/changes/organisation-merge/specs/organisation-merge/spec.md#requirement-dry-run-and-execute-must-report-structurally-identical-counts-for-the-same-unchanged-input` +- **type**: api +- **preconditions**: dry-run for (A, B) reported known counts; no relation objects change between the two calls +- **steps**: `POST /api/organisaties/{A}/merge/dry-run`, then `POST /api/organisaties/{A}/merge` with the same target +- **expected result**: execute's response `counts` equal dry-run's `counts`; the exact number of objects of each type are re-pointed +- **test command**: `/test-api` + +### TC-4: Untouched fields survive re-pointing (PUT-semantics) +- **spec_ref**: `openspec/changes/organisation-merge/specs/organisation-merge/spec.md#requirement-execute-must-re-point-every-relation-type-while-preserving-every-unrelated-field-on-each-object` +- **type**: api +- **preconditions**: a `contract` owned by A has `contractNummer: "C-100"`, `kosten: 5000`, `documentReferentie` set +- **steps**: execute merge (A → B) +- **expected result**: the contract's organisation-reference field equals B; `contractNummer`, `kosten`, `documentReferentie` are unchanged +- **test command**: `/test-api` + +### TC-5: A gebruik object with the source as one of several deelnemers only replaces the matching entry +- **spec_ref**: `openspec/changes/organisation-merge/specs/organisation-merge/spec.md#requirement-execute-must-re-point-every-relation-type-while-preserving-every-unrelated-field-on-each-object` +- **type**: api +- **preconditions**: a `gebruik` object has `deelnemers: [A, C, D]` +- **steps**: execute merge (A → B) +- **expected result**: `deelnemers` becomes `[B, C, D]`; C and D unaffected +- **test command**: `/test-api` + +### TC-6: Re-running execute after a partial failure only finishes remaining relation types +- **spec_ref**: `openspec/changes/organisation-merge/specs/organisation-merge/spec.md#requirement-execute-must-be-idempotent-and-resumable-per-relation-type` +- **type**: api +- **preconditions**: a prior execute call completed `gebruik` and `contract` then failed before `contactpersoon` +- **steps**: re-invoke `POST /api/organisaties/{A}/merge` with the same target +- **expected result**: gebruik/contract objects are not re-pointed a second time; contactpersoon/aanbod/compliancy complete; final audit summary counts each type exactly once +- **test command**: `/test-api` + +### TC-7: Re-running a fully completed merge is a safe no-op +- **spec_ref**: `openspec/changes/organisation-merge/specs/organisation-merge/spec.md#requirement-execute-must-be-idempotent-and-resumable-per-relation-type` +- **type**: api +- **preconditions**: execute (A → B) previously completed and tombstoned A +- **steps**: re-invoke `POST /api/organisaties/{A}/merge` with the same target +- **expected result**: no relation object is modified; response reports the merge as already completed, not an error +- **test command**: `/test-api` + +### TC-8: Source organisation is tombstoned only after every relation type completes +- **spec_ref**: `openspec/changes/organisation-merge/specs/organisation-merge/spec.md#requirement-the-source-organisation-must-be-tombstoned-never-hard-deleted` +- **type**: api +- **preconditions**: execute (A → B) is mid-run with gebruik/contract done, contactpersoon/aanbod/compliancy pending +- **steps**: read organisation A at that point; then let execute finish and re-read A +- **expected result**: mid-run, A's `status` is not yet `samengevoegd`; after completion, `status = 'samengevoegd'`, `mergedInto = B`, A still exists (not deleted), all other fields unchanged +- **test command**: `/test-api` + +### TC-9: Tombstoned organisation is excluded from the default listing but resolvable by direct lookup +- **spec_ref**: `openspec/changes/organisation-merge/specs/organisation-merge/spec.md#requirement-the-source-organisation-must-be-tombstoned-never-hard-deleted` +- **type**: functional +- **preconditions**: organisation A has `status = 'samengevoegd'` +- **steps**: open the Organisaties index; then navigate directly to A's detail URL +- **expected result**: A does not appear in the default index listing; A's detail page still resolves (deep link works, e.g. for a `mergedInto` redirect) +- **test command**: `/test-functional` + +### TC-10: mapStatus treats the tombstone status as inactive +- **spec_ref**: `openspec/changes/organisation-merge/specs/organisatie-service/spec.md#requirement-the-system-shall-update-the-active-flag-of-an-openregister-organisation-from-a-softwarecatalog-status-req-002` +- **type**: api +- **preconditions**: an OR organisation entity exists for A +- **steps**: call `updateOrganizationStatus(A, {beoordeling: 'samengevoegd'})` (invoked by the merge tombstone step) +- **expected result**: the OR entity's `active` flag becomes `false`; `mapStatus('samengevoegd')` returns `false` +- **test command**: `/test-api` + +### TC-11: NC group membership is migrated from source to target +- **spec_ref**: `openspec/changes/organisation-merge/specs/organisation-merge/spec.md#requirement-nc-group-membership-must-be-migrated-from-source-to-target` +- **type**: api +- **preconditions**: A's NC group has members `[alice, bob]`; B's NC group has member `[carol]` +- **steps**: execute merge (A → B) +- **expected result**: B's NC group contains `[alice, bob, carol]`; no error for pre-existing membership overlap +- **test command**: `/test-api` + +### TC-12: Non-admin user is rejected on both endpoints +- **spec_ref**: `openspec/changes/organisation-merge/specs/organisation-merge/spec.md#requirement-both-merge-endpoints-must-be-admin-only-with-an-explicit-per-object-authorization-guard` +- **type**: security +- **preconditions**: an authenticated user who is not a member of the `admin` group +- **steps**: call `POST /api/organisaties/{A}/merge/dry-run` and `POST /api/organisaties/{A}/merge` +- **expected result**: both return 403; no relation object modified; no audit entry written +- **test command**: `/test-security` + +### TC-13: Admin user is authorized +- **spec_ref**: `openspec/changes/organisation-merge/specs/organisation-merge/spec.md#requirement-both-merge-endpoints-must-be-admin-only-with-an-explicit-per-object-authorization-guard` +- **type**: api +- **preconditions**: an authenticated user in the `admin` group +- **steps**: call `POST /api/organisaties/{A}/merge/dry-run` with a valid target +- **expected result**: 200 with per-type counts +- **test command**: `/test-api` + +### TC-14: Self-merge, already-tombstoned-source, and already-tombstoned-target are rejected +- **spec_ref**: `openspec/changes/organisation-merge/specs/organisation-merge/spec.md#requirement-merge-requests-must-be-validated-and-rejected-with-blockers-before-any-write` +- **type**: api +- **preconditions**: (a) `sourceUuid == targetUuid`; (b) target B already has `status = 'samengevoegd'`; (c) source A already has `status = 'samengevoegd'` with a different target C requested +- **steps**: call execute for each precondition +- **expected result**: each returns an error (400/409); no object is modified; a previously-tombstoned A's `mergedInto` is unchanged by (c) +- **test command**: `/test-api` + +### TC-15: Long-running merge is observable via the existing progress endpoint +- **spec_ref**: `openspec/changes/organisation-merge/specs/organisation-merge/spec.md#requirement-execute-must-report-progress-via-the-existing-sse-progress-tracking-mechanism` +- **type**: api +- **preconditions**: execute (A → B) is in flight, 2 of 5 relation types complete +- **steps**: `getProgress(operationId)` +- **expected result**: snapshot's `processed_items`/`statistics` reflect the 2 completed types; `phase` is not `completed` +- **test command**: `/test-api` + +### TC-16: Every dry-run and execute call is audit-logged +- **spec_ref**: `openspec/changes/organisation-merge/specs/organisation-merge/spec.md#requirement-every-dry-run-and-execute-call-must-produce-an-audit-log-entry` +- **type**: security +- **preconditions**: admin `admin1` runs dry-run then execute for (A, B) +- **steps**: query the audit log for this operation +- **expected result**: entries exist for the dry-run call and for execute's summary (actor `admin1`, source/target UUIDs, per-type counts) +- **test command**: `/test-security` + +### TC-17: Admin confirm-dialog flow end-to-end +- **spec_ref**: `openspec/changes/organisation-merge/specs/organisation-merge/spec.md#requirement-the-system-shall-preview-a-merge-with-per-relation-type-counts-before-any-write` +- **type**: functional +- **persona**: Noor (Municipal CISO / Functional Admin) — the persona who would actually run a herindeling merge +- **preconditions**: logged in as an admin on an organisation detail page with a mergeable target organisation available +- **steps**: open the merge confirm dialog, select a target, review the dry-run preview counts, confirm execute, observe progress +- **expected result**: dry-run counts render before any write; execute runs with visible progress; on completion the source shows as merged/tombstoned and is gone from the default listing +- **test command**: `/test-persona-noor` + +### TC-18: Merge dialog and messages are fully localized +- **spec_ref**: `openspec/changes/organisation-merge/specs/organisation-merge/spec.md#non-functional-requirements` +- **type**: functional +- **preconditions**: Nextcloud locale set to `nl_NL`, then `en_US` +- **steps**: open the merge dialog, trigger a blocker (e.g. self-merge attempt) to see the error message, in each locale +- **expected result**: no raw translation keys or English fallback text appears under `nl_NL` +- **test command**: `/test-functional` + +### TC-19: Merge confirm dialog and progress indicator meet WCAG 2.2 AA +- **spec_ref**: `openspec/changes/organisation-merge/specs/organisation-merge/spec.md#non-functional-requirements` +- **type**: accessibility +- **preconditions**: merge dialog implemented +- **steps**: open the dialog via keyboard only; run the accessibility checker against the dialog and progress indicator +- **expected result**: dialog is keyboard-operable with correct focus management and screen-reader announcement; progress state is exposed as text, not color alone +- **test command**: `/test-accessibility` + +### TC-20: Pre-existing organisation/contract/gebruik flows are unaffected by the new schema fields +- **spec_ref**: `openspec/changes/organisation-merge/specs/organisation-merge/spec.md#requirement-the-source-organisation-must-be-tombstoned-never-hard-deleted` +- **type**: regression +- **preconditions**: existing organisations, contracts, and gebruik records created before this change (no `mergedInto`, `status` never `samengevoegd`) +- **steps**: exercise existing organisatie-service, organization-sync, and contract-administration flows (create, sync, status transitions) against those records +- **expected result**: behaviour is unchanged; the new optional `mergedInto` field and `samengevoegd` status value do not alter existing flows +- **test command**: `/test-regression` + +## Coverage Summary +- Dry-run preview + zero-relations edge case — covered (TC-1, TC-2) +- Dry-run/execute parity — covered (TC-3) +- PUT-semantics field preservation (scalar + array relation fields) — covered (TC-4, TC-5) +- Idempotency / resumability (partial failure, full re-run) — covered (TC-6, TC-7) +- Tombstone behaviour (timing, exclusion from listing, direct resolvability, OR `active` flag) — covered (TC-8, TC-9, TC-10) +- NC group membership migration — covered (TC-11) +- Admin-only authorization (reject + accept) — covered (TC-12, TC-13) +- Validation/blockers (self-merge, tombstoned source/target) — covered (TC-14) +- Progress tracking — covered (TC-15) +- Audit logging — covered (TC-16) +- End-to-end admin UX (persona) — covered (TC-17) +- i18n — covered (TC-18) +- Accessibility — covered (TC-19) +- Regression on pre-existing flows — covered (TC-20) + +## Out of Scope +- Undo/rollback of a completed merge — no test cases, since the capability itself is explicitly out of scope (proposal.md). +- Bulk multi-organisation merges — not tested, out of scope. +- Multi-step wizard UX — only the simple confirm-dialog flow (TC-17) is tested; no wizard exists to test. +- Performance/load testing at scale (hundreds of relation types across thousands of objects) is deferred; TC coverage validates correctness, not throughput, beyond the qualitative non-functional target in design.md. diff --git a/openspec/changes/portfolio-rationalization-time/.openspec.yaml b/openspec/changes/portfolio-rationalization-time/.openspec.yaml new file mode 100644 index 00000000..cf371d3a --- /dev/null +++ b/openspec/changes/portfolio-rationalization-time/.openspec.yaml @@ -0,0 +1,2 @@ +schema: conduction +created: 2026-07-23 diff --git a/openspec/changes/portfolio-rationalization-time/context-brief.md b/openspec/changes/portfolio-rationalization-time/context-brief.md new file mode 100644 index 00000000..de8a5675 --- /dev/null +++ b/openspec/changes/portfolio-rationalization-time/context-brief.md @@ -0,0 +1,29 @@ +# Context Brief: portfolio-rationalization-time + +## What +Gartner **TIME classification** (Tolerate / Invest / Migrate / Eliminate) per application-in-use (gebruik), with rationale and review date, plus a **portfolio rationalization report** per organisation: TIME quadrant counts, EOL exposure (from lifecycle data), cloud-transition share, and cost overlay (from contract cost derivation). + +## Why (evidence) +- VNG Softwarecatalogus issue #54 (portfolio statistics incl. EOL and cloud-transition). +- Competitor gap: SAP LeanIX sells exactly this (TIME model) at per-app pricing out of municipal reach; no OSS competitor has it. +- Academic grounding: "A method for application portfolio rationalization" applies APR to small municipalities (logged in Specter external_sources). +- 109 reporting requirements in mapped tenders. +- Specter canonical feature: `portfolio-rationalization-time` (should, demand 9). + +## Current state (read these specs first) +- `openspec/specs/application-lifecycle-tracking` — lifecycle phase derived from gebruik dates; EOL indicators + approaching-EOL filter. TIME builds on this. +- `openspec/specs/gebruik-services` — gebruik APIs; the TIME fields belong on the gebruik object (an application's classification is org-specific). +- `openspec/specs/contract-administration` — annualised cost derivation to reuse for cost overlay. +- `openspec/specs/dashboard-views-api` — dashboard/statistics endpoints; the report is a new page + data endpoint following that pattern. +- Charts: apexcharts is an approved shared dep via @conduction/nextcloud-vue. + +## Scope +IN: gebruik schema fields (timeClassification enum, timeRationale, timeReviewDate, deploymentModel enum e.g. on-premise/saas/hybrid if not already present), edit UI on the gebruik detail/modal, portfolio report page per organisation (quadrant chart + tables + EOL/cloud/cost aggregates, bounded queries), CSV export of the report, i18n, tests, docs. +OUT: automated classification suggestions (AI advisering is deferred — VNG #53), cross-org benchmarking, budgeting/forecasting. + +## Design constraints +- ADR-001 OR storage; schema changes in lib/Settings/softwarecatalogus_register.json (diff against merge base; union merges drop modifications). +- OR saveObject is PUT-semantic — editing TIME fields must carry all other gebruik fields forward. +- Aggregation endpoints bounded (see bound-unbounded-searchobjects-scans pending change) and RBAC-scoped per organisation (respect vendor-visibility work in flight). +- ADR-012 Cn components (CnDashboardPage patterns); ADR-003 tokens; ADR-005 i18n; ADR-009 tests; ADR-010 docs. +- OpenSpec delta headers MUST be `### Requirement: `. diff --git a/openspec/changes/portfolio-rationalization-time/design.md b/openspec/changes/portfolio-rationalization-time/design.md new file mode 100644 index 00000000..10c50da6 --- /dev/null +++ b/openspec/changes/portfolio-rationalization-time/design.md @@ -0,0 +1,162 @@ +# Design: portfolio-rationalization-time + +## Architecture Overview +Two additive pieces on top of existing capabilities, no new storage layer: + +1. **TIME classification on `gebruik`** — three new optional properties on + the existing `gebruik` schema in `lib/Settings/softwarecatalogus_register.json`, + edited through the existing gebruik detail/edit surface, persisted through + OpenRegister exactly like every other gebruik field (ADR-001, ADR-022 — no + app-local CRUD). +2. **Portfolio rationalization report** — a new manifest page + a bounded, + RBAC-scoped read endpoint that composes three existing derivations + (TIME counts from `gebruik`, lifecycle/EOL exposure from + `application-lifecycle-tracking`'s phase/EOL logic, annualised cost from + `contract-administration`'s cost derivation) into one aggregate response, + plus a CSV-format variant of the same data. + +``` +Gebruik detail/edit (Vue) Portfolio report page (Vue, new) + │ PUT (full object) │ GET aggregate JSON / GET ?format=csv + ▼ ▼ +OpenRegister ObjectService ◄───── PortfolioReportService (new, PHP) + │ │ reads (bounded, org-scoped) + ▼ ▼ + gebruik / moduleVersie / contract objects in the softwarecatalogus register +``` + +The report endpoint does not introduce a new data model — it is a read-side +aggregation over existing objects, following the same "thin client, OR is +the only store" pattern as `dashboard-views-api`. + +## Goals / Non-Goals +**Goals:** +- Persist TIME classification, rationale, and review date per gebruik. +- Surface a single organisation-scoped report combining TIME + EOL + cloud + + cost, bounded and RBAC-scoped. +- CSV export of the same underlying rows. + +**Non-Goals:** +- No AI-assisted classification (VNG #53, explicitly deferred). +- No new RBAC/visibility-matrix mechanism — this change consumes whatever + organisation-scoping is current and MUST track (not duplicate or race) + the in-flight `vendor-visibility-rbac` change. +- No cross-organisation benchmarking or budgeting/forecasting. +- No new database tables or Nextcloud DB migration — ADR-001 forbids + app-owned tables; schema changes go through the existing register-config + + repair-step import path, not `lib/Migration/`. + +## Decisions + +### Decision 1: Add three fields to `gebruik`; do not add a `deploymentModel` field +The context brief proposed a `deploymentModel` enum "if not already present". +Inspection of `lib/Settings/softwarecatalogus_register.json` shows `gebruik` +already has `cloudDienstverleningsmodel` (title "Hosting", enum +`On-premises (self-managed)` / `IaaS` / `PaaS` / `SaaS`, `facetable: true`, +already a table-default column). This is exactly the deployment-model +signal the cloud-transition metric needs. +**Decision:** reuse `cloudDienstverleningsmodel` for the cloud-transition +aggregate; add only `timeClassification`, `timeRationale`, and +`timeReviewDate`. Adding a second, competing deployment-model field would +fork the data and confuse existing Hosting-column consumers. +**Alternatives considered:** adding a new `deploymentModel` field as the +brief literally suggested — rejected because it duplicates data already +captured and passes the "if not already present" escape hatch in the brief. + +### Decision 2: `timeClassification` follows the existing enum-on-string convention +Match the `status` field's shape (`type: string`, `enum: [...]`, `title`, +`example`) rather than inventing a new pattern. Values: `Tolerate`, +`Invest`, `Migrate`, `Eliminate` (English canonical values per the Gartner +TIME model; the UI/i18n layer localises labels the same way `status`'s Dutch +enum values are already displayed untranslated-but-labelled — follow +whatever the current `status` field UI convention is at implementation +time, confirmed during apply). +**Alternatives considered:** a numeric quadrant code — rejected, less +self-describing in raw OR object payloads and harder to facet/filter on. + +### Decision 3: Report endpoint composes existing derivations, does not duplicate them +`PortfolioReportService` reuses: +- `application-lifecycle-tracking`'s phase/EOL derivation logic (same + "derive at query time, never persist" principle — TIME counts and EOL + exposure are computed from live `gebruik`/`moduleVersie` data, not cached). +- `contract-administration`'s annualised-cost derivation (`kosten` × + `kostenPeriode`), applied to the set of contracts linked to the + organisation's gebruiken. +No shared derivation is forked into a second implementation; if the existing +JS derivation utilities (`lifecyclePhase.js`, `contractCost.js`) are +frontend-only, the report endpoint reimplements the same rules server-side +for the aggregate (since aggregation over potentially many gebruiken is a +backend job, not a per-row frontend render) — the requirement scenarios +below assert both stay behaviourally identical (same phase/EOL/cost rules, +same inputs → same outputs) rather than assert code-sharing, since the +frontend/backend split makes literal code-sharing impractical. +**Alternatives considered:** running the whole aggregation client-side over +paginated raw gebruik fetches — rejected: would require fetching full +gebruik+contract data for every application in an organisation into the +browser, defeating the bounded-query goal and RBAC narrowing that's easier +to enforce once, server-side. + +### Decision 4: Aggregation queries are bounded and organisation-scoped +Following `bound-unbounded-searchobjects-scans`: every query the report +issues MUST set an explicit `_limit` (or use `searchObjectsPaginated`) — +never an unbounded `searchObjects()` call. The report is scoped to one +organisation per request (selected in the UI, matching the existing +`application-lifecycle-tracking` roadmap's per-organisation pattern) so the +result set for any single call is bounded by that organisation's gebruik +count, with an explicit page-size ceiling on top as a second bound. +RBAC: the endpoint MUST scope results to organisations the requesting user +is authorised to see, using the current tenant-context / organisation-header +mechanism (`softwarecatalog-adopt-or-abstractions`' +`X-OpenRegister-Organisation`, `useTenantContext()`) and MUST deny (not +silently empty-return) a request for an organisation the user cannot see — +consistent with the fail-closed principle stated in the in-flight +`vendor-visibility-rbac` context brief. This change does not implement that +matrix; it plugs into whatever authorisation gate exists on gebruik reads at +implementation time and MUST NOT be merged as a change that bypasses it. + +### Decision 5: CSV export is a format variant of the same endpoint, not a separate data path +`GET .../portfolio-report?organisation={uuid}&format=csv` returns the same +bounded, RBAC-scoped row set serialised as CSV instead of a second +unbounded export mechanism. This avoids a common trap where "export +everything" endpoints skip the bounds and RBAC scoping applied to the +paginated/report view. +**Alternatives considered:** a dedicated unbounded export endpoint — +rejected on both the bounded-query and RBAC-leak risks called out in the +proposal. + +## Risks / Trade-offs +- [Report aggregation cost grows with organisation gebruik count] → Mitigate + with an explicit page-size ceiling and by reusing existing indexed/facetable + fields (`cloudDienstverleningsmodel` is already `facetable: true`); if an + organisation's gebruik count exceeds the ceiling, the report SHALL show a + "showing first N of M" indicator rather than silently truncating without + disclosure. +- [Duplicating lifecycle/cost logic server-side vs. frontend risks drift] → + Mitigate with shared test fixtures asserting identical phase/EOL/cost + outputs for the same input dates across the existing frontend utility + tests and the new backend aggregation tests. +- [RBAC scoping mechanism is still in flux (`vendor-visibility-rbac` in + flight)] → Mitigate by not inventing a parallel mechanism; wire to + whatever authorisation check gates other gebruik/contract reads today, + and flag in tasks.md that the report's RBAC test MUST be re-run once + `vendor-visibility-rbac` lands, in case the enforcement point moves. + +## Migration Plan +No Nextcloud DB migration applies (ADR-001 — no app-owned tables). The +schema change is a targeted diff to +`lib/Settings/softwarecatalogus_register.json` adding the three +`gebruik` properties (never a full-file regeneration, to avoid dropping +concurrent register-config modifications), picked up by the existing +`ConfigurationService::importFromApp()` repair step on next app +upgrade/repair run. Rollback: revert the register-config diff (the three +properties are optional/additive, so existing gebruik objects are +unaffected either way) and remove the report page/controller/route. + +## Open Questions +- Should `timeReviewDate` drive a scheduled notification (mirroring + `eol-approaching` / `phaseout-approaching`)? Not in this change's scope — + see proposal.md Open Questions / DEFERRED_QUESTIONS. +- Exact enforcement point for organisation-scoping (which service/middleware + gates gebruik/contract reads today) needs confirmation against the + softwarecatalog `lib/` codebase at apply time, since `vendor-visibility-rbac` + is still only a context-brief and may change the enforcement point. diff --git a/openspec/changes/portfolio-rationalization-time/proposal.md b/openspec/changes/portfolio-rationalization-time/proposal.md new file mode 100644 index 00000000..722aa785 --- /dev/null +++ b/openspec/changes/portfolio-rationalization-time/proposal.md @@ -0,0 +1,138 @@ +# Proposal: portfolio-rationalization-time + +## Summary +Adds Gartner TIME classification (Tolerate / Invest / Migrate / Eliminate) to +each application-in-use (`gebruik`) — a rationale, a review date, and an +organisation-scoped **portfolio rationalization report** that combines TIME +quadrant counts with existing EOL exposure (`application-lifecycle-tracking`), +cloud-transition share (the existing `cloudDienstverleningsmodel` field), and +annualised cost overlay (`contract-administration`). The report ships a +quadrant chart, supporting tables, and a CSV export, following the +`dashboard-views-api` page + data-endpoint pattern. + +## Motivation +VNG Softwarecatalogus issue #54 asks for portfolio statistics including EOL +and cloud-transition exposure. No OSS competitor in this category offers a +TIME-style rationalization view; SAP LeanIX sells the same model at +per-application pricing that is out of reach for municipalities. The +underlying evidence base (109 reporting requirements across mapped tenders, +Specter canonical feature `portfolio-rationalization-time` at `should`/demand +9, and academic grounding for applying APR to small municipalities) shows +sustained demand for exactly this "which applications do we tolerate, invest +in, migrate, or eliminate" decision-support view. The lifecycle, gebruik, and +contract building blocks already exist — this change composes them into a +management-facing report rather than introducing new data-collection +machinery. + +## Affected Projects +- [x] Project: `softwarecatalog` — `gebruik` schema gains TIME classification + fields; new portfolio rationalization report page + bounded, RBAC-scoped + data endpoint; CSV export; edit UI on the gebruik detail/modal. + +## Scope + +### In Scope +- `gebruik` schema fields: `timeClassification` (enum: Tolerate / Invest / + Migrate / Eliminate), `timeRationale` (free text), `timeReviewDate` (date). +- Confirming `cloudDienstverleningsmodel` (existing Hosting field: On-premises + (self-managed) / IaaS / PaaS / SaaS) as the deployment-model source for the + cloud-transition share metric — no new field, since it already exists on + `gebruik`. +- Edit UI for the three new TIME fields on the existing gebruik detail/modal, + carrying all other gebruik fields forward on save (OR `saveObject` is + PUT-semantic). +- A new portfolio rationalization report: a per-organisation page with a TIME + quadrant chart (apexcharts, via `@conduction/nextcloud-vue`), supporting + tables, and aggregate figures — TIME quadrant counts, EOL exposure (reusing + `application-lifecycle-tracking` derivation), cloud-transition share (from + `cloudDienstverleningsmodel`), and annualised cost overlay (reusing + `contract-administration` cost derivation), each figure bounded and scoped + to the requesting user's visible organisation(s). +- CSV export of the report's underlying rows. +- i18n (NL/EN), tests (>=75% coverage on new code), docs with Playwright + screenshots. + +### Out of Scope +- Automated / AI-assisted TIME classification suggestions — deferred to VNG + issue #53. +- Cross-organisation benchmarking of TIME distributions. +- Budgeting or cost forecasting beyond the existing annualised-cost figure. +- Building a new RBAC visibility matrix — this change consumes whatever + organisation-scoping mechanism is current at implementation time and MUST + NOT regress or conflict with the in-flight `vendor-visibility-rbac` change; + it does not itself define role × object-type visibility rules. + +## Approach +Extend the `gebruik` schema (register-config diff, not a full-file replace) +with the three TIME fields, matching the existing `status` field's +enum-on-string convention. Add the fields to the gebruik detail/edit +component, reading the full object before PUT so untouched fields are carried +forward unchanged. Add a `PortfolioReportController` (or extend +`DashboardController`) that resolves the active register/schema via the +existing resolver pattern, runs bounded `searchObjectsPaginated`/aggregate +queries scoped to the caller's organisation, and returns a report payload +plus a CSV-format variant. Add a manifest report page reusing +`CnDashboardPage` composition patterns with an apexcharts quadrant chart. +Design specifics (endpoint shape, aggregation queries, RBAC integration +point) belong in `design.md`. + +## New Dependencies +None — apexcharts is already an approved shared dependency exposed via +`@conduction/nextcloud-vue`; no new package is introduced. + +## Impact +- `lib/Settings/softwarecatalogus_register.json` — `gebruik` schema gains + three properties. +- Gebruik detail/edit Vue component(s) — new fields on the form. +- A new backend controller/service for the report data endpoint and CSV + export, plus a new manifest report page and its Vue component(s). +- Existing `application-lifecycle-tracking` phase/EOL derivation and + `contract-administration` cost derivation are read, not modified. + +## Cross-Project Dependencies +None — self-contained within `softwarecatalog`. It reuses (without +modifying) OpenRegister's `ObjectService::searchObjectsPaginated` and the +existing tenant-organisation header contract from +`softwarecatalog-adopt-or-abstractions`. + +## Risks + +### Risk 1: Report aggregation query becomes an unbounded full-register scan +**Severity:** High — **Mitigation:** Follow the pattern established by the +`bound-unbounded-searchobjects-scans` change: every aggregate/report query +MUST set an explicit `_limit` or use `searchObjectsPaginated`, never an +unbounded `searchObjects()` call. Cap report rows and document the ceiling. + +### Risk 2: Report or CSV export leaks another organisation's portfolio data +**Severity:** High — **Mitigation:** Scope every aggregate query and the CSV +export to the requesting user's organisation using the same tenant/RBAC +mechanism as other gebruik reads; deny-by-default; add a negative test +proving cross-organisation access is rejected. Track alignment with the +in-flight `vendor-visibility-rbac` change rather than inventing a parallel +mechanism. + +### Risk 3: TIME field edit overwrites unrelated gebruik fields +**Severity:** Medium — **Mitigation:** OR `saveObject` is PUT-semantic; the +edit flow MUST read the full current gebruik object and carry all existing +fields forward, adding only the changed TIME fields. Add a test asserting an +unrelated field (e.g. `startDatumInProductie`) survives a TIME-only edit. + +### Risk 4: Register schema edit collides with concurrent register-config changes +**Severity:** Low — **Mitigation:** Diff `softwarecatalogus_register.json` +against the merge base and apply as a targeted patch (add three properties), +not a wholesale regeneration, per the project's union-merge-drops-changes +lesson. + +## Rollback Strategy +The three new `gebruik` fields are additive and optional; existing gebruik +objects remain valid without them (same pattern as the lifecycle change's +`geplandeVervanging` addition). Reverting means: drop the report page from +the manifest, remove the report controller/route, and remove the three +schema properties (or leave them unused/hidden) — no destructive data +migration is required since no existing field is altered. + +## Open Questions +- Should `timeReviewDate` drive a scheduled notification (mirroring the + `phaseout-approaching` / `eol-approaching` rules), or is that deferred with + the AI-advisering follow-up (VNG #53)? Deferred to DEFERRED_QUESTIONS — + proceeding without a notification rule in this change; see design.md. diff --git a/openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md b/openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md new file mode 100644 index 00000000..dea0068b --- /dev/null +++ b/openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md @@ -0,0 +1,163 @@ +## ADDED Requirements + +### Requirement: TIME classification fields are recorded on the gebruik schema + +The `gebruik` schema SHALL gain three optional fields: `timeClassification` +(enum: `Tolerate`, `Invest`, `Migrate`, `Eliminate`), `timeRationale` (free +text), and `timeReviewDate` (date). TIME classification SHALL be recorded +per gebruik (per organisation's usage of an application), never on the +module or application itself, mirroring how `geplandeVervanging` is scoped +per gebruik rather than per module. Existing gebruik objects SHALL remain +valid without the new fields, and SHALL be treated as unclassified +(no TIME quadrant) until a value is set. + +#### Scenario: User classifies a gebruik as Migrate with a rationale + +- **WHEN** a user edits a gebruik and sets `timeClassification` to + `Migrate`, a `timeRationale`, and a `timeReviewDate` +- **THEN** the gebruik stores all three fields +- **AND** the gebruik appears in the `Migrate` quadrant of the portfolio + report for its organisation + +#### Scenario: Existing objects are unaffected by the schema addition + +- **WHEN** the updated register definition is imported over existing + gebruik data +- **THEN** existing gebruik objects without the new fields load and save + unchanged +- **AND** they are excluded from every TIME quadrant count until classified + +#### Scenario: Clearing the classification returns the gebruik to unclassified + +- **WHEN** a user clears a previously set `timeClassification` on a gebruik +- **THEN** the gebruik has no `timeClassification` value +- **AND** it no longer counts toward any TIME quadrant in the report + +### Requirement: Editing TIME fields preserves every other gebruik field + +The gebruik edit flow SHALL read the complete current gebruik object before +submitting a save and SHALL carry every existing field forward unchanged +alongside the edited TIME fields, because OpenRegister's `saveObject` is +PUT-semantic. Editing only `timeClassification`, `timeRationale`, or +`timeReviewDate` SHALL NOT null out, omit, or otherwise alter any other +gebruik field (including `status`, phase-start dates, relations such as +`module`, `deelnemers`, `koppelingen`, or `cloudDienstverleningsmodel`). + +#### Scenario: A TIME-only edit leaves unrelated fields intact + +- **GIVEN** a gebruik with `status: "In productie"`, + `startDatumInProductie` set, and `cloudDienstverleningsmodel: ["SaaS"]` +- **WHEN** a user edits only the TIME fields and saves +- **THEN** the saved gebruik still has `status: "In productie"`, + the same `startDatumInProductie`, and `cloudDienstverleningsmodel: ["SaaS"]` + unchanged + +### Requirement: Portfolio rationalization report aggregates per organisation + +The app SHALL provide a portfolio rationalization report for a selected +organisation that shows: TIME quadrant counts (Tolerate / Invest / Migrate / +Eliminate, plus an Unclassified count) across the organisation's gebruiken; +EOL exposure reusing the `application-lifecycle-tracking` end-of-support +derivation (count and list of gebruiken whose linked `moduleVersie` has +passed or approaching end-of-support); cloud-transition share derived from +the existing `cloudDienstverleningsmodel` field (share of gebruiken per +hosting model — no new deployment-model field is introduced); and an +annualised cost overlay per TIME quadrant, reusing the +`contract-administration` annualised-cost derivation over each gebruik's +linked contracts. Figures SHALL be computed at query time, never persisted. + +#### Scenario: Report shows quadrant counts with EOL and cost overlay + +- **GIVEN** an organisation with gebruiken classified across all four TIME + quadrants, some with end-of-support versions, and some with active + contracts +- **WHEN** a user opens the portfolio rationalization report for that + organisation +- **THEN** the report shows a count per TIME quadrant (including + Unclassified) +- **AND** each quadrant shows its EOL-exposed gebruik count +- **AND** each quadrant shows its cloud-transition share by hosting model +- **AND** each quadrant shows its summed annualised contract cost + +#### Scenario: Unclassified gebruiken are visible, not hidden + +- **GIVEN** an organisation with gebruiken that have no `timeClassification` + set +- **WHEN** the report is opened +- **THEN** those gebruiken appear in an `Unclassified` group rather than + being omitted from the report + +### Requirement: Report aggregation queries are bounded + +Every query the portfolio report endpoint issues against OpenRegister SHALL +set an explicit `_limit` or use `searchObjectsPaginated` — the report SHALL +NOT issue an unbounded `searchObjects()` call, per the +`bound-unbounded-searchobjects-scans` bounded-query requirement. The report +SHALL apply an explicit page-size ceiling per organisation in addition to +the natural bound of "one organisation's gebruiken", and SHALL disclose when +the result set is truncated at that ceiling rather than silently dropping +rows. + +#### Scenario: Report query sets an explicit limit + +- **WHEN** the portfolio report endpoint builds its query for an + organisation's gebruiken +- **THEN** the query array MUST include an explicit `_limit` value +- **AND** the value MUST NOT be silently omitted or left to default + +#### Scenario: Truncation is disclosed, not silent + +- **GIVEN** an organisation's gebruik count exceeds the report's page-size + ceiling +- **WHEN** the report is generated +- **THEN** the report indicates it is showing a bounded subset (e.g. "first + N of M") +- **AND** does not present the truncated figures as a complete total + without that disclosure + +### Requirement: Report and CSV export are scoped to the requester's authorised organisation(s) + +The portfolio report endpoint (and its CSV export variant) SHALL scope every +result to organisations the requesting user is authorised to see, using the +current tenant/organisation-scoping mechanism that gates other gebruik and +contract reads. A request naming an organisation the requesting user is not +authorised to see SHALL be denied (fail closed), never silently returned +empty or narrowed after an initial broader fetch. + +#### Scenario: Report request for an unauthorised organisation is denied + +- **GIVEN** a user is not authorised to see organisation B's gebruiken +- **WHEN** that user requests the portfolio report for organisation B +- **THEN** the request is denied +- **AND** no organisation B gebruik, contract, or cost data is included in + the response + +#### Scenario: Report request for an authorised organisation returns only that organisation's data + +- **GIVEN** a user is authorised to see organisation A +- **WHEN** that user requests the portfolio report for organisation A +- **THEN** the response contains only gebruiken, EOL exposure, and cost + figures belonging to organisation A + +### Requirement: CSV export of the portfolio report + +The portfolio report SHALL offer a CSV export of its underlying gebruik-level +rows (organisation, application/module, TIME classification, rationale, +review date, lifecycle phase, EOL status, hosting/deployment model, and +annualised cost), scoped and bounded identically to the on-screen report — +the export SHALL NOT be a separate unbounded or unscoped data path. + +#### Scenario: CSV export matches the on-screen report's scope + +- **GIVEN** a user views the portfolio report for an organisation +- **WHEN** the user exports it as CSV +- **THEN** the CSV contains one row per gebruik shown in the report, with + the same organisation scoping and page-size bound as the on-screen view +- **AND** each row includes TIME classification, rationale, review date, + lifecycle phase, EOL status, hosting/deployment model, and annualised cost + +#### Scenario: CSV export is denied for an unauthorised organisation + +- **GIVEN** a user is not authorised to see organisation B's gebruiken +- **WHEN** that user requests the CSV export for organisation B +- **THEN** the request is denied and no CSV is returned diff --git a/openspec/changes/portfolio-rationalization-time/tasks.md b/openspec/changes/portfolio-rationalization-time/tasks.md new file mode 100644 index 00000000..e53dc380 --- /dev/null +++ b/openspec/changes/portfolio-rationalization-time/tasks.md @@ -0,0 +1,98 @@ +# Tasks: portfolio-rationalization-time + +## Implementation Tasks + +### Task 1: Add TIME classification fields to the gebruik schema +- **spec_ref**: `openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-time-classification-fields-are-recorded-on-the-gebruik-schema` +- **files**: `lib/Settings/softwarecatalogus_register.json` +- **acceptance_criteria**: + - GIVEN the current `gebruik` schema WHEN diffed against the merge base THEN it gains exactly three new optional properties (`timeClassification` enum `Tolerate`/`Invest`/`Migrate`/`Eliminate`, `timeRationale` string, `timeReviewDate` date), matching the `status` field's enum-on-string shape + - GIVEN existing gebruik objects WHEN the updated register is imported via `ConfigurationService::importFromApp()` THEN they load and save unchanged with no `timeClassification` value +- [ ] Implement +- [ ] Test + +### Task 2: Add TIME fields to the gebruik edit surface with PUT-semantic carry-forward +- **spec_ref**: `openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-editing-time-fields-preserves-every-other-gebruik-field` +- **files**: `src/modals/object/ObjectModal.vue`, `src/views/organisaties/OrganisatieIndex.vue` +- **acceptance_criteria**: + - GIVEN a gebruik with `status`, phase dates, and `cloudDienstverleningsmodel` already set WHEN a user edits only the TIME fields and saves THEN the PUT request body includes all pre-existing field values unchanged alongside the edited TIME fields + - GIVEN a user clears a previously set `timeClassification` WHEN they save THEN the gebruik has no `timeClassification` value and no longer counts toward any TIME quadrant +- [ ] Implement +- [ ] Test + +### Task 3: Add PortfolioReportController and route +- **spec_ref**: `openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-portfolio-rationalization-report-aggregates-per-organisation` +- **files**: `lib/Controller/PortfolioReportController.php`, `appinfo/routes.php` +- **acceptance_criteria**: + - GIVEN a valid organisation UUID WHEN `GET /api/portfolio-report?organisation={uuid}` is called by an authorised user THEN a 200 JSON response with TIME quadrant, EOL, cloud-transition, and cost figures is returned + - GIVEN the route is registered WHEN routes.php is inspected THEN the controller method carries the correct NC auth attribute (per hydra-gate-route-auth) matching its actual authorisation requirement +- [ ] Implement +- [ ] Test + +### Task 4: Implement PortfolioReportService aggregation (TIME + EOL + cloud + cost), bounded +- **spec_ref**: `openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-report-aggregation-queries-are-bounded` +- **files**: `lib/Service/PortfolioReportService.php` +- **acceptance_criteria**: + - GIVEN an organisation's gebruiken across all four TIME quadrants plus unclassified WHEN the report is built THEN quadrant counts, EOL exposure (reusing the lifecycle end-of-support rule), cloud-transition share (from `cloudDienstverleningsmodel`), and annualised cost per quadrant (reusing the contract cost derivation) are all present + - GIVEN the service builds any OpenRegister query WHEN inspected THEN every call includes an explicit `_limit` or uses `searchObjectsPaginated` — no unbounded `searchObjects()` call + - GIVEN an organisation's gebruik count exceeds the configured page-size ceiling WHEN the report is built THEN the response discloses truncation ("first N of M") rather than presenting a silently incomplete total +- [ ] Implement +- [ ] Test + +### Task 5: Enforce organisation-scoped authorisation on the report endpoint +- **spec_ref**: `openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-report-and-csv-export-are-scoped-to-the-requesters-authorised-organisations` +- **files**: `lib/Controller/PortfolioReportController.php`, `lib/Service/PortfolioReportService.php` +- **acceptance_criteria**: + - GIVEN a user not authorised for organisation B WHEN they request the report for organisation B THEN the request is denied before any organisation B data is queried (fail closed) + - GIVEN a user authorised for organisation A WHEN they request the report for organisation A THEN only organisation A data is returned + - Note: re-verify this task's enforcement point once `vendor-visibility-rbac` lands, per design.md Risks — the gating mechanism may move +- [ ] Implement +- [ ] Test + +### Task 6: Add CSV export format variant +- **spec_ref**: `openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-csv-export-of-the-portfolio-report` +- **files**: `lib/Controller/PortfolioReportController.php`, `lib/Service/PortfolioReportService.php` +- **acceptance_criteria**: + - GIVEN a user views the portfolio report WHEN they request `?format=csv` for the same organisation THEN the CSV contains one row per gebruik shown on screen with TIME classification, rationale, review date, lifecycle phase, EOL status, hosting model, and annualised cost, under the same scope and bound as the JSON report + - GIVEN a user not authorised for organisation B WHEN they request the CSV export for organisation B THEN the request is denied and no CSV is returned +- [ ] Implement +- [ ] Test + +### Task 7: Build the portfolio rationalization report page (quadrant chart + tables + CSV button) +- **spec_ref**: `openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-portfolio-rationalization-report-aggregates-per-organisation` +- **files**: `src/manifest.json`, `src/views/organisaties/PortfolioReport.vue`, `src/components/cards/` +- **acceptance_criteria**: + - GIVEN an organisation is selected WHEN the report page loads THEN it renders a TIME quadrant chart (apexcharts via `@conduction/nextcloud-vue`) plus supporting tables for EOL exposure, cloud-transition share, and cost overlay, using `CnDashboardPage` composition (ADR-012) and NL Design System tokens (ADR-003, no hardcoded colors) + - GIVEN unclassified gebruiken exist WHEN the report renders THEN they appear in a visible Unclassified group, not omitted + - GIVEN the user clicks "Export CSV" WHEN the download completes THEN the file matches the on-screen report's rows +- [ ] Implement +- [ ] Test + +### Task 8: Add Dutch and English translation strings +- **spec_ref**: `openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-time-classification-fields-are-recorded-on-the-gebruik-schema` +- **files**: `l10n/nl.json`, `l10n/en.json` +- **acceptance_criteria**: + - GIVEN the new TIME fields, quadrant labels (Tolerate/Invest/Migrate/Eliminate/Unclassified), report page, and CSV export button WHEN the UI renders in `nl_NL` or `en_US` THEN every new user-facing string is translated (no raw i18n keys visible) +- [ ] Implement +- [ ] Test + +### Task 9: Write feature docs with Playwright screenshots +- **spec_ref**: `openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-portfolio-rationalization-report-aggregates-per-organisation` +- **files**: `docs/features/portfolio-rationalization-time.md`, `docs/images/` +- **acceptance_criteria**: + - GIVEN the feature is implemented WHEN docs are captured via Playwright MCP THEN `docs/features/portfolio-rationalization-time.md` documents TIME classification editing and the report/export flow with committed screenshots in `docs/images/` +- [ ] Implement +- [ ] Test + +## Quality checklist + +- All new/changed business logic covered by PHPUnit unit tests (`tests/Unit/`) and Vue unit tests (vitest), minimum 75% coverage on new code (ADR-009) +- New/changed API endpoints (`portfolio-report`, CSV export) covered by Newman/Postman tests +- UI changes (TIME fields on gebruik edit, report page, CSV export button) covered by Playwright browser tests +- Negative RBAC tests prove a user cannot fetch or export another organisation's report (Risk 2 in proposal.md) +- A test asserts a TIME-only edit leaves unrelated gebruik fields (e.g. `startDatumInProductie`) unchanged (Risk 3 in proposal.md) +- Report/export queries verified to always set an explicit `_limit` or use `searchObjectsPaginated` (no unbounded `searchObjects()` call) +- All tests pass (`composer check:strict`, container PHPUnit run, `npm run test`, `newman run`) +- Feature documentation updated in `docs/features/` with Playwright screenshots (ADR-010) +- Dutch (`nl_NL`) and English (`en_US`) translation strings added for all new user-facing strings (ADR-005) +- `openspec validate --change portfolio-rationalization-time` passes diff --git a/openspec/changes/sbom-import/.openspec.yaml b/openspec/changes/sbom-import/.openspec.yaml new file mode 100644 index 00000000..cf371d3a --- /dev/null +++ b/openspec/changes/sbom-import/.openspec.yaml @@ -0,0 +1,2 @@ +schema: conduction +created: 2026-07-23 diff --git a/openspec/changes/sbom-import/context-brief.md b/openspec/changes/sbom-import/context-brief.md new file mode 100644 index 00000000..d89edd08 --- /dev/null +++ b/openspec/changes/sbom-import/context-brief.md @@ -0,0 +1,28 @@ +# Context Brief: sbom-import + +## What +Import an **SBOM file (CycloneDX JSON; SPDX JSON if cheap)** for a module version: parse components into OpenRegister objects linked to the moduleversie, show the component list with licenses, match components against the existing kwetsbaarheden (vulnerability) register by CVE/package where possible, and show summary counts (components, distinct licenses, matched vulnerabilities). + +## Why (evidence) +- The dependency-management research domain (Specter domain 160) has ZERO implementation in the app today. +- Reference architecture: OWASP Dependency-Track (20k+ orgs) — SBOM ingestion correlating against advisory data; CycloneDX is ECMA-424, SPDX is ISO 5962 (both logged in Specter external_sources). +- Sonatype 2026: 80% of dependencies stay un-upgraded >1yr — freshness visibility is the demand driver. +- NIS2/BIO supply-chain requirements make SBOM handling a near-term procurement checkbox (561 compliance reqs in mapped tenders). +- Specter canonical feature: `sbom-file` (should, demand 8, upgraded 2026-07-23). + +## Current state (read these specs first) +- `openspec/specs/module-vulnerability-tracking` — kwetsbaarheden register, CVSS bands, per-org exposure. SBOM matching feeds THIS, do not fork a parallel vuln model. +- `openspec/specs/progress-tracking` — SSE progress for long-running work; large SBOM parse should report progress. +- `openspec/specs/archimate-import` — existing pattern for file upload → parse → object creation → status/cancel; mirror its ergonomics. +- Schemas: lib/Settings/softwarecatalogus_register.json via repair step. + +## Scope +IN: upload endpoint (bounded file size, JSON only), CycloneDX 1.5/1.6 JSON parser service (SPDX JSON optional second format), sbomComponent schema (purl, name, version, licenses[], hashes optional, moduleversie relation), local vulnerability matching against existing kwetsbaarheden objects (by CVE id and/or purl/package name), components tab on the module-version detail page, summary counts, re-import replaces previous component set for that version (idempotent), i18n, tests with real small CycloneDX fixtures, docs. +OUT: calling external services (OSV.dev/NVD API queries — that belongs in openconnector later), SBOM generation/export, license-policy evaluation, transitive dependency graphs. + +## Design constraints +- ADR-001 OR storage only; no custom tables. Mind OR bulk-save performance for SBOMs with hundreds of components (bounded batches). +- OR DELETE is soft-delete — "replace previous component set" must handle trash rows (filter `_deleted`). +- Parser is a pure service (ADR-008), unit-testable without OR. +- ADR-012 Cn components; ADR-005 i18n; ADR-009 tests ≥75%; ADR-010 docs. +- OpenSpec delta headers MUST be `### Requirement: `. diff --git a/openspec/changes/sbom-import/design.md b/openspec/changes/sbom-import/design.md new file mode 100644 index 00000000..a60377fc --- /dev/null +++ b/openspec/changes/sbom-import/design.md @@ -0,0 +1,314 @@ +# Design: sbom-import + +## Architecture Overview + +``` +Upload (multipart, .json) + │ + ▼ +SbomController::importSbom() (auth + size/type guard, mirrors importArchiMate) + │ + ▼ +SbomImportService::importForModuleVersie() + │ ├─ SbomParserService::parse() (pure, no OR calls — CycloneDX 1.5/1.6, optional SPDX) + │ ├─ ProgressTracker (progress-tracking spec) — start/update/complete + │ ├─ soft-delete previous sbomComponent set for this moduleVersie + │ │ (ObjectService findAll filtered on moduleVersie + not _deleted, bounded batch delete) + │ └─ bulk-save new sbomComponent set in bounded batches + ▼ +OpenRegister object store (sbomComponent, moduleVersie) + │ + ▼ +Components tab (ModuleversieDetail) — SbomComponentsPanel.vue + │ ├─ component table (name, version, purl, licenses) + │ ├─ summary counts (components / distinct licenses / matched vulnerabilities) + │ └─ sbomVulnerabilityMatch.js — read-time join vs kwetsbaarheid register +``` + +Mirrors `archimate-import`'s upload → parse → object creation → status +ergonomics (`SettingsController::importArchiMate` / +`parseArchiMateFileUpload` / `resolveArchiMateMethod`), scoped down to a +single-schema, single-parent-object import instead of a whole-model import. + +## Decision 1 — Parser is a pure, OR-free service (ADR-008) + +`SbomParserService::parse(string $json): array` takes raw JSON text and +returns an array of normalized component DTOs +(`purl`, `name`, `version`, `licenses`, `hashes`, `type`, `bomRef`). It has no +constructor dependency on `ObjectService` or any OR class — it is +unit-testable with plain fixture files and no database. It validates +`bomFormat === 'CycloneDX'` and `specVersion` in `{'1.5', '1.6'}` before +attempting to read `components[]`; anything else throws a typed +`UnsupportedSbomFormatException` with the offending format/version in the +message, caught by the controller and returned as a 422. + +SPDX JSON support (optional, "if cheap") is added as a second entry point, +`SbomParserService::parseSpdx(string $json): array`, sharing the same +component DTO shape (SPDX `packages[]` → `name`, `versionInfo` → `version`, +`licenseConcluded`/`licenseDeclared` → `licenses`, `externalRefs` of type +`purl` → `purl`). The controller picks the parser based on a required +`format` upload parameter (`cyclonedx-json` | `spdx-json`) rather than +sniffing content, so a malformed file gets a clear "wrong format selected" +error instead of a guessed partial parse. + +**Alternative considered:** auto-detecting format from JSON shape +(`bomFormat` key vs `spdxVersion` key). Rejected — explicit is cheaper to +reason about and test, and the upload UI already knows which button the user +clicked. + +## Decision 2 — Component persistence: new `sbomComponent` schema, `moduleVersie`-scoped + +`sbomComponent` (new schema in `softwarecatalogus_register.json`): + +| Field | Type | Notes | +|---|---|---| +| `moduleVersie` | related-object → `moduleVersie` | required; `inversedBy: sbomComponents` | +| `name` | string | required | +| `version` | string | component version as reported in the SBOM | +| `purl` | string | Package URL (`pkg:...`), optional but expected for most ecosystems | +| `licenses` | array\ | SPDX license id(s)/expression(s) as reported; free text if the SBOM has no SPDX id | +| `type` | string | CycloneDX component type (`library`, `application`, `framework`, `container`, …), optional | +| `hashes` | array\ | optional, informational only — not used for matching | +| `bomRef` | string | CycloneDX `bom-ref`, kept for within-import traceability only, not queried cross-import | + +One `sbomComponent` object per component per import. No dependency-graph +edges are modelled (out of scope) — `bomRef` exists only so a future change +could add that without a schema break, it is not read by anything in this +change. + +**Alternative considered:** embedding components as a JSON blob array on +`moduleVersie` itself instead of separate OR objects. Rejected — per-object +storage gets list/filter/count for free from OR's query layer (needed for the +summary counts and the vulnerability-match join), matches how every other +one-to-many relation in this app is modelled (e.g. `compliancy` per module), +and keeps `moduleVersie` objects a bounded size regardless of SBOM size. + +## Decision 3 — Re-import is idempotent via soft-delete-aware replace + +`SbomImportService::importForModuleVersie()`: + +1. Parse the upload (Decision 1). If parsing fails, nothing is written — + the previous component set is untouched. +2. Query existing `sbomComponent` objects for this `moduleVersie` via + `ObjectService` with the standard non-deleted filter (OR's list calls + already exclude `_deleted` rows by default — the previous-set query relies + on that default rather than re-implementing trash filtering, so a prior + replace's freshly-trashed rows are never picked up again). +3. Soft-delete the queried set (OR's normal `deleteObject`/bulk-delete path — + soft-delete, never a hard purge) in bounded batches. +4. Bulk-save the newly-parsed component set in bounded batches (Decision 4). +5. Update `moduleVersie.sbomLastImportedAt` / `sbomFormat` / `sbomFileName` + (Decision 5) in the same call. + +Steps 3–4 are not wrapped in a single OR transaction (OR's ObjectService has +no cross-object transaction primitive available to app-local code) — if step +4 fails partway through, the version is left with **no** component set rather +than a mixed old/new set, which is caught by re-running the import (the next +import starts from "no non-deleted components", identical to a fresh import). +This asymmetry (fail → empty, not fail → stale) is the safer default: a +missing SBOM is visibly missing; a stale one silently misleads. + +**Alternative considered:** additive import (never delete, tag each import +with a batch id, always show only the latest batch). Rejected — the brief is +explicit that re-import *replaces*, and additive-with-latest-batch-filter +adds a batch-id concept and an extra filter dimension to every read for no +scoped benefit (no requirement asks for import history). + +## Decision 4 — Bounded batches + progress reporting (progress-tracking spec) + +Bulk operations (soft-delete of the old set, create of the new set) run in +fixed-size batches (target ~100 objects/batch, matching the batch discipline +already used elsewhere in this app's bulk-save paths) rather than one +all-at-once OR call, per the design constraint on OR bulk-save performance +for SBOMs with hundreds of components. + +For imports whose parsed component count exceeds a threshold (component count +> 50), `SbomImportService` starts a `ProgressTracker` operation +(`startOperation('sbom-import', ['total_items' => $count])`), calls +`setPhase`/`incrementProgress`/`updateStatistics` per batch, and +`completeOperation` at the end, returning the `operationId` in the response +so the frontend can poll `getProgress` — reusing `progress-tracking` exactly +as `archimate-import` does, rather than inventing a second progress +mechanism. Small imports (≤ 50 components, the common case for a single +application's direct+transitive-flattened list) complete synchronously +without needing a poll loop; the response still includes final counts either +way. + +## Decision 5 — Import provenance lives on `moduleVersie`, not a separate import-log object + +Three optional fields are added to the existing `moduleVersie` schema: +`sbomLastImportedAt` (date-time), `sbomFormat` (enum +`cyclonedx-json` | `spdx-json`), `sbomFileName` (string). This gives the +Components tab a "last imported from , " line without a +second schema/query. Existing `moduleVersie` objects remain valid with these +fields unset (same additive-optional-field pattern +`application-lifecycle-tracking` used for `geplandeVervanging` on `gebruik`). + +**Alternative considered:** a dedicated `sbomImport` history object per +upload (auditable log of every import attempt). Rejected as scope creep for +this change — no requirement asks for import history/audit beyond "what's +here now"; OR's own audit trail (already surfaced via the sidebar History tab +pattern used on every detail page) covers the object-level create/update +trail for `moduleVersie` and `sbomComponent` if that's ever needed. + +## Decision 6 — Vulnerability matching is a read-time join, never persisted + +Consistent with how `module-vulnerability-tracking` derives severity and +exposure (Decisions 2–3 of that change) rather than storing them, +SBOM-to-vulnerability matching is computed on demand by a frontend util +(`src/utils/sbomVulnerabilityMatch.js`), not written back to either register. +Two match strategies, both bounded (no full-register free-text scan): + +1. **Confirmed (CVE-id) match.** If the uploaded CycloneDX document carries a + top-level `vulnerabilities[]` (VEX) block — optional per the CycloneDX + spec — the parser (Decision 1) extracts `{cveId, componentBomRef}` pairs + alongside the component list. At render time, each extracted `cveId` is + compared (case-insensitive, exact) against `kwetsbaarheid.cveCode` across + the full `kwetsbaarheid` register (bounded — CVE-id equality is a single + indexed-shape comparison per record, not a text scan). +2. **Possible (name/purl) match.** For each `sbomComponent`, a + case-insensitive substring match of the component's `name` (or the + package segment of `purl`) against `kwetsbaarheid.naam`, **scoped to + `kwetsbaarheid` records whose `modules` already include the + `moduleVersie`'s parent `module`** — never a catalogue-wide scan. This + keeps the heuristic bounded and relevant: it surfaces "you already + recorded a vulnerability against this application — here's the component + in your SBOM it might refer to," not "any word in your SBOM resembles any + vulnerability name in the whole catalogue." + +Confirmed and possible matches render with visually distinct badges; possible +matches are explicitly labelled as such (not auto-elevated to confirmed). +Because nothing is persisted, editing or adding a `kwetsbaarheid` after an +SBOM import changes the match set on next render with no re-import needed — +the two registers stay independently authoritative. + +**Alternative considered:** persisting `matchedKwetsbaarheid` references on +`sbomComponent` at import time. Rejected — would go stale the moment a +`kwetsbaarheid` is edited/added/removed after import, duplicating exactly the +staleness problem `module-vulnerability-tracking` deliberately avoided for +severity; a read-time join has no staleness window. + +## Decision 7 — No outbound HTTP, anywhere in this path + +Neither `SbomParserService` nor `SbomImportService` nor the frontend match +util makes an HTTP call. The parser reads only the uploaded file's bytes; the +matcher reads only the two local OR registers (`sbomComponent` via the +moduleVersie relation, `kwetsbaarheid` via the existing index query). This is +enforced structurally (neither class is given an HTTP client dependency) and +verified by a unit test asserting `SbomParserService`'s constructor takes no +network-capable dependency. Automatic advisory-feed enrichment +(OSV.dev/NVD) stays explicitly out of scope, matching +`module-vulnerability-tracking`'s Decision 5 (external CVE enrichment routes +through openconnector, never bespoke HTTP in softwarecatalog). + +## Nextcloud Integration + +- Controllers: `SbomController` (`importSbom`, `getSbomImportStatus`) — new, + `#[NoAdminRequired]` with an explicit admin/manage-ACL check in the method + body (mirrors `importArchiMate`'s pattern), `#[NoCSRFRequired]` on the + upload action to allow multipart form posts consistent with the ArchiMate + upload endpoint. +- Services: `SbomParserService` (pure), `SbomImportService` + (Controller → Service → Mapper layering, ADR-008), reusing + `ProgressTracker`. +- Mappers/Entities: none new — persistence goes through OpenRegister's + `ObjectService` like every other app-local write (ADR-022), no app-local + Doctrine entity/mapper for `sbomComponent`. +- Events/Hooks: none. + +## Security Considerations + +- Upload endpoint enforces a maximum file size (config value, default 10 MB) + and rejects non-`.json`/non-JSON-parseable content before any parsing is + attempted — the 400 happens on size/content-type, never after a large + buffer is fully parsed. +- Auth mirrors `importArchiMate`: admin group membership (or manage-ACL on + the target `moduleVersie`'s parent `module`) required to import; read + access to the Components tab follows normal `moduleVersie`/OR object read + ACLs — no new public/anonymous route. +- No SSRF surface — the parser never dereferences a URL found inside the SBOM + (e.g. `externalReferences` entries are stored as opaque strings if + captured at all, never fetched). +- Input validation: parser rejects malformed/oversized JSON structurally + (bounded `json_decode` depth, explicit `bomFormat`/`specVersion` checks) + rather than trusting the file. + +## NL Design System + +Components tab uses `CnDataTable` for the component list (columns: name, +version, purl, licenses) and standard Nextcloud upload/button components for +the import control (ADR-012 — no custom table/upload widgets). Match badges +use existing NL Design System status-tag styling (the same visual language as +the severity bands in `module-vulnerability-tracking`, via CSS variables, no +hardcoded colors — ADR-003). + +## File Structure + +``` +lib/ + Controller/ + SbomController.php + Service/ + SbomParserService.php + SbomImportService.php + Settings/ + softwarecatalogus_register.json (sbomComponent + moduleVersie additions) +src/ + components/ + SbomComponentsPanel.vue + utils/ + sbomVulnerabilityMatch.js + manifest.json (Components sidebar tab on ModuleversieDetail) +appinfo/ + routes.php (importSbom, getSbomImportStatus routes) +tests/ + Unit/ + SbomParserServiceTest.php + SbomImportServiceTest.php + fixtures/sbom/ + cyclonedx-1.6-valid.json + cyclonedx-1.5-valid.json + cyclonedx-invalid-format.json + cyclonedx-empty-components.json +tests/vitest/ + sbomVulnerabilityMatch.spec.js +docs/features/ + sbom-import.md +``` + +## Seed Data + +### Schema: `sbomComponent` + +| Field | Object 1 | Object 2 | Object 3 | +|---|---|---|---| +| slug | `sbom-component-lodash` | `sbom-component-log4j-core` | `sbom-component-openssl` | +| moduleVersie | (seeded `moduleVersie` of an existing seeded `module`) | (same version) | (same version) | +| name | `lodash` | `log4j-core` | `openssl` | +| version | `4.17.21` | `2.14.1` | `3.0.2` | +| purl | `pkg:npm/lodash@4.17.21` | `pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1` | `pkg:generic/openssl@3.0.2` | +| licenses | `["MIT"]` | `["Apache-2.0"]` | `["Apache-2.0"]` | +| type | `library` | `library` | `library` | + +**Related items per object:** none (no Files/Notes/Tasks/Contacts relations +on `sbomComponent`). The seeded `log4j-core@2.14.1` name is deliberately +chosen to demonstrate a "possible match" against a seed `kwetsbaarheid` +already carrying `naam: "Log4Shell"` / `cveCode: "CVE-2021-44228"` if such a +seed exists (else this pairing is a documented candidate for the +`module-vulnerability-tracking` seed set, not duplicated here). + +## Trade-offs + +- Choosing render-time vulnerability matching over persisted matches trades a + small amount of per-render compute (bounded — component count × + module-scoped kwetsbaarheid count) for zero staleness, consistent with the + rest of the app's derived-not-stored conventions. +- Restricting the name/purl heuristic to module-scoped `kwetsbaarheid` + records trades recall (a vulnerability recorded against the *wrong* module + by mistake won't surface here) for precision and a bounded query — judged + the right trade for a "possible match, human confirms" feature. +- SPDX support sharing one parser class with CycloneDX trades some internal + branching for avoiding a second service + second set of tests; if SPDX + parsing turns out non-trivial during implementation, `parseSpdx` can be + deferred to a follow-up change without touching the CycloneDX path or the + `sbomComponent` schema (the DTO shape is format-agnostic by design). diff --git a/openspec/changes/sbom-import/proposal.md b/openspec/changes/sbom-import/proposal.md new file mode 100644 index 00000000..6095cc93 --- /dev/null +++ b/openspec/changes/sbom-import/proposal.md @@ -0,0 +1,155 @@ +--- +kind: feature +depends_on: [] +--- + +# softwarecatalog — SBOM import + +## Summary + +Add the ability to import a Software Bill of Materials (SBOM) — CycloneDX +1.5/1.6 JSON, with SPDX JSON as an optional second format — for a specific +`moduleVersie`. The uploaded file is parsed into `sbomComponent` OpenRegister +objects (purl, name, version, licenses, optional hashes) linked to that +version, shown on a new Components tab with license and summary counts, and +cross-referenced against the existing `kwetsbaarheid` register so a version's +component list can answer "are any of these already-known vulnerabilities +relevant here" without leaving the app or calling an external service. + +## Motivation + +The dependency-management research domain (Specter domain 160) has **zero** +implementation in the app today, despite being a recurring reference-tool +category (OWASP Dependency-Track — 20k+ orgs — correlates ingested SBOMs +against advisory data) and a near-term procurement checkbox: NIS2/BIO +supply-chain requirements appear in 561 mapped compliance requirements across +tenders, and Sonatype's 2026 findings (80% of dependencies un-upgraded >1yr) +make dependency-freshness visibility a live demand driver. Specter's canonical +feature catalogue lists `sbom-file` as a `should`-priority feature (demand 8, +upgraded 2026-07-23). + +The `kwetsbaarheid` register already exists (module-vulnerability-tracking) +but nothing in the app currently connects it to what a version *actually +ships* — SBOM import is the missing link between "here is a released version" +and "here is what's inside it, and is any of it already flagged." + +## Affected Projects + +- [x] Project: `softwarecatalog` — new `sbomComponent` schema, SBOM upload + + parse + import backend, Components tab on the module-version detail page, + local vulnerability cross-referencing. + +## Scope + +### In Scope + +- Upload endpoint: bounded file size, JSON content only, admin/manage-ACL + gated (mirrors `importArchiMate`'s auth pattern). +- `SbomParserService`: pure PHP parser (no OR calls) for CycloneDX 1.5 and 1.6 + JSON `components[]`; SPDX 2.3 JSON as an optional second format if it stays + cheap to add on top of the same service. +- `sbomComponent` schema: `purl`, `name`, `version`, `licenses[]`, optional + `hashes[]`, `type`, `moduleVersie` relation. +- Re-import for the same `moduleVersie` replaces the previous component set + (idempotent), soft-delete aware (existing OR trash rows are excluded from + the "previous set" before replacing, never double-processed). +- Bounded-batch OR bulk-save with progress reporting for larger files, reusing + the existing `progress-tracking` capability. +- Components tab on the `ModuleversieDetail` manifest page: component list + with licenses, upload control, summary counts (component count, distinct + license count, matched-vulnerability count). +- Local vulnerability matching against existing `kwetsbaarheid` objects by CVE + id (when the SBOM carries CycloneDX VEX data) and by best-effort + name/purl matching — computed at render time, never persisted, feeding into + (not forking) the `module-vulnerability-tracking` model. +- i18n (Dutch + English), tests with real small CycloneDX fixtures, docs. + +### Out of Scope + +- Any outbound HTTP call to an external vulnerability/advisory service + (OSV.dev, NVD, GitHub Advisories, etc.) — that integration belongs in + openconnector, later, per `feedback_integrations-not-leaves`. +- SBOM generation or export (this change only imports). +- License-policy evaluation (allow/deny lists, obligations) — only the raw + license identifiers are captured and shown. +- Transitive dependency graphs — CycloneDX component **list** only, no + dependency-edge graph parsing/rendering. + +## Approach + +A pure `SbomParserService` normalizes an uploaded CycloneDX (or SPDX) JSON +document into component DTOs; a thin `SbomController` endpoint validates the +upload (size, content-type, auth) and hands off to `SbomImportService`, which +resolves the target `moduleVersie`, soft-deletes its previous `sbomComponent` +set (excluding already-trashed rows), and bulk-saves the new set through +OpenRegister's `ObjectService` in bounded batches, reporting progress via the +existing `ProgressTracker` for larger imports. The frontend adds a Components +tab (`SbomComponentsPanel`) to `ModuleversieDetail` with an upload control, +component table, and summary counts; vulnerability matching against +`kwetsbaarheid` is a read-time join computed by a frontend util, mirroring how +`module-vulnerability-tracking`'s exposure and severity are derived rather +than stored. Full detail in `design.md`. + +## New Dependencies + +None. Parsing uses PHP's built-in `json_decode`; no new Composer or npm +packages. + +## Impact + +- **New schema**: `sbomComponent` in + `lib/Settings/softwarecatalogus_register.json`. +- **Modified schema**: `moduleVersie` gains three optional provenance fields + (`sbomLastImportedAt`, `sbomFormat`, `sbomFileName`) so the Components tab + can show "last imported" without a separate lookup. +- **New backend**: `SbomParserService`, `SbomImportService`, `SbomController` + + route. +- **New frontend**: `SbomComponentsPanel.vue`, a Components sidebar tab on + `ModuleversieDetail`, and a render-time vulnerability-match util. +- **No app-local vulnerability model** — matching reads the existing + `kwetsbaarheid` register; nothing new is written to it. + +## Cross-Project Dependencies + +None. Self-contained within `softwarecatalog`; consumes OpenRegister only. +Automatic external CVE/advisory enrichment (out of scope here) would depend +on `openconnector` if built later. + +## Risks + +### Risk 1: Large SBOMs (hundreds of components) stress OR bulk-save +**Severity:** Medium — **Mitigation:** bounded batch sizes on both the +soft-delete-replace and the create path, with progress reporting so an +in-flight large import is visible rather than appearing hung (per the +`reference_or-magic-table-scan-n1`/bulk-save-performance gotchas already +logged for this app family). + +### Risk 2: Name/purl-based vulnerability matching produces false positives +**Severity:** Medium — **Mitigation:** the heuristic match is scoped to +`kwetsbaarheid` records already linked to the version's parent `module` (not +a full-register free-text scan) and is labelled "possible match" in the UI, +visually distinct from the CVE-id "confirmed match" — the user, not the +system, makes the final call. + +### Risk 3: CycloneDX version/dialect drift (1.4 vs 1.5 vs 1.6, `bomFormat` +variants) +**Severity:** Low — **Mitigation:** parser explicitly validates +`bomFormat: "CycloneDX"` and `specVersion` in `{1.5, 1.6}`, rejecting anything +else with a clear error rather than guessing; unsupported versions are a +documented follow-up, not a silent partial parse. + +## Rollback Strategy + +Additive only: new schema, new endpoint, new manifest tab. Revert the +register-config changes (drop `sbomComponent`, revert the `moduleVersie` +addition) and the controller route/manifest entry; no destructive migration +of existing data is introduced, so rollback is a plain revert of the PR. +Previously-imported `sbomComponent` objects remain in OR storage (soft-deleted +on cleanup if desired) but are inert once the schema/route are removed. + +## Open Questions + +- SPDX JSON support is scoped "if cheap" — the design will confirm whether it + fits inside `SbomParserService` as a second normalizer or should be + deferred to a follow-up change if the two formats diverge too much to share + cleanly. diff --git a/openspec/changes/sbom-import/specs/sbom-import/spec.md b/openspec/changes/sbom-import/specs/sbom-import/spec.md new file mode 100644 index 00000000..d93d7964 --- /dev/null +++ b/openspec/changes/sbom-import/specs/sbom-import/spec.md @@ -0,0 +1,281 @@ +# sbom-import Specification + +**Status**: planned +**Scope**: softwarecatalog +**OpenSpec changes**: +- sbom-import + +## Purpose + +Imports a Software Bill of Materials (SBOM) — CycloneDX 1.5/1.6 JSON, with +SPDX JSON as an optional second format — for a specific `moduleVersie`, +parsing its components into `sbomComponent` OpenRegister objects and +surfacing them on a Components tab with licenses, summary counts, and a +render-time cross-reference against the existing `kwetsbaarheid` +(vulnerability) register. Feeds `module-vulnerability-tracking` rather than +forking a parallel vulnerability model (ADR-001, ADR-008, ADR-022). + +## ADDED Requirements + +### Requirement: CycloneDX SBOM files are parsed into a normalized component list + +`SbomParserService` SHALL parse a CycloneDX JSON document whose +`bomFormat` equals `CycloneDX` and whose `specVersion` is `1.5` or `1.6` into +a list of component records (`name`, `version`, `purl`, `licenses`, optional +`hashes`, optional `type`, `bomRef`) from the document's `components[]` +array. The parser SHALL be a pure service with no dependency on +OpenRegister's `ObjectService` or any HTTP client, so it is unit-testable +against fixture files alone. + +#### Scenario: A valid CycloneDX 1.6 document parses into components + +- **WHEN** `SbomParserService::parse()` is called with a well-formed + CycloneDX 1.6 JSON document containing three `components[]` entries with + `name`, `version`, `purl`, and `licenses` +- **THEN** it returns three component records with those fields populated +- **AND** no OpenRegister call and no HTTP call occurs during parsing + +#### Scenario: An unsupported bomFormat or specVersion is rejected + +- **WHEN** `SbomParserService::parse()` is called with a JSON document whose + `bomFormat` is not `CycloneDX`, or whose `specVersion` is not `1.5` or + `1.6` +- **THEN** the parser throws an `UnsupportedSbomFormatException` naming the + offending format/version +- **AND** no partial component list is returned + +### Requirement: Uploaded SBOM files are bounded in size and JSON-only + +The SBOM upload endpoint SHALL reject any upload exceeding the configured +maximum file size (default 10 MB) and any upload that is not valid JSON, +before invoking the parser, and SHALL require admin group membership or +manage-ACL on the target `moduleVersie`'s parent `module`. + +#### Scenario: An oversized file is rejected before parsing + +- **WHEN** a user uploads an SBOM file larger than the configured maximum +- **THEN** the endpoint responds with an error before `SbomParserService` is + invoked +- **AND** no `sbomComponent` objects are created or replaced + +#### Scenario: A non-JSON file is rejected + +- **WHEN** a user uploads a file that is not valid JSON +- **THEN** the endpoint responds with a 400 error identifying the problem +- **AND** the previous component set for the target `moduleVersie`, if any, + is left unchanged + +#### Scenario: Import requires admin or manage-ACL + +- **WHEN** a user without admin group membership and without manage-ACL on + the target version's module attempts to import an SBOM +- **THEN** the endpoint responds with a 403 error +- **AND** no component objects are created + +### Requirement: Imported components persist as OpenRegister objects scoped to a moduleVersie + +Each parsed component SHALL persist as an `sbomComponent` OpenRegister object +with a required `moduleVersie` relation, `name`, and the parsed `version`, +`purl`, and `licenses` fields; optional `hashes`, `type`, and `bomRef` SHALL +be stored when present in the source SBOM. No app-local database table SHALL +be introduced (ADR-001). + +#### Scenario: A parsed component persists with its moduleVersie relation + +- **WHEN** an SBOM import for a given `moduleVersie` completes +- **THEN** each parsed component exists as an `sbomComponent` object whose + `moduleVersie` relation resolves to that version +- **AND** its `name`, `version`, `purl`, and `licenses` match the source SBOM + +### Requirement: Re-import replaces the previous component set and is soft-delete aware + +The app SHALL replace a `moduleVersie`'s previously imported component set +when a new SBOM is imported for that same version: the previous non-deleted +`sbomComponent` objects for that version SHALL be soft-deleted, and the newly +parsed set SHALL then be created. Already-trashed rows from a prior replace +SHALL NOT be re-processed or double-counted. A failed import SHALL leave the +version with no component set rather than a mixed old/new set. + +#### Scenario: A second import replaces the first + +- **WHEN** a `moduleVersie` already has an imported component set and a user + imports a new SBOM for the same version +- **THEN** the previously imported `sbomComponent` objects are soft-deleted +- **AND** only the components from the new SBOM appear on the version's + Components tab afterwards + +#### Scenario: A prior replace's trashed rows are not reprocessed + +- **WHEN** a `moduleVersie` has already had one replace cycle (its first + component set is soft-deleted, its second is live) +- **AND** a third import runs for the same version +- **THEN** only the live (second) component set is soft-deleted before the + third set is created +- **AND** the count of soft-deleted `sbomComponent` objects from the first + cycle does not change + +### Requirement: Large imports run in bounded batches with progress reporting + +`SbomImportService` SHALL persist and soft-delete `sbomComponent` objects in +bounded batches rather than a single unbounded bulk call. For imports whose +parsed component count exceeds 50, the service SHALL start a +`progress-tracking` operation, update it per batch, and complete it when the +import finishes, exposing the operation id in the import response. + +#### Scenario: A large SBOM import reports incremental progress + +- **WHEN** an uploaded SBOM parses into more than 50 components +- **THEN** the import response includes an operation id +- **AND** `getProgress(operationId)` returns increasing `processed_items` + values while the import is in flight +- **AND** the operation reaches `phase = completed` with `percentage = 100` + when the import finishes + +#### Scenario: A small SBOM import completes without a progress operation + +- **WHEN** an uploaded SBOM parses into 50 or fewer components +- **THEN** the import completes synchronously +- **AND** the response includes the final component count without requiring + a progress poll + +### Requirement: The module-version detail page shows imported components with summary counts + +The `ModuleversieDetail` manifest page SHALL gain a Components tab showing +the imported `sbomComponent` list (name, version, purl, licenses) and summary +counts: total component count, distinct license count, and matched- +vulnerability count (per the matching requirement below). + +#### Scenario: The Components tab reflects an import + +- **WHEN** a user opens the Components tab of a `moduleVersie` that has an + imported SBOM +- **THEN** the component list shows each component's name, version, purl, + and licenses +- **AND** the summary counts show the total component count and the count of + distinct licenses across those components + +#### Scenario: A version with no imported SBOM shows an empty state + +- **WHEN** a user opens the Components tab of a `moduleVersie` with no + imported component set +- **THEN** the tab shows an empty state with an upload control +- **AND** no summary counts are shown as non-zero + +### Requirement: Components are matched against existing kwetsbaarheden without external calls + +For each `sbomComponent`, the app SHALL compute (at render time, never +persisted) matches against the existing `kwetsbaarheid` register using two +bounded local strategies: a confirmed match by exact CVE id when the source +SBOM carries CycloneDX VEX vulnerability data, compared against +`kwetsbaarheid.cveCode`; and a possible match by case-insensitive +name/purl-package comparison against `kwetsbaarheid.naam`, scoped to +`kwetsbaarheid` records whose `modules` already reference the version's +parent `module`. No matched-vulnerability reference SHALL be written back to +either the `sbomComponent` or `kwetsbaarheid` schema, and no HTTP request to +an external vulnerability feed (OSV.dev, NVD, or otherwise) SHALL be made by +the import or matching path. + +#### Scenario: A component with VEX-declared CVE data gets a confirmed match + +- **WHEN** an uploaded CycloneDX document's `vulnerabilities[]` block + references a component by `bom-ref` with `id` equal to an existing + `kwetsbaarheid.cveCode` +- **THEN** that component's Components-tab row shows a confirmed match to + that `kwetsbaarheid` +- **AND** the match is computed at render time, not stored on the + `sbomComponent` object + +#### Scenario: A component name matching a module-scoped vulnerability gets a possible match + +- **WHEN** a `kwetsbaarheid` record's `modules` includes the parent `module` + of an imported `moduleVersie`, and one of that version's `sbomComponent` + names case-insensitively matches (or is contained in) the + `kwetsbaarheid.naam` +- **THEN** that component's Components-tab row shows a possible match, + visually distinguished from a confirmed match + +#### Scenario: A name match outside the module's own vulnerabilities is not surfaced + +- **WHEN** a `kwetsbaarheid` record's `modules` does NOT include the parent + `module` of an imported `moduleVersie`, even if a component name would + textually match that `kwetsbaarheid.naam` +- **THEN** no possible match is shown for that pairing + +#### Scenario: Editing a vulnerability changes the match with no re-import + +- **WHEN** a `kwetsbaarheid`'s `cveCode` or `naam` is edited after an SBOM + has already been imported for an affected version +- **THEN** the Components tab's matches reflect the edited `kwetsbaarheid` + data the next time it is rendered, with no re-import of the SBOM required + +#### Scenario: No outbound HTTP call is made during matching + +- **WHEN** the Components tab computes matches for a version's component + list +- **THEN** the computation reads only the local `sbomComponent` and + `kwetsbaarheid` OpenRegister data +- **AND** no HTTP request is issued to any external vulnerability or + advisory service + +### Requirement: moduleVersie records SBOM import provenance + +The `moduleVersie` schema SHALL gain three optional fields — +`sbomLastImportedAt` (date-time), `sbomFormat` (`cyclonedx-json` | +`spdx-json`), and `sbomFileName` (string) — populated on each successful +import. Existing `moduleVersie` objects SHALL remain valid with these fields +unset. + +#### Scenario: A successful import records provenance on the version + +- **WHEN** an SBOM import for a `moduleVersie` completes successfully +- **THEN** that version's `sbomLastImportedAt`, `sbomFormat`, and + `sbomFileName` are set to the import's timestamp, format, and source file + name + +#### Scenario: Existing versions are unaffected by the schema addition + +- **WHEN** the updated register definition is imported over existing data +- **THEN** existing `moduleVersie` objects without the new fields load and + save unchanged + +## Non-Functional Requirements + +- **Performance:** import of a 500-component SBOM completes without a single + unbounded OR bulk-save call (batches of ~100); the Components tab's + vulnerability-match computation stays bounded to the module-scoped + `kwetsbaarheid` subset plus a single CVE-code equality pass, not a + catalogue-wide scan. +- **Accessibility:** the Components tab and upload control follow existing + `CnDataTable`/Nextcloud form component accessibility behaviour (WCAG AA, + ADR-003) — no custom table/upload widget. +- **Internationalization:** Dutch and English MUST be supported for all new + user-facing strings (ADR-005) — upload control labels, error messages, + Components tab headings, and confirmed/possible match badges. + +## Acceptance Criteria + +- [ ] A valid CycloneDX 1.5 or 1.6 JSON file uploaded against a `moduleVersie` + produces one `sbomComponent` object per parsed component, linked to that + version. +- [ ] Re-importing for the same `moduleVersie` replaces the previous + component set, leaving no duplicate or stale live components. +- [ ] The Components tab shows the component list, license list, and summary + counts for a version with an imported SBOM. +- [ ] Confirmed (CVE-id) and possible (name/purl) vulnerability matches are + visually distinguished and computed without persisting a match reference. +- [ ] No import, parse, or match code path makes an outbound HTTP request. +- [ ] Oversized or non-JSON uploads are rejected before parsing. + +## Notes + +- Vulnerability matching intentionally feeds `module-vulnerability-tracking` + (reads `kwetsbaarheid`) rather than introducing a parallel vulnerability + model, per the context brief and ADR-022 (consume OR abstractions). +- SPDX JSON support is optional for this change; if it proves non-trivial to + share cleanly with the CycloneDX parser, it is deferred to a follow-up + change without affecting any requirement above (all requirements above are + written against the CycloneDX path, which is the required minimum). +- Automatic external CVE/advisory feed ingestion is explicitly out of scope — + see `module-vulnerability-tracking`'s "External CVE enrichment routes + through openconnector" requirement for the pattern a future change would + follow. diff --git a/openspec/changes/sbom-import/tasks.md b/openspec/changes/sbom-import/tasks.md new file mode 100644 index 00000000..2ade19b6 --- /dev/null +++ b/openspec/changes/sbom-import/tasks.md @@ -0,0 +1,99 @@ +# Tasks: sbom-import + +## Implementation Tasks + +### Task 1: Register schema — `sbomComponent` + `moduleVersie` provenance fields +- **spec_ref**: `openspec/changes/sbom-import/specs/sbom-import/spec.md#requirement-imported-components-persist-as-openregister-objects-scoped-to-a-moduleversie` +- **files**: `lib/Settings/softwarecatalogus_register.json` +- **acceptance_criteria**: + - GIVEN the updated register definition WHEN it is imported via the repair step THEN `sbomComponent` exists with `moduleVersie` (required, related-object), `name` (required), `version`, `purl`, `licenses[]`, optional `hashes[]`/`type`/`bomRef` + - GIVEN the updated `moduleVersie` schema WHEN existing `moduleVersie` objects are loaded THEN they remain valid with `sbomLastImportedAt`/`sbomFormat`/`sbomFileName` unset +- [ ] Implement +- [ ] Test + +### Task 2: `SbomParserService` — pure CycloneDX 1.5/1.6 parser +- **spec_ref**: `openspec/changes/sbom-import/specs/sbom-import/spec.md#requirement-cyclonedx-sbom-files-are-parsed-into-a-normalized-component-list` +- **files**: `lib/Service/SbomParserService.php`, `lib/Exception/UnsupportedSbomFormatException.php`, `tests/Unit/SbomParserServiceTest.php`, `tests/fixtures/sbom/cyclonedx-1.6-valid.json`, `tests/fixtures/sbom/cyclonedx-1.5-valid.json`, `tests/fixtures/sbom/cyclonedx-invalid-format.json`, `tests/fixtures/sbom/cyclonedx-with-vex.json` +- **acceptance_criteria**: + - GIVEN a well-formed CycloneDX 1.6 fixture WHEN `parse()` is called THEN it returns component records with name/version/purl/licenses and makes no OR or HTTP call + - GIVEN a fixture with `bomFormat != CycloneDX` or unsupported `specVersion` WHEN `parse()` is called THEN it throws `UnsupportedSbomFormatException` and returns no partial list + - GIVEN a fixture with a top-level `vulnerabilities[]` VEX block WHEN `parse()` is called THEN it also returns `{cveId, componentBomRef}` pairs +- [ ] Implement +- [ ] Test + +### Task 3: `SbomImportService` — soft-delete-aware replace, bounded batches, progress +- **spec_ref**: `openspec/changes/sbom-import/specs/sbom-import/spec.md#requirement-re-import-replaces-the-previous-component-set-and-is-soft-delete-aware` +- **files**: `lib/Service/SbomImportService.php`, `tests/Unit/SbomImportServiceTest.php` +- **acceptance_criteria**: + - GIVEN a `moduleVersie` with an existing live component set WHEN a new SBOM is imported for it THEN the previous set is soft-deleted and only the new set is live afterwards, in bounded batches + - GIVEN a version with an already-trashed prior set from an earlier replace WHEN a third import runs THEN the already-trashed rows are not re-queried or re-deleted + - GIVEN a parsed set of more than 50 components WHEN import runs THEN a `progress-tracking` operation is started, updated per batch, and completed, with its id returned in the response + - GIVEN a successful import WHEN it completes THEN `moduleVersie.sbomLastImportedAt`/`sbomFormat`/`sbomFileName` are set +- [ ] Implement +- [ ] Test + +### Task 4: `SbomController` upload + status endpoints +- **spec_ref**: `openspec/changes/sbom-import/specs/sbom-import/spec.md#requirement-uploaded-sbom-files-are-bounded-in-size-and-json-only` +- **files**: `lib/Controller/SbomController.php`, `appinfo/routes.php` +- **acceptance_criteria**: + - GIVEN an upload exceeding the configured max size WHEN it is posted THEN the endpoint rejects it before the parser runs and no `sbomComponent` objects change + - GIVEN a non-JSON upload WHEN it is posted THEN the endpoint responds 400 and the previous component set is unchanged + - GIVEN a user without admin group membership or manage-ACL on the target module WHEN they attempt an import THEN the endpoint responds 403 and creates no objects +- [ ] Implement +- [ ] Test + +### Task 5: Render-time vulnerability match util +- **spec_ref**: `openspec/changes/sbom-import/specs/sbom-import/spec.md#requirement-components-are-matched-against-existing-kwetsbaarheden-without-external-calls` +- **files**: `src/utils/sbomVulnerabilityMatch.js`, `tests/vitest/sbomVulnerabilityMatch.spec.js` +- **acceptance_criteria**: + - GIVEN a component with a VEX-extracted CVE id equal to an existing `kwetsbaarheid.cveCode` WHEN matches are computed THEN that component gets a confirmed match, computed on the fly and not read from a stored field + - GIVEN a `kwetsbaarheid` linked to the version's parent module whose `naam` case-insensitively contains a component's name WHEN matches are computed THEN that component gets a possible match; a same-name `kwetsbaarheid` NOT linked to that module produces no match + - GIVEN the match computation runs WHEN inspected THEN it issues zero HTTP requests (no `fetch`/`axios`/network call in the util) +- [ ] Implement +- [ ] Test + +### Task 6: Components tab UI — `SbomComponentsPanel` + manifest wiring +- **spec_ref**: `openspec/changes/sbom-import/specs/sbom-import/spec.md#requirement-the-module-version-detail-page-shows-imported-components-with-summary-counts` +- **files**: `src/components/SbomComponentsPanel.vue`, `src/manifest.json` +- **acceptance_criteria**: + - GIVEN a `moduleVersie` with an imported component set WHEN its Components tab is opened THEN the component list (name/version/purl/licenses) and summary counts (total, distinct licenses, matched vulnerabilities) render via `CnDataTable` + - GIVEN a `moduleVersie` with no imported set WHEN its Components tab is opened THEN an empty state with an upload control renders and no summary counts show as non-zero +- [ ] Implement +- [ ] Test + +### Task 7: i18n strings +- **spec_ref**: `openspec/changes/sbom-import/specs/sbom-import/spec.md#non-functional-requirements` +- **files**: `l10n/en.js`, `l10n/en.json`, `l10n/nl.js`, `l10n/nl.json` +- **acceptance_criteria**: + - GIVEN the Components tab, upload control, and confirmed/possible match badges WHEN rendered in Dutch or English THEN every new user-facing string resolves to a translated key in both locales (English source keys, per i18n convention) +- [ ] Implement +- [ ] Test + +### Task 8: Optional SPDX JSON support +- **spec_ref**: `openspec/changes/sbom-import/specs/sbom-import/spec.md#notes` +- **files**: `lib/Service/SbomParserService.php`, `tests/fixtures/sbom/spdx-2.3-valid.json`, `tests/Unit/SbomParserServiceTest.php` +- **acceptance_criteria**: + - GIVEN a valid SPDX 2.3 JSON fixture WHEN `parseSpdx()` is called THEN it returns component records in the same DTO shape as `parse()` (name/version/purl/licenses) + - GIVEN SPDX parsing proves non-trivial to share cleanly with the CycloneDX path WHEN this task is assessed THEN it is deferred to a follow-up change and this task is marked deferred with a reason, per the proposal's open question — the CycloneDX path (Tasks 1-7) already satisfies every MUST requirement +- [ ] Implement +- [ ] Test + +### Task 9: Docs + traceability +- **spec_ref**: `openspec/changes/sbom-import/specs/sbom-import/spec.md#purpose` +- **files**: `docs/features/sbom-import.md`, `docs/images/sbom-import-*.png` +- **acceptance_criteria**: + - GIVEN the Components tab is implemented WHEN documented THEN `docs/features/sbom-import.md` describes upload, replace-on-reimport, and confirmed/possible matching with Playwright-captured screenshots + - GIVEN new/changed backend and frontend methods for this change WHEN inspected THEN each carries `@spec openspec/changes/sbom-import/specs/sbom-import/spec.md` (or a reason-bearing `@spec exclude`) +- [ ] Implement +- [ ] Test + +## Quality checklist + +- All new/changed business logic covered by PHPUnit unit tests (`tests/Unit/`), including `SbomParserService` and `SbomImportService`, using real small CycloneDX fixtures (not mocked JSON shapes) +- New/changed API endpoints (`importSbom`, `getSbomImportStatus`) covered by Newman/Postman tests +- UI changes (Components tab, upload flow) covered by Playwright browser tests +- All tests pass (`composer test`, `newman run`); overall new-code coverage ≥ 75% (ADR-009) +- Feature documentation updated in `docs/features/sbom-import.md` with screenshots (ADR-010) +- Dutch (`nl_NL`) and English (`en_US`) translation strings added for every new user-facing string (ADR-005) +- `openspec validate --change sbom-import` passes +- No outbound HTTP call exists anywhere in the parse/import/match path (verified by the Task 2/5 tests and a structural check that neither service is given an HTTP client dependency) diff --git a/openspec/changes/vendor-visibility-rbac/.openspec.yaml b/openspec/changes/vendor-visibility-rbac/.openspec.yaml new file mode 100644 index 00000000..cf371d3a --- /dev/null +++ b/openspec/changes/vendor-visibility-rbac/.openspec.yaml @@ -0,0 +1,2 @@ +schema: conduction +created: 2026-07-23 diff --git a/openspec/changes/vendor-visibility-rbac/context-brief.md b/openspec/changes/vendor-visibility-rbac/context-brief.md new file mode 100644 index 00000000..11313179 --- /dev/null +++ b/openspec/changes/vendor-visibility-rbac/context-brief.md @@ -0,0 +1,27 @@ +# Context Brief: vendor-visibility-rbac + +## What +A server-enforced visibility matrix that hides an organisation's **applicatielandschap (gebruik), koppelingen, and contracts** from vendor-role users and from other organisations, unless explicitly shared (deelname) or published as open data. Includes an audit of existing endpoints for leak paths and regression tests proving a vendor cannot enumerate another org's landscape. + +## Why (evidence) +- VNG Softwarecatalogus issue #105 ("leverancier mag applicatielandschap niet zien") — plus leak bug reports #315, #394, #455 in the incumbent product: this is a known, recurring vulnerability class in this product category. +- 32 organisatie/RBAC-labelled VNG issues; 192 security + 208 privacy tender requirements in the mapped set. +- Specter canonical feature: `vendor-visibility-rbac` (must, demand 36) — highest-demand item of the build wave. + +## Current state (read these specs first) +- `openspec/specs/aangeboden-gebruik-api` — afnemer/ambtenaar/deelnemer role scoping already exists for offered-usage. +- `openspec/specs/deelnames-gebruik` — deelname queries use a scoped RBAC bypass; understand it before extending. +- `openspec/specs/softwarecatalog-adopt-or-abstractions` — tenant context via `X-OpenRegister-Organisation`; RegisterResolver. +- `openspec/specs/open-data-publishing` — published-only anonymous surface (PII-stripped) is the ONLY intended public path. +- `openspec/changes/organisation-parent-hierarchy-rbac-fix` (pending on development) — related RBAC work; do not conflict. +- lib/: sc-handlers, organisatie-service specs describe role groups (beheerder/inkoper/ambtenaar). + +## Scope +IN: define the visibility matrix (role × object type × relationship), server-side enforcement in the services/handlers that serve gebruik/koppelingen/contract reads, deny-by-default for cross-org access, leak-path audit of routes.php endpoints touching those objects, tests (incl. negative tests per role), docs. +OUT: UI permission editor, new sharing flows, changes to open-data publishing. + +## Design constraints +- **Fail closed.** Known trap (OpenRegister or#2025): a custom-scope veto evaluated AFTER a default-open grant is dead code — enforce deny BEFORE any default grant path. +- Publish state is RBAC, not a self-serve flag. +- ADR-001 OR storage only; ADR-008 layering; ADR-009 tests mandatory for security changes (hydra gate security-change-has-tests will check). +- OpenSpec delta headers MUST be `### Requirement: `. diff --git a/openspec/changes/vendor-visibility-rbac/design.md b/openspec/changes/vendor-visibility-rbac/design.md new file mode 100644 index 00000000..86255c82 --- /dev/null +++ b/openspec/changes/vendor-visibility-rbac/design.md @@ -0,0 +1,74 @@ +# Design: vendor-visibility-rbac + +## Architecture Overview +SoftwareCatalog owns no domain tables (ADR-001); all gebruik/koppeling/contract data lives in OpenRegister as JSON objects, and reads normally go through OpenRegister's own schema RBAC engine. The read paths this change touches are the ones that deliberately **bypass** that engine (`_rbac: false`, `_multitenancy: false`) to serve cross-organisation queries such as "what has been offered to my organisation" or "who uses my product" — a pattern already established by `aangeboden-gebruik-api` and `deelnames-gebruik`. This change does not introduce a new authorization mechanism; it introduces one explicit, shared visibility matrix that every RBAC-bypassing read path in `AangebodenGebruikController`/`Service` and `GebruikController`/`Service` MUST evaluate before the bypass query runs, and it verifies the one path (contracts) where the answer is "let OpenRegister's own schema RBAC handle it, verify the rule is correct." + +``` +Caller ──▶ Controller (resolves: role, active-org UUID, target relationship) + │ + ├─ deny check (BEFORE any bypass query) ──▶ 403 / empty envelope + │ + └─ allow ──▶ Service (may use _rbac:false/_multitenancy:false, + but the query is ALWAYS field-scoped to the + caller's own org UUID or the specific relationship + already proven above — never "everything") +``` + +## Visibility Matrix +This is the canonical decision table this change implements. "Object" = a `gebruik`, `koppeling`, or `contract` OpenRegister object. + +| Caller role | Relationship to object | Result | +|---|---|---| +| `admin` | any | full read | +| `ambtenaar` | any | full read (existing, unchanged — already gated in `getAllGebruiksForAmbtenaar`/`getSingleGebruikForAmbtenaar`/`getKoppelingenGebruikByUuid`) | +| `gebruik-beheerder` (municipality/samenwerking) | object's owning organisation == caller's active organisation | read | +| `gebruik-beheerder` | object's owning organisation != caller's active organisation, and caller's org is not afnemer/deelnemer on it | **deny** (closes discovery.md finding 2) | +| `aanbod-beheerder` (vendor) | object's `aanbieder` == caller's active organisation (i.e. it is the vendor's own offered product's usage) | read | +| `aanbod-beheerder` | object's owning organisation is a different organisation and the caller is not the `aanbieder` | **deny** — this is the context brief's core complaint | +| any authenticated caller | caller's active organisation is `afnemer` on the object | read (existing `getGebruiksWhereAfnemer` behaviour, hardened with an explicit auth guard) | +| any authenticated caller | caller's active organisation is in the object's `deelnemers` array | read (existing `deelnames-gebruik` behaviour, unchanged) | +| unauthenticated / no active organisation | any | deny — empty envelope, never a 500, never a defaulted organisation | +| anonymous (public) | object is published (`publicatiedatum <= now`) | read via the existing `open-data-publishing` surface only — unaffected by this change | + +The matrix is relationship-first: "which organisation does the caller represent, and what is that organisation's relationship to this object" — not a flat role→data-shape mapping. `ambtenaar`/`admin` are the only roles with a role-only (relationship-free) bypass, matching the pattern already used everywhere else in the codebase except the one gap this change closes. + +## Nextcloud Integration +- Controllers: `AangebodenGebruikController` (add explicit auth guard to `getGebruiksWhereAfnemer`), `GebruikController` (extend `applyAanbodScopeToOptions` — renamed in spirit to cover both `aanbod-beheerder` and `gebruik-beheerder` — to org-scope `gebruik-beheerder` the same way `aanbod-beheerder` is already scoped) +- Services: `AangebodenGebruikService` (no query-shape change — the existing field-scoped queries are already correct and are locked in with tests), `GebruikService::getGebruiken()` (unchanged — the fix is entirely at the controller's option-building layer, consistent with the existing `aanbod-beheerder` pattern) +- Mappers/Entities: none — no new OpenRegister schema fields +- Events/Hooks: none + +## Security Considerations +- **Deny-before-grant ordering (the or#2025 trap named in the context brief)**: every enforcement point in this change resolves the caller's role + relationship and returns the deny result **before** any `_rbac: false` query is built or issued. This is already the pattern in `getKoppelingenGebruikByUuid()`'s ownership check and in `applyAanbodScopeToOptions()`'s early-return for empty `applicatieIds`; the `gebruik-beheerder` fix and the `getGebruiksWhereAfnemer` auth guard follow the identical shape. +- **Fail closed, not fail informative**: every deny path returns the existing empty-result envelope (`{results: [], total: 0, ...}`), HTTP 200 or 403 per the existing per-endpoint convention — never a 500, and never falls through to an unscoped query on any exception (matches the existing try/catch-then-empty-array convention already used throughout `AangebodenGebruikService`). +- **No new trust boundary**: the caller's active-organisation UUID continues to come exclusively from `OrganisationService::getActiveOrganisation()` (server-side session state), never from a client-supplied `organisation` query parameter, except for the already-gated `ambtenaar`-only override in `getKoppelingenGebruikByUuid`. +- **Contract schema RBAC verification**: because contract CRUD runs through the OR object store directly (ADR-022), this change adds a verification test asserting the `contract` schema's RBAC read rule denies a non-counterparty `aanbod-beheerder` a cross-organisation read. If the assertion fails against the current schema config, the schema's RBAC read rule (in `lib/Settings/softwarecatalogus_register.json`) is tightened as part of this change — this is a config fix, not new controller code, and stays within ADR-001 (OR storage only). +- **Negative tests are mandatory**: per ADR-009 and the hydra `security-change-has-tests` gate, every deny branch in the matrix above gets a corresponding PHPUnit negative test asserting the empty/denied result, not just a positive test of the allow path. + +## File Structure +``` +lib/ + Controller/ + AangebodenGebruikController.php (add explicit auth guard to getGebruiksWhereAfnemer) + GebruikController.php (extend org-scoping to gebruik-beheerder) + Service/ + AangebodenGebruikService.php (no shape change; covered by new tests) + GebruikService.php (no shape change; covered by new tests) + Settings/ + softwarecatalogus_register.json (contract schema RBAC read rule — only if the + verification test in tasks.md finds a gap) +tests/ + Unit/Controller/AangebodenGebruikControllerTest.php (new negative-access tests) + Unit/Controller/GebruikControllerTest.php (new negative-access tests: + gebruik-beheerder cross-org denied) + Unit/Service/AangebodenGebruikServiceTest.php (lock in existing correct scoping) + Integration/ContractRbacTest.php (or equivalent — schema RBAC verification) +``` + +## Trade-offs +- **Extending the matrix to `gebruik-beheerder` vs. scoping this change strictly to the vendor complaint**: the narrower option (touch only `aanbod-beheerder` paths) would leave the `gebruik-beheerder` cross-municipality leak (discovery.md finding 2) live, which contradicts the context brief's own framing ("...and from other organisations") and the fail-closed design constraint. The wider option is chosen, but is called out explicitly in `proposal.md`/`DEFERRED_QUESTIONS` because it changes already-shipped behaviour for an existing group, not just new code for a new concern. +- **Verifying vs. rewriting contract RBAC**: rewriting the OR schema RBAC engine itself is out of scope (ADR-011 — check for existing functionality first; OpenRegister already has a schema RBAC read-rule mechanism, proven by `open-data-publishing`'s `{group:public, match:{publicatiedatum:{$lte:$now}}}` rule). This change verifies and, only if necessary, adjusts the existing `contract` schema's rule rather than adding app-level contract-read gating, keeping contract CRUD entirely on the OR object store per ADR-022. +- **No new NC group / no UI permission editor**: the existing `admin`/`ambtenaar`/`gebruik-beheerder`/`aanbod-beheerder` groups are reused as-is; this change is enforcement-only, matching the "OUT: UI permission editor" scope boundary. + +## Open Questions +See `DEFERRED_QUESTIONS` in the final task report — specifically whether closing the `gebruik-beheerder` cross-organisation read (design decision above) is approved as part of this change, given it changes already-shipped behaviour for every currently-provisioned `Gemeente`/`Samenwerking` organisation. diff --git a/openspec/changes/vendor-visibility-rbac/discovery.md b/openspec/changes/vendor-visibility-rbac/discovery.md new file mode 100644 index 00000000..5e1ea96b --- /dev/null +++ b/openspec/changes/vendor-visibility-rbac/discovery.md @@ -0,0 +1,54 @@ +# Discovery: vendor-visibility-rbac + +## Question +Does SoftwareCatalog currently leak an organisation's applicatielandschap (gebruik), koppelingen, or contracts to vendor-role (`aanbod-beheerder`) users or to other organisations — and if so, exactly where, and what is the minimal fix that fails closed? + +## Approach Taken +Read every controller/service pair that serves gebruik, koppelingen, or contract data, tracing each `_rbac: false` / `_multitenancy: false` OpenRegister query back to the authorization check (or absence of one) that gates it: +- `lib/Controller/AangebodenGebruikController.php` + `lib/Service/AangebodenGebruikService.php` (all 7 endpoints) +- `lib/Controller/GebruikController.php` + `lib/Service/GebruikService.php` +- `lib/Controller/ContractApprovalController.php` (contract approval delegation; contract CRUD itself runs through the OR object store per ADR-022, so it is governed by OR schema RBAC, not controller code) +- `appinfo/routes.php` for the full set of routes touching these three object types +- Role model: `lib/Service/SoftwareCatalogue/ContactPersonHandler.php::getRoleGroupByOrganizationType()` (organisation `type` → NC group mapping) and `lib/Settings/softwarecatalogus_register.json` (`organisatie.type` enum: `Gemeente`, `Leverancier`, `Samenwerking`, `Community`) +- Cross-checked against `openspec/specs/aangeboden-gebruik-api`, `openspec/specs/deelnames-gebruik`, `openspec/specs/organisatie-service`, `openspec/specs/sc-handlers` (all `status: done`) + +## Findings + +### Role model (as it exists in code today) +Organisation `type` maps to an NC group at contact-provisioning time: +- `Gemeente` / `Samenwerking` → `gebruik-beheerder` (municipality / collaboration — "usage manager") +- `Leverancier` / `Community` → `aanbod-beheerder` (**this is the "vendor" role** named in the context brief) +- `admin` and `ambtenaar` are separate, orthogonal NC groups (not derived from organisation type) used as the "sees everything" bypass in the aangeboden-gebruik-api surface. + +### Confirmed-correct enforcement (no change needed, but needs regression tests) +1. `AangebodenGebruikService::getKoppelingenGebruikByUuid()` — for non-ambtenaar callers, computes `hasAccess = ($ownerOrg === $currentOrg)` by fetching the target uuid's `@self.organisation` **before** issuing the RBAC-disabled search, and returns the empty envelope when `hasAccess` is false. This is the correct "deny check before default-open grant" ordering the context brief's fail-closed constraint calls for. `$currentOrg` is derived from `IUserSession::getUser()` → null for anonymous, so an unauthenticated caller always fails this check. +2. `AangebodenGebruikController::getKoppelingenGebruikByUuid()` — the `organisation` query-param override is applied **only** when `isAmbtenaar === true`; non-ambtenaar callers cannot widen the query. +3. `AangebodenGebruikService::getGebruiksWhereAfnemer()` / `getGebruiksWhereDeelnemers()` — both are RBAC-disabled by design (per `deelnames-gebruik`), but the query is hard-filtered to `afnemer == currentOrg` / `deelnemers == currentOrg` respectively, where `currentOrg` comes from the caller's own authenticated session (`OrganisationService::getActiveOrganisation()`, never client-supplied). These endpoints return only the caller's own organisation's relationship view ("what's been offered to us" / "where we participate"), not another organisation's landscape — so despite the `@PublicPage` + RBAC-disabled combination, they do not leak cross-org data today. This matches the existing `deelnames-gebruik` and `aangeboden-gebruik-api` specs. +4. `ContractApprovalController::submit()` / `submitRenewal()` already carry a per-object ownership guard (admin, or the aanbod-beheerder whose active organisation owns the contract) per its own docblock (ADR-005) — confirmed present, not a gap. + +### Confirmed gap 1 (hardening, not a live leak): implicit-only auth on a `@PublicPage` endpoint +`AangebodenGebruikController::getGebruiksWhereAfnemer()` is annotated `@PublicPage` with **no explicit authentication check** in the method body. Its safety today depends entirely on the unstated invariant that `AangebodenGebruikService::getCurrentOrganisation()` returns `null` for any request with no authenticated NC user, which it currently does (`$this->userSession->getUser() === null` short-circuits before calling OpenRegister). This is correct today but fragile: it is a security property of a downstream helper, not an explicit guard at the entry point, and nothing tests it. **Recommendation: add an explicit auth check (mirroring the pattern already used in `setGebruikSelfToActiveOrg`/`deleteGebruikAsAfnemer`) and cover it with a negative test**, rather than continuing to rely on the implicit chain. + +### Confirmed gap 2 (real, in-scope leak): `gebruik-beheerder` gets an unscoped, cross-organisation read +`GebruikController::getGebruiken()`'s own docblock states the current design: *"For a gebruik-beheerder, returns all gebruiken. For an aanbod-beheerder, returns gebruiken of applications of the user's organization."* Tracing this: +- `GebruikService::getGebruiken()` unconditionally calls `searchObjectsPaginated(..., _rbac: false, _multitenancy: false)` — RBAC is disabled for every caller who reaches the service, not just `ambtenaar`/`admin`. +- `GebruikController::applyAanbodScopeToOptions()` only restricts the query (to the caller's own `aanbieder` organisation's applications) when the caller is `aanbod-beheerder` **and not** `admin` **and not** `gebruik-beheerder`. Any caller in `gebruik-beheerder` (or `admin`) passes straight through with **no organisation filter added at all**. +- `gebruik-beheerder` is auto-assigned to **every** contact person whose organisation `type` is `Gemeente` or `Samenwerking` (`ContactPersonHandler::getRoleGroupByOrganizationType()`) — i.e. every municipality's own staff, not a national/oversight role. + +Net effect: today, any municipality's `gebruik-beheerder` user gets a **global, cross-organisation** view of every organisation's gebruik data — not just their own — through `GET /api/gebruik`. This is inconsistent with the rest of the codebase's established pattern, where every other "see everything" path (`getAllGebruiksForAmbtenaar`, `getSingleGebruikForAmbtenaar`, `getKoppelingenGebruikByUuid` cross-org branch) is gated specifically behind the separate `ambtenaar` (or `admin`) group, never behind `gebruik-beheerder`. This is squarely the failure mode the context brief describes — "hides applicatielandschap ... from vendor-role users **and from other organisations**" — just manifesting for the municipality-side role rather than the vendor-side role. It is flagged as `DEFERRED_QUESTIONS` item 1 below because closing it changes already-shipped behaviour for an existing group, and needs an explicit go-ahead before the spec commits to a behaviour change beyond the vendor-specific ask. + +### Contract read paths +Contract CRUD runs entirely through the manifest renderer's OpenRegister object store (`contract-administration` spec, ADR-022) — there is no app-local contract controller for reads. Visibility of contracts is therefore governed by the OpenRegister `contract` schema's own RBAC read rule, not by SoftwareCatalog PHP code. This audit did not find the schema's current read-rule configuration inside this repo (it lives in `lib/Settings/softwarecatalogus_register.json`'s schema RBAC block, deployed at import time) — verifying and, if needed, tightening that rule is a design/tasks item, not a controller-code item. + +## Recommendation +Proceed to design.md and specs with a visibility matrix that: +1. Defines relationship-based access (owner / afnemer / aanbieder / deelnemer / published) as the primary enforcement mechanism for the AangebodenGebruik surface — it is already implemented correctly there; the work is to make the `getGebruiksWhereAfnemer` auth guard explicit and lock all of it in with tests. +2. Extends the `aanbod-beheerder` (vendor) organisation-scoping pattern already proven in `applyAanbodScopeToOptions()` to `gebruik-beheerder` as well, so that role also loses its accidental global-read privilege and is scoped to its own organisation's gebruik — while the deliberate cross-org bypass remains available only to `ambtenaar`/`admin`, matching the pattern everywhere else in the codebase. +3. Treats the OR `contract` schema RBAC read rule as a verification target: assert (via a schema-config read + an integration/PHPUnit test) that a non-counterparty `aanbod-beheerder` cannot read another organisation's contract, and fix the schema config if the assertion fails. + +## Risks Uncovered +- Closing the `gebruik-beheerder` global-read gap (finding 2) is a behaviour change for an existing, already-provisioned NC group — every currently-onboarded `Gemeente`/`Samenwerking` organisation's staff will lose the ability to browse other municipalities' gebruik data through this endpoint. This is the correct fail-closed behaviour per the design constraints, but it is a bigger blast radius than "just fix the vendor path," so it is called out explicitly rather than silently folded into the vendor fix. +- No `contract` schema RBAC config was located inside this repo checkout to inspect directly; the corresponding spec requirement is written as a verification + fail-closed-fix requirement rather than asserting today's exact rule text. + +## Next Steps +Proceed to design.md and the `vendor-visibility-rbac` spec delta, carrying forward both confirmed gaps (1: explicit auth guard, 2: gebruik-beheerder org-scoping) as MUST requirements, and the contract-schema RBAC verification as a MUST requirement with a fail-closed remediation path. Surface finding 2's scope as `DEFERRED_QUESTIONS` for explicit confirmation before `opsx-apply` implements it. diff --git a/openspec/changes/vendor-visibility-rbac/proposal.md b/openspec/changes/vendor-visibility-rbac/proposal.md new file mode 100644 index 00000000..c5040daf --- /dev/null +++ b/openspec/changes/vendor-visibility-rbac/proposal.md @@ -0,0 +1,65 @@ +# Proposal: vendor-visibility-rbac + +## Summary +Defines and server-side enforces a visibility matrix (role × object type × relationship) that hides an organisation's applicatielandschap (gebruik), koppelingen, and contracts from vendor-role (`aanbod-beheerder`) users and from other organisations, unless the data was explicitly shared (deelname) or published as open data. The change audits the existing gebruik/koppelingen/contract read endpoints for leak paths, closes any gaps found, and adds regression tests — including negative tests — proving a vendor cannot enumerate another organisation's landscape. + +## Motivation +This is a known, recurring vulnerability class in this product category: VNG Softwarecatalogus issue #105 ("leverancier mag applicatielandschap niet zien") plus leak bug reports #315, #394, #455 in the incumbent product all describe the same failure mode — a supplier user is able to see a customer organisation's full software portfolio rather than only the usage that involves the supplier's own products. The mapped requirement set carries 192 security and 208 privacy tender requirements, and 32 organisatie/RBAC-labelled VNG issues, across the wider ecosystem. Specter's canonical feature list flags `vendor-visibility-rbac` as `must` with demand 36 — the highest-demand item of the current build wave. + +SoftwareCatalog already has partial scoping (`GebruikController::applyAanbodScopeToOptions`, `AangebodenGebruikService::getKoppelingenGebruikByUuid` ownership check, `ContractApprovalController`'s per-object ownership guard) but no single documented visibility matrix, no systematic audit of every gebruik/koppelingen/contract read path, and — critically — at least one endpoint (`AangebodenGebruikController::getGebruiksWhereAfnemer`, `@PublicPage` with no authentication check) that has never been proven not to leak. Codifying the matrix now, before more read paths are added, closes the gap while it is still small. + +## Affected Projects +- [x] Project: `softwarecatalog` — visibility matrix definition, server-side enforcement in `AangebodenGebruikController`/`AangebodenGebruikService`, `GebruikController`/`GebruikService`, contract read paths (OR schema RBAC + `ContractApprovalController`), leak-path audit of `appinfo/routes.php`, regression tests. + +## Scope + +### In Scope +- Define the visibility matrix: role (admin / ambtenaar / gebruik-beheerder / aanbod-beheerder(vendor) / anonymous) × object type (gebruik, koppeling, contract) × relationship (owner, afnemer, aanbieder, deelnemer, published, unrelated). +- Server-side enforcement of that matrix in the services/handlers/controllers that serve gebruik, koppelingen, and contract reads (`AangebodenGebruikController`/`Service`, `GebruikController`/`Service`, contract read paths). +- Deny-by-default (fail closed) for cross-organisation access: the deny check MUST run before any default-open / RBAC-bypass grant path, per the OpenRegister or#2025 trap (a veto evaluated after a default-open grant is dead code). +- A leak-path audit of every `appinfo/routes.php` endpoint that reads gebruik, koppelingen, or contract objects, with each endpoint's current authorization posture documented and any gap closed. +- Automated tests, including negative tests per role (vendor denied cross-org reads; unauthenticated caller denied non-public reads), and i18n + docs tasks per project rules. + +### Out of Scope +- A UI permission editor — the visibility matrix is enforced server-side; there is no admin-configurable permission UI in this change. +- New sharing flows — deelname (participant) sharing already exists (`deelnames-gebruik`); this change enforces around it, it does not add new ways to share. +- Changes to the open-data publishing mechanism (`open-data-publishing`) — publish/depublish and the anonymous public surface are unchanged; this change only confirms non-published data stays out of vendor/cross-org reach. +- Restoring organisation parent/child hierarchy — tracked separately in `organisation-parent-hierarchy-rbac-fix`; this change does not touch `OrganisatieService::createOrganisationEntityInternal()`. + +## Approach +1. Document the visibility matrix as the canonical `vendor-visibility-rbac` capability spec, cross-referencing the existing `aangeboden-gebruik-api` and `deelnames-gebruik` specs rather than restating their requirements. +2. Add/confirm a deny-before-grant guard at the top of every gebruik/koppelingen/contract read path: resolve the caller's role and relationship to the target object(s) first, and only then apply any RBAC-bypass (`_rbac: false`) query. +3. Close the specific gap found during discovery in `AangebodenGebruikController::getGebruiksWhereAfnemer()` (no authentication check on a `@PublicPage` endpoint) and any equivalent gaps the routes.php audit surfaces. +4. Lock in already-correct behaviour (e.g. the ownership check in `getKoppelingenGebruikByUuid`, the `aanbod-beheerder` scoping in `GebruikController`, the per-object guard in `ContractApprovalController::submit`/`submitRenewal`) with regression tests so it cannot silently regress. +5. Verify the OpenRegister `contract` schema's RBAC read rule denies cross-organisation reads for non-counterparty, non-admin, non-ambtenaar callers; contract CRUD itself runs through the OR object store (ADR-022), so this is primarily a schema RBAC verification + test, not new controller code. + +## New Dependencies +None. + +## Impact +- `lib/Controller/AangebodenGebruikController.php`, `lib/Service/AangebodenGebruikService.php` +- `lib/Controller/GebruikController.php`, `lib/Service/GebruikService.php` +- `lib/Controller/ContractApprovalController.php` (verification only — guard already exists) +- `appinfo/routes.php` (audit; no route shape changes expected) +- Softwarecatalogus `contract` schema RBAC read rule (verification, and a fix if the audit finds a gap) +- Test suite: new PHPUnit unit + negative-access tests; Newman REST collection additions for the audited endpoints + +## Cross-Project Dependencies +None — this is a self-contained SoftwareCatalog change. It reads (but does not modify) OpenRegister's schema RBAC engine and the `RegisterResolverService`/tenant-context abstractions already adopted per `softwarecatalog-adopt-or-abstractions`. + +## Risks + +### Risk 1: Fail-open regression via veto-after-default-grant ordering +**Severity:** High — **Mitigation:** Every enforcement point in this change places the deny/role check strictly before any `_rbac: false` / default-open query path, mirroring the OpenRegister or#2025 post-mortem named in the design constraints. Each such ordering is covered by a negative test that asserts the deny path short-circuits before the bypass query is ever built. + +### Risk 2: Audit misses a leak path outside the four named controllers +**Severity:** Medium — **Mitigation:** The routes.php audit task enumerates every route touching the `gebruik`, `koppeling`, and `contract` schemas (via `grep`/register/schema cross-reference), not just the four controllers identified during discovery, and records the result in `discovery.md` for reviewer sign-off. + +### Risk 3: Tightening existing endpoints breaks a currently-working (if accidentally permissive) frontend flow +**Severity:** Low — **Mitigation:** Enforcement changes are scoped to cross-organisation reads only; same-organisation, afnemer/aanbieder, deelnemer, and published relationships are explicitly preserved and covered by positive tests alongside the negative ones. + +## Rollback Strategy +All enforcement changes are additive guard clauses (early-return deny checks) in existing PHP methods, with no schema or route shape changes expected. Revert is a straight `git revert` of the PR; if the OR `contract` schema RBAC rule is changed, that change is reverted independently via the schema config JSON. No data migration is introduced, so no backward-migration step is needed. + +## Open Questions +None — the visibility matrix design constraints (fail closed, publish = RBAC not self-serve, OR storage only) are fixed by the context brief; specific enforcement-point decisions belong in design.md. diff --git a/openspec/changes/vendor-visibility-rbac/specs/vendor-visibility-rbac/spec.md b/openspec/changes/vendor-visibility-rbac/specs/vendor-visibility-rbac/spec.md new file mode 100644 index 00000000..f50e969f --- /dev/null +++ b/openspec/changes/vendor-visibility-rbac/specs/vendor-visibility-rbac/spec.md @@ -0,0 +1,156 @@ +# vendor-visibility-rbac Specification + +**Status**: planned +**Scope**: softwarecatalog +**OpenSpec changes**: +- [vendor-visibility-rbac](../../changes/vendor-visibility-rbac/) _(active)_ + +## Purpose +Defines and server-side enforces a visibility matrix (role × object type × relationship) that hides an organisation's applicatielandschap (gebruik), koppelingen, and contracts from vendor-role (`aanbod-beheerder`) users and from other organisations, unless the data was explicitly shared (deelname) or published as open data (`open-data-publishing`). Every enforcement point evaluates the deny check before any RBAC-bypassing query runs (fail closed, per OpenRegister or#2025), closing both the vendor-specific leak named in VNG Softwarecatalogus issue #105 and the `gebruik-beheerder` cross-municipality leak found during this change's discovery audit. This capability layers on top of — and does not restate — the object-specific behaviour already owned by `aangeboden-gebruik-api`, `deelnames-gebruik`, `gebruik-services`, and `open-data-publishing`. + +## ADDED Requirements + +### Requirement: Every RBAC-bypassing gebruik/koppeling/contract read MUST evaluate its deny check before issuing the bypass query (REQ-001) + +Any code path that queries OpenRegister with `_rbac: false` and/or `_multitenancy: false` for a `gebruik`, `koppeling`, or `contract` object MUST first resolve the caller's role and the caller's organisation's relationship to the target object(s), and MUST return the deny result (the standard empty-result envelope) without issuing the bypass query when that resolution fails. A custom-scope veto evaluated after a default-open grant path has already executed MUST NOT exist anywhere in this surface. + +#### Scenario: Deny check short-circuits before the bypass query is built +- GIVEN a caller whose role/relationship resolution fails the visibility matrix for a given gebruik object +- WHEN the controller processes the request +- THEN the deny branch MUST return before `ObjectService::searchObjectsPaginated` (or `searchObjects`/`find`) is invoked with `_rbac: false` +- AND no cross-organisation query MUST be issued to OpenRegister as a result of this request + +#### Scenario: Exception during resolution fails closed, not open +- GIVEN role/relationship resolution throws an exception (e.g. OpenRegister's `OrganisationService` is unavailable) +- WHEN a gebruik, koppeling, or contract read is requested +- THEN the response MUST be the standard empty-result envelope or a 5xx error +- AND the response MUST NOT contain any object data from an organisation other than the caller's own + +### Requirement: `aanbod-beheerder` (vendor) reads of gebruik/koppeling objects MUST be scoped to the vendor's own offered products (REQ-002) + +An authenticated user whose only relevant group membership is `aanbod-beheerder`, and who is not `admin` or `ambtenaar`, MUST see only gebruik and koppeling objects where their active organisation is the `aanbieder` (offering party) — never another organisation's applicatielandschap as a whole. This requirement locks in and regression-tests the existing `GebruikController::applyAanbodScopeToOptions()` behaviour and the existing `AangebodenGebruikService::getKoppelingenGebruikByUuid()` ownership check. + +#### Scenario: Vendor sees only their own product's usage +- GIVEN a user in the `aanbod-beheerder` group whose active organisation is vendor V, and V is the `aanbieder` on module M +- WHEN the user requests `GET /api/gebruik` +- THEN the response MUST contain only gebruik records whose `module` is one of V's own applications +- AND the response MUST NOT contain gebruik records for any application V does not offer + +#### Scenario: Vendor is denied a cross-organisation applicatielandschap read +- GIVEN a user in the `aanbod-beheerder` group whose active organisation is vendor V +- WHEN the user requests koppelingen/gebruik for a UUID that identifies a different organisation (e.g. municipality G, which V neither owns nor offers to) via `GET /api/koppelingen-gebruik/{uuid}` +- THEN the response MUST be the empty-result envelope +- AND no gebruik or koppeling object belonging to organisation G MUST be present in the response + +#### Scenario: Vendor with no offered applications gets an empty, not unscoped, result +- GIVEN a user in the `aanbod-beheerder` group whose active organisation offers zero applications +- WHEN the user requests `GET /api/gebruik` +- THEN the response MUST be the empty-result envelope +- AND the underlying OpenRegister search MUST NOT be executed without a module filter + +### Requirement: `gebruik-beheerder` reads of gebruik objects MUST be scoped to the caller's own organisation (REQ-003) + +An authenticated user whose group membership includes `gebruik-beheerder` but not `admin` or `ambtenaar` MUST see only gebruik objects owned by, or explicitly shared with (afnemer/deelnemer), their own active organisation — never another organisation's gebruik data. This closes the cross-municipality leak identified in this change's `discovery.md` (finding 2): today `GebruikController::applyAanbodScopeToOptions()` applies no organisation filter for `gebruik-beheerder`, and `GebruikService::getGebruiken()` is unconditionally RBAC-disabled, so any `gebruik-beheerder` currently receives every organisation's gebruik data. + +#### Scenario: Municipality user is denied another municipality's landscape +- GIVEN a user in the `gebruik-beheerder` group whose active organisation is municipality A +- AND municipality B owns gebruik records unrelated to A (A is neither afnemer nor deelnemer) +- WHEN the user requests `GET /api/gebruik` +- THEN the response MUST NOT contain any gebruik record owned by municipality B +- AND the response MUST contain only gebruik records owned by, offered to, or shared with municipality A + +#### Scenario: Municipality user still sees their own organisation's full gebruik set +- GIVEN a user in the `gebruik-beheerder` group whose active organisation is municipality A, which owns 12 gebruik records +- WHEN the user requests `GET /api/gebruik` +- THEN the response MUST contain all 12 of municipality A's own gebruik records +- AND pagination/filtering behaviour for those 12 records MUST be unchanged from before this requirement + +#### Scenario: ambtenaar retains the existing unrestricted read +- GIVEN a user in the `ambtenaar` group (with or without `gebruik-beheerder`) +- WHEN the user requests `GET /api/gebruik` +- THEN the response MUST NOT be organisation-restricted +- AND this MUST remain consistent with the existing `ambtenaar` bypass already implemented in `getAllGebruiksForAmbtenaar`/`getSingleGebruikForAmbtenaar`/`getKoppelingenGebruikByUuid` + +### Requirement: The offered-usage "afnemer" endpoint MUST require authentication explicitly, not implicitly (REQ-004) + +`AangebodenGebruikController::getGebruiksWhereAfnemer()` MUST explicitly reject an unauthenticated caller before invoking `AangebodenGebruikService::getGebruiksWhereAfnemer()`, rather than relying on the service's internal `getCurrentOrganisation()` returning `null` for anonymous sessions as the only safeguard. + +#### Scenario: Unauthenticated caller is explicitly rejected +- GIVEN no authenticated user session +- WHEN `GET /api/aangeboden-gebruik/afnemer` is called +- THEN the controller MUST return the empty-result envelope (or 401) without depending on `AangebodenGebruikService::getCurrentOrganisation()` resolving to `null` as the sole guard +- AND a test MUST assert this behaviour independent of `OrganisationService::getActiveOrganisation()`'s internal implementation + +#### Scenario: Authenticated caller with no active organisation gets the documented empty envelope +- GIVEN an authenticated user with no active organisation set +- WHEN `GET /api/aangeboden-gebruik/afnemer` is called +- THEN the response MUST be the "no current organization available" empty envelope +- AND no cross-organisation data MUST be returned + +### Requirement: Deelname and afnemer relationship reads remain unaffected (REQ-005) + +This change MUST NOT restrict the existing, correct relationship-based reads: an organisation MUST continue to see gebruik/koppeling objects where it is the `afnemer` (`getGebruiksWhereAfnemer`) or a `deelnemer` (`getGebruiksWhereDeelnemers`, per `deelnames-gebruik`), scoped to its own active organisation UUID exactly as today. + +#### Scenario: Own organisation's afnemer view is preserved +- GIVEN an authenticated user whose active organisation A is the afnemer on 3 offered gebruik records +- WHEN the user requests `GET /api/aangeboden-gebruik/afnemer` +- THEN all 3 records MUST be returned +- AND this behaviour MUST be unchanged from before this capability was added + +#### Scenario: Own organisation's deelnemer view is preserved +- GIVEN an authenticated user whose active organisation A appears in the `deelnemers` array of 2 gebruiksobjecten owned by other organisations +- WHEN the user requests `GET /api/aangeboden-gebruik/deelnemers` +- THEN both records MUST be returned +- AND this MUST remain consistent with `deelnames-gebruik`'s existing RBAC-disabled, `deelnemers`-filtered query behaviour + +### Requirement: Contract reads MUST deny non-counterparty cross-organisation access via the OpenRegister schema RBAC rule (REQ-006) + +Because contract CRUD runs entirely through the OpenRegister object store (ADR-022, `contract-administration`), contract read visibility MUST be governed by the `contract` schema's RBAC read rule denying any caller whose active organisation is neither the contract's owning organisation, the `admin` group, nor `ambtenaar`. If verification finds the deployed rule does not deny this case, the schema's RBAC read rule in `lib/Settings/softwarecatalogus_register.json` MUST be corrected as part of this change. + +#### Scenario: Vendor cannot read another organisation's contract +- GIVEN a contract owned by municipality A, and a user in the `aanbod-beheerder` group whose active organisation is vendor V (not a counterparty on this contract) +- WHEN the user attempts to read the contract via the OpenRegister object API +- THEN the read MUST be denied (empty/404, governed by the schema RBAC rule) +- AND V MUST NOT receive any field of the contract object + +#### Scenario: Counterparty and owner retain contract read access +- GIVEN a contract owned by municipality A referencing vendor V as counterparty +- WHEN A or V (whichever the schema's counterparty rule recognises) reads the contract +- THEN the read MUST succeed +- AND `admin` and `ambtenaar` MUST also retain read access regardless of counterparty status + +### Requirement: Every route touching gebruik, koppeling, or contract objects MUST have a documented, tested authorization posture (REQ-007) + +Every entry in `appinfo/routes.php` whose controller method reads a `gebruik`, `koppeling`, or `contract` OpenRegister object MUST be enumerated with its current authorization guard (auth annotation, role check, relationship check, or "denied by OR schema RBAC") and MUST be covered by at least one automated test exercising both an allowed and a denied case, so future additions to this route surface cannot silently reintroduce a leak. + +#### Scenario: Audit table covers every gebruik/koppeling/contract route +- GIVEN `appinfo/routes.php` +- WHEN the routes touching the `gebruik`, `koppeling`, and `contract` schemas are enumerated (by controller/method cross-reference) +- THEN each route MUST appear in the audit with its documented authorization posture +- AND no such route MUST be left undocumented + +#### Scenario: Undocumented or unguarded route fails review +- GIVEN a route added to `appinfo/routes.php` after this capability lands that reads a gebruik, koppeling, or contract object +- WHEN it lacks both a documented authorization posture and a covering test +- THEN it MUST be treated as a spec violation of this requirement and blocked at review + +## Non-Functional Requirements + +- **Performance:** Adding the organisation-scoping filter to `gebruik-beheerder` reads MUST NOT change the query shape's complexity class — it reuses the same `getApplicationIds`-style pre-fetch pattern already used for `aanbod-beheerder`, not an additional per-record check. +- **Accessibility:** No UI surface is introduced or changed by this capability (server-side enforcement only). +- **Internationalization:** Any new user-facing error/empty-state text (e.g. the explicit auth-guard rejection message) MUST be provided in Dutch and English (ADR-005). + +## Acceptance Criteria + +- [ ] A vendor (`aanbod-beheerder`) user cannot enumerate another organisation's applicatielandschap, koppelingen, or contracts through any gebruik/koppeling/contract read endpoint +- [ ] A `gebruik-beheerder` user cannot read another organisation's gebruik data unless that organisation shared it via afnemer/deelname +- [ ] `getGebruiksWhereAfnemer` explicitly rejects unauthenticated callers rather than relying on implicit downstream null-handling +- [ ] The `contract` schema RBAC read rule is verified (and corrected if necessary) to deny non-counterparty cross-organisation reads +- [ ] Every gebruik/koppeling/contract route in `appinfo/routes.php` is enumerated with a documented, tested authorization posture +- [ ] Every requirement above has at least one negative (denied-access) PHPUnit test, per ADR-009 and the hydra `security-change-has-tests` gate + +## Notes + +- This capability intentionally does not restate `aangeboden-gebruik-api`'s or `deelnames-gebruik`'s own requirements — it adds the missing deny-before-grant guarantees and closes the two gaps found in `discovery.md`, leaving their already-correct behaviour (afnemer/deelnemer relationship scoping) as-is and covered by REQ-005's regression scenarios. +- REQ-003 (closing the `gebruik-beheerder` global-read gap) is a behaviour change for an existing, already-provisioned NC group. It is included here per the context brief's explicit framing ("hides ... from vendor-role users **and from other organisations**") and the fail-closed design constraint, but is flagged in the change's `DEFERRED_QUESTIONS` for explicit confirmation before implementation, since it changes already-shipped behaviour beyond the vendor-specific ask. +- Related: `openspec/specs/aangeboden-gebruik-api`, `openspec/specs/deelnames-gebruik`, `openspec/specs/gebruik-services`, `openspec/specs/open-data-publishing`, `openspec/specs/contract-administration`, `openspec/specs/softwarecatalog-adopt-or-abstractions` (tenant context / `X-OpenRegister-Organisation`). Does not conflict with `openspec/changes/organisation-parent-hierarchy-rbac-fix` (organisation creation parent-linkage — a different concern from read-time visibility). diff --git a/openspec/changes/vendor-visibility-rbac/tasks.md b/openspec/changes/vendor-visibility-rbac/tasks.md new file mode 100644 index 00000000..49454619 --- /dev/null +++ b/openspec/changes/vendor-visibility-rbac/tasks.md @@ -0,0 +1,86 @@ +# Tasks: vendor-visibility-rbac + +## Implementation Tasks + +### Task 1: Explicit auth guard on the offered-usage afnemer endpoint +- **spec_ref**: `openspec/changes/vendor-visibility-rbac/specs/vendor-visibility-rbac/spec.md#req-004` +- **files**: `lib/Controller/AangebodenGebruikController.php` +- **acceptance_criteria**: + - GIVEN no authenticated user session WHEN `GET /api/aangeboden-gebruik/afnemer` is called THEN the controller explicitly rejects the call (empty envelope or 401) before `AangebodenGebruikService::getGebruiksWhereAfnemer()` is invoked + - GIVEN an authenticated user with no active organisation WHEN the same endpoint is called THEN the documented empty envelope is returned +- [ ] Implement +- [ ] Test + +### Task 2: Scope `gebruik-beheerder` reads to the caller's own organisation +- **spec_ref**: `openspec/changes/vendor-visibility-rbac/specs/vendor-visibility-rbac/spec.md#req-003` +- **files**: `lib/Controller/GebruikController.php` +- **acceptance_criteria**: + - GIVEN a `gebruik-beheerder` user whose active organisation is A, and municipality B owns unrelated gebruik records WHEN the user calls `GET /api/gebruik` THEN no record owned by B is returned + - GIVEN the same user WHEN A owns 12 gebruik records THEN all 12 are still returned unchanged + - GIVEN an `ambtenaar` (with or without `gebruik-beheerder`) WHEN the same endpoint is called THEN the existing unrestricted read is preserved +- [ ] Implement +- [ ] Test + +### Task 3: Lock in vendor (`aanbod-beheerder`) scoping with negative regression tests +- **spec_ref**: `openspec/changes/vendor-visibility-rbac/specs/vendor-visibility-rbac/spec.md#req-002` +- **files**: `tests/Unit/Controller/GebruikControllerTest.php`, `tests/Unit/Service/AangebodenGebruikServiceTest.php` +- **acceptance_criteria**: + - GIVEN a vendor V offering module M WHEN V requests `GET /api/gebruik` THEN only V's own module's gebruik records are returned + - GIVEN vendor V and unrelated municipality G WHEN V requests koppelingen/gebruik for a UUID identifying G via `GET /api/koppelingen-gebruik/{uuid}` THEN the empty envelope is returned and no data belonging to G is present + - GIVEN a vendor offering zero applications WHEN it requests `GET /api/gebruik` THEN the empty envelope is returned without an unscoped OpenRegister search +- [ ] Implement +- [ ] Test + +### Task 4: Lock in afnemer/deelnemer relationship reads with regression tests +- **spec_ref**: `openspec/changes/vendor-visibility-rbac/specs/vendor-visibility-rbac/spec.md#req-005` +- **files**: `tests/Unit/Controller/AangebodenGebruikControllerTest.php`, `tests/Unit/Service/AangebodenGebruikServiceTest.php` +- **acceptance_criteria**: + - GIVEN an organisation A that is afnemer on 3 offered gebruik records WHEN A calls `GET /api/aangeboden-gebruik/afnemer` THEN all 3 are returned unchanged from current behaviour + - GIVEN organisation A appears as deelnemer in 2 gebruiksobjecten owned by other organisations WHEN A calls `GET /api/aangeboden-gebruik/deelnemers` THEN both are returned unchanged from current behaviour +- [ ] Implement +- [ ] Test + +### Task 5: Verify (and if needed fix) the contract schema RBAC read rule +- **spec_ref**: `openspec/changes/vendor-visibility-rbac/specs/vendor-visibility-rbac/spec.md#req-006` +- **files**: `lib/Settings/softwarecatalogus_register.json`, `tests/Integration/ContractRbacTest.php` +- **acceptance_criteria**: + - GIVEN a contract owned by municipality A WHEN a vendor V that is not a counterparty attempts to read it via the OpenRegister object API THEN the read is denied + - GIVEN the same contract WHEN A, its counterparty, `admin`, or `ambtenaar` reads it THEN the read succeeds + - IF the deployed schema RBAC rule does not already deny the first case THEN the rule in `softwarecatalogus_register.json` is corrected as part of this task +- [ ] Implement +- [ ] Test + +### Task 6: Leak-path audit of gebruik/koppeling/contract routes +- **spec_ref**: `openspec/changes/vendor-visibility-rbac/specs/vendor-visibility-rbac/spec.md#req-007` +- **files**: `docs/security/vendor-visibility-rbac.md`, `appinfo/routes.php` (audit only — no route shape changes expected) +- **acceptance_criteria**: + - GIVEN `appinfo/routes.php` WHEN every route whose controller method reads a gebruik, koppeling, or contract object is enumerated THEN each appears in an audit table with its authorization posture and the test(s) that cover it + - GIVEN the audit table WHEN cross-checked against Tasks 1-5 THEN every route's posture matches an implemented guard or an explicit, justified exception (e.g. OR schema RBAC) +- [ ] Implement +- [ ] Test + +### Task 7: Deny-before-grant ordering guard on every RBAC-bypassing read path +- **spec_ref**: `openspec/changes/vendor-visibility-rbac/specs/vendor-visibility-rbac/spec.md#req-001` +- **files**: `lib/Controller/AangebodenGebruikController.php`, `lib/Controller/GebruikController.php`, `lib/Service/AangebodenGebruikService.php` +- **acceptance_criteria**: + - GIVEN a caller who fails the visibility-matrix resolution for a target object WHEN the request is processed THEN the deny branch returns before any `_rbac: false` query is built + - GIVEN role/relationship resolution throws (e.g. `OrganisationService` unavailable) WHEN a gebruik/koppeling/contract read is requested THEN the response is the empty envelope or a 5xx, never another organisation's data +- [ ] Implement +- [ ] Test + +### Task 8: i18n strings for new/changed authorization responses +- **spec_ref**: `openspec/changes/vendor-visibility-rbac/specs/vendor-visibility-rbac/spec.md#non-functional-requirements` +- **files**: `l10n/nl.json`, `l10n/en.json` (or the app's existing translation source files) +- **acceptance_criteria**: + - GIVEN the explicit auth-guard rejection and any new denied-access user-facing text introduced by Tasks 1-2 WHEN the UI renders them THEN both Dutch (`nl_NL`) and English (`en_US`) strings are present +- [ ] Implement +- [ ] Test + +## Quality checklist + +- All new/changed business logic covered by PHPUnit unit tests (`tests/Unit/`), run via `docker exec -w /var/www/html/custom_apps/softwarecatalog nextcloud php vendor/bin/phpunit -c phpunit-unit.xml` — minimum 75% coverage on the changed files (ADR-009) +- Every negative (denied-access) scenario in the spec has a corresponding PHPUnit test — required by the hydra `security-change-has-tests` gate for this security change +- New/changed API endpoint behaviour covered by Newman/Postman collection updates (`getGebruiksWhereAfnemer`, `GET /api/gebruik`) reflecting the tightened responses +- No UI changes are introduced by this capability (server-side enforcement only), so no Playwright browser test task or feature screenshot is required (ADR-010 N/A — justification: enforcement-only backend change, no new/changed UI surface); the routes/security posture is documented instead in `docs/security/vendor-visibility-rbac.md` per Task 6 +- All tests pass (`composer test:all` via container, per project convention; not the theatre `composer test:all` alias — invoke phpunit directly as shown above) +- `openspec validate --change vendor-visibility-rbac` passes diff --git a/openspec/changes/vendor-visibility-rbac/test-plan.md b/openspec/changes/vendor-visibility-rbac/test-plan.md new file mode 100644 index 00000000..40a181af --- /dev/null +++ b/openspec/changes/vendor-visibility-rbac/test-plan.md @@ -0,0 +1,151 @@ +# Test Plan: vendor-visibility-rbac + +## Test Cases + +### TC-1: Deny check short-circuits before the bypass query is built +- **spec_ref**: `openspec/changes/vendor-visibility-rbac/specs/vendor-visibility-rbac/spec.md#req-001` +- **type**: security +- **persona**: n/a (backend enforcement) +- **preconditions**: A caller whose role/relationship resolution fails the visibility matrix for a given gebruik object +- **steps**: Call the endpoint; assert (via mock/spy on `ObjectService`) that `searchObjectsPaginated`/`searchObjects`/`find` with `_rbac: false` is never invoked +- **expected result**: The deny branch returns before any bypass query is issued; no cross-org data present +- **test command**: PHPUnit unit test (mock ObjectService call count = 0 on the deny path) + +### TC-2: Resolution exception fails closed +- **spec_ref**: `openspec/changes/vendor-visibility-rbac/specs/vendor-visibility-rbac/spec.md#req-001` +- **type**: security +- **preconditions**: `OrganisationService` throws when resolving the active organisation +- **steps**: Request a gebruik/koppeling/contract read +- **expected result**: Empty envelope or 5xx; never another organisation's object data +- **test command**: PHPUnit unit test (mock throws, assert response shape + no leaked data) + +### TC-3: Vendor sees only their own product's usage +- **spec_ref**: `openspec/changes/vendor-visibility-rbac/specs/vendor-visibility-rbac/spec.md#req-002` +- **type**: api +- **persona**: Mark Visser (MKB Software Vendor) +- **preconditions**: Vendor V is `aanbieder` on module M; vendor V has active organisation set +- **steps**: `GET /api/gebruik` as V +- **expected result**: Response contains only gebruik records whose `module` belongs to V +- **test command**: /test-api (Newman collection) + PHPUnit + +### TC-4: Vendor denied cross-organisation applicatielandschap read +- **spec_ref**: `openspec/changes/vendor-visibility-rbac/specs/vendor-visibility-rbac/spec.md#req-002` +- **type**: security +- **persona**: Mark Visser (MKB Software Vendor) +- **preconditions**: Vendor V does not own or offer to municipality G +- **steps**: `GET /api/koppelingen-gebruik/{G's uuid}` as V +- **expected result**: Empty envelope; zero objects belonging to G present in the response body +- **test command**: /test-security + PHPUnit negative test + +### TC-5: Vendor with zero offered applications gets empty, not unscoped, result +- **spec_ref**: `openspec/changes/vendor-visibility-rbac/specs/vendor-visibility-rbac/spec.md#req-002` +- **type**: security +- **preconditions**: Vendor V's `getApplicationIds` returns `[]` +- **steps**: `GET /api/gebruik` as V +- **expected result**: Empty envelope; underlying OpenRegister search not executed without a module filter +- **test command**: PHPUnit unit test (assert `getGebruiken` never called with an unfiltered `module`) + +### TC-6: Municipality user denied another municipality's landscape +- **spec_ref**: `openspec/changes/vendor-visibility-rbac/specs/vendor-visibility-rbac/spec.md#req-003` +- **type**: security +- **persona**: Noor Yilmaz (Municipal CISO / Functional Admin) +- **preconditions**: `gebruik-beheerder` user, active org = municipality A; municipality B owns unrelated gebruik records +- **steps**: `GET /api/gebruik` as the A user +- **expected result**: No gebruik record owned by B present in the response +- **test command**: /test-security + PHPUnit negative test (this is the primary regression test for discovery.md finding 2) + +### TC-7: Municipality user still sees own organisation's full gebruik set +- **spec_ref**: `openspec/changes/vendor-visibility-rbac/specs/vendor-visibility-rbac/spec.md#req-003` +- **type**: regression +- **persona**: Noor Yilmaz (Municipal CISO / Functional Admin) +- **preconditions**: `gebruik-beheerder` user, active org = municipality A, which owns 12 gebruik records +- **steps**: `GET /api/gebruik` as the A user +- **expected result**: All 12 records returned; pagination/filtering unchanged +- **test command**: /test-api + PHPUnit + +### TC-8: `ambtenaar` retains unrestricted read +- **spec_ref**: `openspec/changes/vendor-visibility-rbac/specs/vendor-visibility-rbac/spec.md#req-003` +- **type**: regression +- **preconditions**: User in `ambtenaar` group +- **steps**: `GET /api/gebruik` as the ambtenaar user +- **expected result**: Response is not organisation-restricted, consistent with existing ambtenaar bypass paths +- **test command**: PHPUnit regression test + +### TC-9: Unauthenticated caller explicitly rejected on afnemer endpoint +- **spec_ref**: `openspec/changes/vendor-visibility-rbac/specs/vendor-visibility-rbac/spec.md#req-004` +- **type**: security +- **preconditions**: No authenticated session +- **steps**: `GET /api/aangeboden-gebruik/afnemer` with no session cookie +- **expected result**: Explicit rejection (empty envelope/401) at the controller, independent of `getCurrentOrganisation()`'s internal null-handling +- **test command**: /test-security + PHPUnit (asserts controller-level guard, not just service behaviour) + +### TC-10: Authenticated caller with no active organisation gets documented empty envelope +- **spec_ref**: `openspec/changes/vendor-visibility-rbac/specs/vendor-visibility-rbac/spec.md#req-004` +- **type**: api +- **preconditions**: Authenticated user, no active organisation set +- **steps**: `GET /api/aangeboden-gebruik/afnemer` +- **expected result**: "No current organization available" empty envelope; no cross-org data +- **test command**: PHPUnit + +### TC-11: Own organisation's afnemer view preserved +- **spec_ref**: `openspec/changes/vendor-visibility-rbac/specs/vendor-visibility-rbac/spec.md#req-005` +- **type**: regression +- **preconditions**: Active org A is afnemer on 3 offered gebruik records +- **steps**: `GET /api/aangeboden-gebruik/afnemer` as A +- **expected result**: All 3 records returned, unchanged from pre-change behaviour +- **test command**: /test-regression + PHPUnit + +### TC-12: Own organisation's deelnemer view preserved +- **spec_ref**: `openspec/changes/vendor-visibility-rbac/specs/vendor-visibility-rbac/spec.md#req-005` +- **type**: regression +- **preconditions**: Active org A appears in `deelnemers` of 2 gebruiksobjecten owned by other organisations +- **steps**: `GET /api/aangeboden-gebruik/deelnemers` as A +- **expected result**: Both records returned, consistent with `deelnames-gebruik` +- **test command**: /test-regression + PHPUnit + +### TC-13: Vendor cannot read another organisation's contract +- **spec_ref**: `openspec/changes/vendor-visibility-rbac/specs/vendor-visibility-rbac/spec.md#req-006` +- **type**: security +- **persona**: Mark Visser (MKB Software Vendor) +- **preconditions**: Contract owned by municipality A; vendor V is not a counterparty +- **steps**: V reads the contract via the OpenRegister object API +- **expected result**: Read denied (empty/404) per the schema RBAC rule +- **test command**: /test-security (integration test against the deployed schema RBAC config) + +### TC-14: Counterparty and owner retain contract read access +- **spec_ref**: `openspec/changes/vendor-visibility-rbac/specs/vendor-visibility-rbac/spec.md#req-006` +- **type**: regression +- **preconditions**: Contract owned by municipality A, counterparty vendor V +- **steps**: A, V, `admin`, and `ambtenaar` each read the contract +- **expected result**: All four reads succeed +- **test command**: PHPUnit/integration regression test + +### TC-15: Audit table covers every gebruik/koppeling/contract route +- **spec_ref**: `openspec/changes/vendor-visibility-rbac/specs/vendor-visibility-rbac/spec.md#req-007` +- **type**: regression +- **preconditions**: `appinfo/routes.php` as of this change +- **steps**: Cross-reference every route touching `gebruik`/`koppeling`/`contract` schemas against `docs/security/vendor-visibility-rbac.md` +- **expected result**: Every such route appears with a documented, tested authorization posture; none undocumented +- **test command**: Manual review during PR + code-review checklist item + +### TC-16: Undocumented/unguarded future route flagged +- **spec_ref**: `openspec/changes/vendor-visibility-rbac/specs/vendor-visibility-rbac/spec.md#req-007` +- **type**: regression +- **preconditions**: A hypothetical new route reading gebruik/koppeling/contract objects, added without a documented posture or test +- **steps**: Code review against this requirement +- **expected result**: Flagged as a spec violation and blocked at review +- **test command**: Code review checklist (this requirement's acceptance criterion is enforced procedurally, not by an automated test) + +## Coverage Summary +- REQ-001 (deny-before-grant ordering): covered — TC-1, TC-2 +- REQ-002 (vendor scoping): covered — TC-3, TC-4, TC-5 +- REQ-003 (gebruik-beheerder scoping): covered — TC-6, TC-7, TC-8 +- REQ-004 (explicit auth guard): covered — TC-9, TC-10 +- REQ-005 (afnemer/deelnemer preserved): covered — TC-11, TC-12 +- REQ-006 (contract RBAC): covered — TC-13, TC-14 +- REQ-007 (route audit): covered — TC-15, TC-16 + +## Out of Scope +- UI-level Playwright/browser testing — this change has no UI surface (server-side enforcement only); see `tasks.md` Quality checklist for the ADR-010 N/A justification. +- Load/performance testing beyond the existing query-shape complexity — REQ-003's fix reuses the already-proven `getApplicationIds`-style pre-fetch pattern, so no new performance test is added; flagged in `design.md` Non-Functional Requirements instead. +- Testing the `open-data-publishing` anonymous surface itself — explicitly out of scope per `proposal.md`; only confirmed as unaffected via the existing anonymous-empty-envelope behaviour on `getGebruiken()`.