From be9ac5c33a10e9462acb633c14d0b18d17199293 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 27 Sep 2026 19:34:01 +0200 Subject: [PATCH 01/20] docs(openspec): contracts-expiry-and-owner, an expiring status and a responsible user per contract --- .../contracts-expiry-and-owner/.openspec.yaml | 2 + .../contracts-expiry-and-owner/design.md | 80 ++++++++++++ .../contracts-expiry-and-owner/proposal.md | 56 ++++++++ .../specs/contract-expiry-and-owner/spec.md | 123 ++++++++++++++++++ .../contracts-expiry-and-owner/tasks.md | 71 ++++++++++ 5 files changed, 332 insertions(+) create mode 100644 openspec/changes/contracts-expiry-and-owner/.openspec.yaml create mode 100644 openspec/changes/contracts-expiry-and-owner/design.md create mode 100644 openspec/changes/contracts-expiry-and-owner/proposal.md create mode 100644 openspec/changes/contracts-expiry-and-owner/specs/contract-expiry-and-owner/spec.md create mode 100644 openspec/changes/contracts-expiry-and-owner/tasks.md diff --git a/openspec/changes/contracts-expiry-and-owner/.openspec.yaml b/openspec/changes/contracts-expiry-and-owner/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/contracts-expiry-and-owner/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/contracts-expiry-and-owner/design.md b/openspec/changes/contracts-expiry-and-owner/design.md new file mode 100644 index 000000000..203c297dd --- /dev/null +++ b/openspec/changes/contracts-expiry-and-owner/design.md @@ -0,0 +1,80 @@ +# Design: contracts-expiry-and-owner + +Read at development 49e65cb4. + +## Where it fits + +| Part | File and line | What changes | +|---|---|---| +| Schema | `lib/Settings/softwarecatalogus_register.json:3250` `catalogContract` | `status` enum (`:3428`) gains `Expiring`; new property `responsibleUser`; lifecycle block (`:3531`) renamed to the English states; schema `version` (`:3270`, now 0.1.1) and register `info.version` (`:6`, now 2.5.0) move up | +| Notification rule | `lib/Settings/softwarecatalogus_register.json:3253` `contract-expiry` | one recipient added to `recipients` (`:3258`) | +| Service | `lib/Service/ContractStatusService.php` | `shouldExpire()` (`:77`) accepts Active and Expiring; new `shouldStartExpiring()` and `shouldReturnToActive()`; `expirePastContracts()` (`:114`) becomes one pass over Active and Expiring contracts | +| Job | `lib/BackgroundJob/ContractStatusJob.php:78` | unchanged call, it now logs three counts | +| Setting | `lib/Repair/InitializeSettings.php:103` `contract_expiry_window_days` | read by the service through `IAppConfig` | +| Page | `src/manifest.json:527` `Contracten` | columns (`:534`) gain `responsibleUser`; quick filters (`:542` to `:547`) split into Expiring and Expired | +| Page | `src/manifest.json:556` `ContractDetail` | no manifest change: the `ct-data` widget (`:566`) renders every schema property, `responsibleUser` included | +| Seed | `lib/Settings/stackiq_mock_register.json:7608` onward | demo contracts get an Expiring example and a `responsibleUser` | + +No new controller, route or store. The pages keep reading OpenRegister directly (ADR-022). + +## Decisions + +### D1. Expiring is a stored status, set by the daily job + +The archived change 2026-06-14-contract-administration (design decision 3) chose a query instead: "expiring soon" as a filter on `endDate`, never a status. That query was never built, and a query cannot be a status column value, a facet or a lifecycle state that another app reads. The matrix row asks for a status that moves on its own, so this change stores it. + +Rejected: a derived value computed in the browser. It would show in one view and nowhere else, not in the API, the export or the notification filter. + +### D2. The job re-evaluates Expiring every day, in both directions + +`ContractStatusService` gets one pure decision per move, like `shouldExpire()` today: + +| From | To | When | +|---|---|---| +| Active | Expiring | `endDate` is today or later and at most `contract_expiry_window_days` days away | +| Active or Expiring | Expired | `endDate` is before today | +| Expiring | Active | `endDate` is more than the window away, because someone extended the contract | + +The job never touches In negotiation, never moves a contract out of Expired, and skips a contract without a parseable end date, as `shouldExpire()` does now (`:84` to `:97`). One query fetches Active and Expiring contracts with the same 5000 ceiling as `:137`. + +Rejected: OpenRegister's automatic lifecycle transitions. They fire at the end of a write (`lib/Service/Lifecycle/AutoTransitionPass.php` in OpenRegister), not on a clock, so a date that passes without a save moves nothing. + +### D3. The lifecycle block uses the English states + +The block at `:3531` names Actief, Verlopen and In onderhandeling. The enum and every migrated row say Active, Expired and In negotiation. The register changelog 2.4.4 (`:7`) records the same bug on `organization` and why the schema version must move with it: OpenRegister's content check compares properties, required and authorization, never `configuration`, so a lifecycle-only edit never deploys. This change rewrites the block with `initial: In negotiation`, `final: [Expired]`, and the transitions `sign` (In negotiation to Active), `approach` (Active to Expiring), `extend` (Expiring to Active), `expire` (Active or Expiring to Expired) and `renegotiate` (Expired to In negotiation). + +### D4. The responsible person is a Nextcloud user id + +`responsibleUser` is a string with `referenceType: nextcloud-user`. The library's `CnFormDialog` renders that as a searchable Nextcloud user picker (`@conduction/nextcloud-vue` 2.57.1, `src/utils/schema.js:355` and `src/components/CnFormDialog/CnFormDialog.vue:217`), so no custom component is needed (ADR-012). + +Rejected: reusing `contactPersonUser`. It is a nested name and email (`:3398`), written for suppliers and colleagues outside Nextcloud. OpenRegister resolves a `field` recipient only when the value is an existing Nextcloud uid (`lib/Service/Notification/NotificationRecipientResolver.php:187` in OpenRegister), so an email string would be dropped without a word. + +Rejected: a group field for a team. The same resolver has no kind that reads a group id from a field. Named in the proposal's out of scope. + +## Declarative versus imperative + +- Notification: declarative. The recipient is one more entry, `{"kind": "field", "field": "responsibleUser"}`, in the existing `x-openregister-notifications` rule. Stackiq sends nothing itself (ADR-031). +- Lifecycle: declarative states and transitions in `x-openregister-lifecycle`, so a person can still move a contract by hand through OpenRegister's transition endpoint. +- The time-driven moves stay imperative in `ContractStatusJob`. OpenRegister has no clock-driven transition (D2), and the job already exists for the Active to Expired move. + +## Seed data + +`catalogContract` changes, so the demo descriptor gets matching rows. Values follow the English enum. + +| Field | Contract 1 | Contract 2 | Contract 3 | Contract 4 | +|---|---|---|---|---| +| `@self.slug` | contract-contract-1-1 | contract-contract-2-2 | contract-contract-3-3 | contract-expiring-4 | +| `contractNumber` | CON-2025-001 | CON-2024-017 | CON-2026-003 | CON-2023-042 | +| `contractType` | SLA | Licence | Maintenance | Licence | +| `startDate` | 2025-01-01 | 2024-03-01 | 2026-02-01 | 2023-07-01 | +| `endDate` | 2027-12-31 | 2026-03-01 | empty | 60 days after the seed date | +| `status` | Active | Expired | In negotiation | Expiring | +| `responsibleUser` | admin | admin | empty | admin | + +Contract 4 is new. The seed writer computes its end date from the import date, so the Expiring example stays inside the window. `admin` exists on every development and CI instance. + +## Risks + +- **A long window marks many contracts at once.** A window of 365 days on a large catalogue flips many rows in one night. The pass is bounded at 5000 rows and each save is logged, as today. +- **Existing Expired rows stay Expired.** The job never moves a contract out of Expired, so a contract that expired by mistake still needs a person to renegotiate it. +- **The rule may not fire yet.** Until `stackiq:ctr-expiry-alert` fixes the filter and the subject fields, the responsible user receives nothing. The scenarios that need a delivered notification are marked for the unit test that checks the declaration, not for a browser run. diff --git a/openspec/changes/contracts-expiry-and-owner/proposal.md b/openspec/changes/contracts-expiry-and-owner/proposal.md new file mode 100644 index 000000000..13e413847 --- /dev/null +++ b/openspec/changes/contracts-expiry-and-owner/proposal.md @@ -0,0 +1,56 @@ +--- +kind: code +depends_on: [] +--- + +# Contracts show when they are expiring and name who is responsible + +## Summary + +A contract in stackiq jumps from Active straight to Expired on the day its end date passes. Nobody sees it coming, and the only person named on a contract is a free-text name and email that no warning can reach. This change adds an Expiring status that the daily contract job sets inside the notice window, and a responsible Nextcloud user on each contract, so the expiry warning has someone to go to. + +## Why + +This change covers two matrix rows. + +- `stackiq:ctr-status`, "See each contract's status move from active to expiring to expired on its own." Stackiq rates itself partial: the daily job moves Active to Expired, but there is no expiring state in between. SAP LeanIX rates yes: "The contract fact sheet uses lifecycle phases to represent the current state of a contract: Plan, Phase In, Contract Start Date (active), Contract Notice Period, Contract End Date (expired)" (https://help.sap.com/docs/leanix/ea/contract-extension-to-meta-model). TOPdesk rates yes: "Status: Configurable drop-down showing the contract's lifecycle status, e.g. draft, active ... Reminder date" (https://docs.topdesk.com/en/creating-a-contract.html) and "The contract will terminate once the end date passes" (https://docs.topdesk.com/en/terminating-a-contract.html). +- `stackiq:ctr-contract-owner`, "Name the person or team responsible for a contract, so expiry warnings go to them." Demand: a GLPI feature request asks for contract assignees as notification recipients (https://github.com/glpi-project/roadmap/discussions/290). SAP LeanIX rates yes: automations "notify contract owners at key milestones" (https://help.sap.com/docs/leanix/ea/step-2-set-up-contract-lifecycle-automations). TOPdesk rates yes: "Operator The TOPdesk operator responsible for managing the contract ... Reminder date Date on which an operator should be reminded about the contract, e.g. ahead of expiry" (https://docs.topdesk.com/en/creating-a-contract.html) and "notify a manager that a contract will expire in a month" (https://docs.topdesk.com/en/events-that-trigger-actions.html). + +Both rows are partial and built. This change builds the missing half of each: the expiring state between active and expired, and an expiry warning that reaches the named person. + +## What stackiq has today + +Read at development 49e65cb4. + +- `lib/Service/ContractStatusService.php:77` `shouldExpire()` returns true only for status `Active` with a parseable `endDate` in the past. `:114` `expirePastContracts()` queries Active contracts (`:136`) and saves them as `Expired` (`:157`). +- `lib/BackgroundJob/ContractStatusJob.php:57` runs that pass once a day. It is registered in `appinfo/info.xml:99`. +- `lib/Settings/softwarecatalogus_register.json:3428` `catalogContract.status` has the enum Active, Expired, In negotiation. There is no expiring value. +- `lib/Settings/softwarecatalogus_register.json:3531` the schema's `x-openregister-lifecycle` still names the Dutch states Actief, Verlopen and In onderhandeling, while the enum and the stored rows are English. A lifecycle whose states match no row offers no transition. +- `lib/Settings/softwarecatalogus_register.json:3398` `contactPersonUser` is a nested object with a name and an email. It is not a Nextcloud user, so no notification recipient can resolve it. +- `lib/Settings/softwarecatalogus_register.json:3253` declares the `contract-expiry` notification. Its recipients (`:3258`) are the `software-catalog-admins` group and users with manage rights on the record. The person responsible is not among them. +- `lib/Repair/InitializeSettings.php:103` seeds `contract_expiry_window_days` with 90. No code reads it. +- `src/manifest.json:545` the Contracts page quick filter "Expiring / expired" filters `status` equal to `Expired` only, so it never shows a contract that is about to expire. + +## What this change builds + +- A fourth status value, `Expiring`, on `catalogContract.status`. +- The daily contract job moves an Active contract to Expiring when its end date falls inside the notice window, moves an Expiring contract to Expired once the end date has passed, and moves an Expiring contract back to Active when someone extends its end date past the window. +- The notice window comes from `contract_expiry_window_days`, which the job starts reading. +- The schema's lifecycle block names the English states the enum and the rows use, with Expiring added. +- The Contracts page gets separate Expiring and Expired quick filters. +- A `responsibleUser` property on `catalogContract`: a Nextcloud user picked in the contract form, shown on the contract detail page and as a column on the Contracts page. +- The `contract-expiry` notification rule gains a recipient that reads `responsibleUser`, so the responsible person gets the warning together with the administrators. + +## Out of scope + +- Making the `contract-expiry` rule fire. Its filter compares `status` with `Actief` and its subject uses the old Dutch field names. That is the pending row `stackiq:ctr-expiry-alert`, marked specified, and it is fixed there, not here. This change only adds a recipient to the rule. +- Dispatching scheduled notifications. OpenRegister owns the notification engine (ADR-031) and resolves the `field` recipient kind. +- A responsible team. OpenRegister resolves a `field` recipient only as a single user id (`NotificationRecipientResolver.php:187` in OpenRegister). A group held in a contract field needs a new recipient kind in OpenRegister first. +- Renewal chains, obligations and spend. Shillinq owns the contract lifecycle beyond the catalogue view (ADR-066), as the archived change 2026-06-14-contract-administration decided. +- An admin screen for the notice window. The setting is changed with `occ config:app:set` until a settings section asks for it. + +## Risks + +- A stored status can drift from the end date when someone edits the date. The job re-evaluates Expiring contracts every day, so the drift lasts at most one day. +- Adding an enum value and a property changes the schema. The schema version and the register version must both move up, or the import skips the change (register changelog 2.4.4, `lib/Settings/softwarecatalogus_register.json:7`). +- Contracts saved as Expiring by the job are visible to every reader of the contract. That is the intent, but a Nextcloud admin who filters on Active in a script sees fewer contracts than before. diff --git a/openspec/changes/contracts-expiry-and-owner/specs/contract-expiry-and-owner/spec.md b/openspec/changes/contracts-expiry-and-owner/specs/contract-expiry-and-owner/spec.md new file mode 100644 index 000000000..3d2dc8401 --- /dev/null +++ b/openspec/changes/contracts-expiry-and-owner/specs/contract-expiry-and-owner/spec.md @@ -0,0 +1,123 @@ +# contract-expiry-and-owner specification + +**Status**: proposed +**Scope**: stackiq +**OpenSpec changes**: +- contracts-expiry-and-owner + +## Purpose + +A municipal information manager sees a contract move from Active to Expiring to Expired without anyone touching it, and every contract names one Nextcloud user who is responsible for it. The expiry warning that OpenRegister sends for a contract reaches that person as well as the catalogue administrators. + +## ADDED Requirements + +### Requirement: REQ-CEO-001 The contract status MUST include Expiring, set by the daily contract job inside the notice window + +`catalogContract.status` SHALL accept `Expiring` next to Active, Expired and In negotiation. Once a day `ContractStatusJob` SHALL move an Active contract to Expiring when its `endDate` is today or later and at most `contract_expiry_window_days` days away. The window SHALL default to 90 days when the setting is empty or not a positive number. + +#### Scenario: An active contract inside the window becomes expiring +@e2e exclude The job runs on the Nextcloud cron, not in a browser; tests/Unit/Service/ContractStatusServiceTest.php covers shouldStartExpiring() and the pass. + +- **GIVEN** an Active contract whose `endDate` is 30 days from now and `contract_expiry_window_days` is 90 +- **WHEN** `ContractStatusJob` runs +- **THEN** the contract's `status` SHALL be `Expiring` +- **AND** the job's log line SHALL count it among the contracts that started expiring + +#### Scenario: An active contract outside the window stays active +@e2e exclude Covered by tests/Unit/Service/ContractStatusServiceTest.php, which asserts shouldStartExpiring() is false beyond the window. + +- **GIVEN** an Active contract whose `endDate` is 200 days from now and the window is 90 days +- **WHEN** `ContractStatusJob` runs +- **THEN** the contract's `status` SHALL still be `Active` + +#### Scenario: A contract in negotiation is never touched +@e2e exclude Covered by tests/Unit/Service/ContractStatusServiceTest.php for every move. + +- **GIVEN** a contract In negotiation whose `endDate` is 10 days from now +- **WHEN** `ContractStatusJob` runs +- **THEN** the contract's `status` SHALL still be `In negotiation` + +### Requirement: REQ-CEO-002 The daily job SHALL expire an expiring contract after its end date and SHALL return it to active when its end date moves past the window + +`ContractStatusJob` SHALL move an Active or Expiring contract whose `endDate` is before today to Expired. It SHALL move an Expiring contract whose `endDate` is now more than the window away back to Active. It SHALL NOT move a contract out of Expired, and it SHALL skip a contract without a parseable `endDate`. + +#### Scenario: An expiring contract expires after its end date +@e2e exclude The job runs on the Nextcloud cron; tests/Unit/Service/ContractStatusServiceTest.php covers shouldExpire() for both source states. + +- **GIVEN** an Expiring contract whose `endDate` was yesterday +- **WHEN** `ContractStatusJob` runs +- **THEN** the contract's `status` SHALL be `Expired` + +#### Scenario: An extended contract returns to active +@e2e exclude Covered by tests/Unit/Service/ContractStatusServiceTest.php, which asserts shouldReturnToActive(). + +- **GIVEN** an Expiring contract, and an application owner has changed its `endDate` to two years from now +- **WHEN** `ContractStatusJob` runs +- **THEN** the contract's `status` SHALL be `Active` + +#### Scenario: The lifecycle names the states the rows hold +@e2e exclude A register declaration; tests/Unit/Settings/ContractLifecycleDeclarationTest.php asserts every lifecycle state is an enum value and the schema version moved up. + +- **GIVEN** `lib/Settings/softwarecatalogus_register.json` +- **WHEN** the `catalogContract` lifecycle block is read +- **THEN** every state in `initial`, `final`, `from` and `to` SHALL be a value of the `status` enum +- **AND** a transition SHALL exist from Active to Expiring, from Expiring to Active, and from Expiring to Expired + +### Requirement: REQ-CEO-003 The Contracts page MUST let a user filter on expiring and on expired contracts separately + +The `Contracten` page at `/contracten` SHALL offer an Expiring quick filter on `status` equal to `Expiring` and an Expired quick filter on `status` equal to `Expired`, in place of the single "Expiring / expired" filter. Both labels SHALL exist in English and Dutch. + +#### Scenario: An information manager lists the contracts about to expire +@e2e tests/e2e/spec-coverage/contract-expiry-and-owner.spec.ts + +- **GIVEN** a municipal information manager, and the catalogue holds one Expiring and one Expired contract +- **WHEN** they open `/contracten` and choose the Expiring quick filter +- **THEN** the list SHALL show the Expiring contract +- **AND** it SHALL NOT show the Expired contract + +#### Scenario: The status column shows the expiring state +@e2e tests/e2e/spec-coverage/contract-expiry-and-owner.spec.ts + +- **GIVEN** an Expiring contract +- **WHEN** a municipal information manager opens `/contracten` +- **THEN** its status cell SHALL read Expiring, or Verlopend on a Dutch instance + +### Requirement: REQ-CEO-004 A contract SHALL name one responsible Nextcloud user, picked from the users of the instance + +`catalogContract` SHALL carry `responsibleUser`, a string holding a Nextcloud user id with `referenceType` `nextcloud-user`. The contract form SHALL offer it as a user picker. The `ContractDetail` page SHALL show it, and the `Contracten` page SHALL show it as a column. + +#### Scenario: An application owner names the responsible person +@e2e tests/e2e/spec-coverage/contract-expiry-and-owner.spec.ts + +- **GIVEN** an application owner editing a contract on `/contracten/:id` +- **WHEN** they open the edit form, pick a colleague in the Responsible user field and save +- **THEN** the contract detail SHALL show that colleague as the responsible user +- **AND** the Contracts page SHALL show the colleague in the Responsible user column + +#### Scenario: A free-text value is not accepted +@e2e exclude The picker only offers existing users; tests/vitest/contractResponsibleUser.spec.js asserts the schema property resolves to the user widget. + +- **GIVEN** the contract form +- **WHEN** an application owner types a name that matches no Nextcloud user +- **THEN** the form SHALL offer no value to pick +- **AND** `responsibleUser` SHALL stay empty + +### Requirement: REQ-CEO-005 The contract expiry warning MUST include the responsible user among its recipients + +The `contract-expiry` rule in `x-openregister-notifications` on `catalogContract` SHALL list a recipient of kind `field` reading `responsibleUser`, next to the existing `software-catalog-admins` group and manage rights recipients. When the rule fires for a contract, OpenRegister SHALL resolve that recipient to the named user. + +#### Scenario: The declaration names the responsible user as a recipient +@e2e exclude A register declaration; tests/Unit/Settings/ContractLifecycleDeclarationTest.php asserts the field recipient on the contract-expiry rule and that the field exists on the schema. + +- **GIVEN** `lib/Settings/softwarecatalogus_register.json` +- **WHEN** the `contract-expiry` rule on `catalogContract` is read +- **THEN** its `recipients` SHALL contain `{"kind": "field", "field": "responsibleUser"}` +- **AND** `responsibleUser` SHALL be a property of `catalogContract` + +#### Scenario: The responsible person receives the warning +@e2e exclude Delivery needs the rule fix of stackiq:ctr-expiry-alert and OpenRegister's scheduled notification job; OpenRegister's NotificationRecipientResolver tests cover the field kind, and this app asserts only the declaration. + +- **GIVEN** an Expiring contract whose `responsibleUser` is a Nextcloud user, and the `contract-expiry` rule fires for it +- **WHEN** OpenRegister resolves the recipients +- **THEN** the responsible user SHALL receive the warning in Nextcloud notifications +- **AND** the members of `software-catalog-admins` SHALL still receive it diff --git a/openspec/changes/contracts-expiry-and-owner/tasks.md b/openspec/changes/contracts-expiry-and-owner/tasks.md new file mode 100644 index 000000000..9bdda2c9c --- /dev/null +++ b/openspec/changes/contracts-expiry-and-owner/tasks.md @@ -0,0 +1,71 @@ +# Tasks: contracts-expiry-and-owner + +## Implementation tasks + +### Task 1: Add Expiring, responsibleUser and the English lifecycle to the contract schema +- **spec_ref**: openspec/changes/contracts-expiry-and-owner/specs/contract-expiry-and-owner/spec.md#requirement-req-ceo-002-the-daily-job-shall-expire-an-expiring-contract-after-its-end-date-and-shall-return-it-to-active-when-its-end-date-moves-past-the-window +- **files**: `lib/Settings/softwarecatalogus_register.json`, `tests/Unit/Settings/ContractLifecycleDeclarationTest.php` +- **acceptance_criteria**: + - GIVEN the register WHEN `catalogContract.status` is read THEN its enum is Active, Expiring, Expired, In negotiation + - GIVEN the register WHEN the `catalogContract` lifecycle is read THEN every state is an enum value and approach, extend and expire exist + - GIVEN the register WHEN `responsibleUser` is read THEN it is a string with `referenceType` `nextcloud-user` + - GIVEN the register WHEN the versions are read THEN the `catalogContract` version and `info.version` are higher than 0.1.1 and 2.5.0, with a changelog line +- [ ] Implement +- [ ] Test (PHPUnit tests/Unit/Settings/ContractLifecycleDeclarationTest.php) + +### Task 2: Move contracts into and out of Expiring in the daily job +- **spec_ref**: openspec/changes/contracts-expiry-and-owner/specs/contract-expiry-and-owner/spec.md#requirement-req-ceo-001-the-contract-status-must-include-expiring-set-by-the-daily-contract-job-inside-the-notice-window +- **files**: `lib/Service/ContractStatusService.php`, `lib/BackgroundJob/ContractStatusJob.php`, `tests/Unit/Service/ContractStatusServiceTest.php` +- **acceptance_criteria**: + - GIVEN an Active contract ending in 30 days and a 90 day window WHEN the pass runs THEN it is saved as Expiring + - GIVEN an Expiring contract that ended yesterday WHEN the pass runs THEN it is saved as Expired + - GIVEN an Expiring contract extended by two years WHEN the pass runs THEN it is saved as Active + - GIVEN a contract In negotiation or Expired WHEN the pass runs THEN it is not saved + - GIVEN `contract_expiry_window_days` is empty or zero WHEN the pass runs THEN it uses 90 +- [ ] Implement +- [ ] Test (PHPUnit tests/Unit/Service/ContractStatusServiceTest.php) + +### Task 3: Split the Contracts quick filter and show the responsible user +- **spec_ref**: openspec/changes/contracts-expiry-and-owner/specs/contract-expiry-and-owner/spec.md#requirement-req-ceo-003-the-contracts-page-must-let-a-user-filter-on-expiring-and-on-expired-contracts-separately +- **files**: `src/manifest.json`, `l10n/en.json`, `l10n/nl.json`, `tests/e2e/spec-coverage/contract-expiry-and-owner.spec.ts` +- **acceptance_criteria**: + - GIVEN the Contracten page WHEN it renders THEN it offers All, Active, Expiring, Expired and In negotiation quick filters + - GIVEN the Contracten page WHEN it renders THEN its columns include `responsibleUser` + - GIVEN a Dutch instance WHEN the page renders THEN the filter and column labels are Dutch +- [ ] Implement +- [ ] Test (Playwright tests/e2e/spec-coverage/contract-expiry-and-owner.spec.ts, and node tests/validate-manifest.js) + +### Task 4: Pick the responsible user in the contract form +- **spec_ref**: openspec/changes/contracts-expiry-and-owner/specs/contract-expiry-and-owner/spec.md#requirement-req-ceo-004-a-contract-shall-name-one-responsible-nextcloud-user-picked-from-the-users-of-the-instance +- **files**: `tests/vitest/contractResponsibleUser.spec.js`, `tests/e2e/spec-coverage/contract-expiry-and-owner.spec.ts` +- **acceptance_criteria**: + - GIVEN the `catalogContract` schema WHEN the library resolves the field widget for `responsibleUser` THEN it is `user` + - GIVEN an application owner on ContractDetail WHEN they pick a user and save THEN the detail shows that user +- [ ] Implement +- [ ] Test (vitest tests/vitest/contractResponsibleUser.spec.js and the Playwright spec above) + +### Task 5: Add the responsible user to the contract-expiry recipients +- **spec_ref**: openspec/changes/contracts-expiry-and-owner/specs/contract-expiry-and-owner/spec.md#requirement-req-ceo-005-the-contract-expiry-warning-must-include-the-responsible-user-among-its-recipients +- **files**: `lib/Settings/softwarecatalogus_register.json`, `tests/Unit/Settings/ContractLifecycleDeclarationTest.php` +- **acceptance_criteria**: + - GIVEN the `contract-expiry` rule WHEN its recipients are read THEN they include `{"kind": "field", "field": "responsibleUser"}`, the admins group and the manage rights recipient +- [ ] Implement +- [ ] Test (PHPUnit tests/Unit/Settings/ContractLifecycleDeclarationTest.php) + +### Task 6: Seed an expiring contract and document the feature +- **spec_ref**: openspec/changes/contracts-expiry-and-owner/specs/contract-expiry-and-owner/spec.md#requirement-req-ceo-001-the-contract-status-must-include-expiring-set-by-the-daily-contract-job-inside-the-notice-window +- **files**: `lib/Settings/stackiq_mock_register.json`, `docs/features/contract-expiry-and-owner.md` +- **acceptance_criteria**: + - GIVEN a fresh demo import WHEN the Contracts page opens THEN at least one contract reads Expiring and names a responsible user + - GIVEN the docs WHEN a reader opens the feature page THEN it explains the window setting and shows a screenshot of the Expiring filter +- [ ] Implement +- [ ] Test (Playwright tests/e2e/spec-coverage/contract-expiry-and-owner.spec.ts against the demo data) + +## Verification + +- `openspec validate contracts-expiry-and-owner --type change --strict` +- PHPUnit: tests/Unit/Service/ContractStatusServiceTest.php and tests/Unit/Settings/ContractLifecycleDeclarationTest.php +- vitest: tests/vitest/contractResponsibleUser.spec.js +- Playwright: tests/e2e/spec-coverage/contract-expiry-and-owner.spec.ts +- Docs in docs/features/contract-expiry-and-owner.md with a screenshot of the Contracts page (ADR-010) +- English and Dutch strings for Expiring, Expired, Responsible user in l10n/en.json and l10n/nl.json (ADR-005) From 1862c6bda01c8569a537e4359fe1bba9c8926c61 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 27 Sep 2026 19:35:22 +0200 Subject: [PATCH 02/20] docs(openspec): contracts-expiry-and-owner, guard quick filters against the enum --- openspec/changes/contracts-expiry-and-owner/tasks.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openspec/changes/contracts-expiry-and-owner/tasks.md b/openspec/changes/contracts-expiry-and-owner/tasks.md index 9bdda2c9c..fee2e16f6 100644 --- a/openspec/changes/contracts-expiry-and-owner/tasks.md +++ b/openspec/changes/contracts-expiry-and-owner/tasks.md @@ -27,13 +27,14 @@ ### Task 3: Split the Contracts quick filter and show the responsible user - **spec_ref**: openspec/changes/contracts-expiry-and-owner/specs/contract-expiry-and-owner/spec.md#requirement-req-ceo-003-the-contracts-page-must-let-a-user-filter-on-expiring-and-on-expired-contracts-separately -- **files**: `src/manifest.json`, `l10n/en.json`, `l10n/nl.json`, `tests/e2e/spec-coverage/contract-expiry-and-owner.spec.ts` +- **files**: `src/manifest.json`, `l10n/en.json`, `l10n/nl.json`, `tests/vitest/manifestFilterEnumParity.spec.js`, `tests/e2e/spec-coverage/contract-expiry-and-owner.spec.ts` - **acceptance_criteria**: - GIVEN the Contracten page WHEN it renders THEN it offers All, Active, Expiring, Expired and In negotiation quick filters - GIVEN the Contracten page WHEN it renders THEN its columns include `responsibleUser` - GIVEN a Dutch instance WHEN the page renders THEN the filter and column labels are Dutch + - GIVEN a quick filter whose value is not in the schema enum WHEN tests/vitest/manifestFilterEnumParity.spec.js runs THEN it fails, so quick filters get the same guard as \`config.filter\` - [ ] Implement -- [ ] Test (Playwright tests/e2e/spec-coverage/contract-expiry-and-owner.spec.ts, and node tests/validate-manifest.js) +- [ ] Test (Playwright tests/e2e/spec-coverage/contract-expiry-and-owner.spec.ts, vitest tests/vitest/manifestFilterEnumParity.spec.js, and node tests/validate-manifest.js) ### Task 4: Pick the responsible user in the contract form - **spec_ref**: openspec/changes/contracts-expiry-and-owner/specs/contract-expiry-and-owner/spec.md#requirement-req-ceo-004-a-contract-shall-name-one-responsible-nextcloud-user-picked-from-the-users-of-the-instance From cf9e35af850af120cdb7988c57aaec5bfe2af798 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 27 Sep 2026 19:35:33 +0200 Subject: [PATCH 03/20] docs(openspec): contracts-expiry-and-owner, fix an escaped backtick --- openspec/changes/contracts-expiry-and-owner/tasks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openspec/changes/contracts-expiry-and-owner/tasks.md b/openspec/changes/contracts-expiry-and-owner/tasks.md index fee2e16f6..982fc2753 100644 --- a/openspec/changes/contracts-expiry-and-owner/tasks.md +++ b/openspec/changes/contracts-expiry-and-owner/tasks.md @@ -32,7 +32,7 @@ - GIVEN the Contracten page WHEN it renders THEN it offers All, Active, Expiring, Expired and In negotiation quick filters - GIVEN the Contracten page WHEN it renders THEN its columns include `responsibleUser` - GIVEN a Dutch instance WHEN the page renders THEN the filter and column labels are Dutch - - GIVEN a quick filter whose value is not in the schema enum WHEN tests/vitest/manifestFilterEnumParity.spec.js runs THEN it fails, so quick filters get the same guard as \`config.filter\` + - GIVEN a quick filter whose value is not in the schema enum WHEN tests/vitest/manifestFilterEnumParity.spec.js runs THEN it fails, so quick filters get the same guard as `config.filter` - [ ] Implement - [ ] Test (Playwright tests/e2e/spec-coverage/contract-expiry-and-owner.spec.ts, vitest tests/vitest/manifestFilterEnumParity.spec.js, and node tests/validate-manifest.js) From 610e836881c622c8830e9db502dfaa677c413f64 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 27 Sep 2026 19:36:37 +0200 Subject: [PATCH 04/20] docs(openspec): contracts-expiry-and-owner, put appended schema parts in a register fragment --- openspec/changes/contracts-expiry-and-owner/design.md | 8 ++++++-- openspec/changes/contracts-expiry-and-owner/tasks.md | 8 ++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/openspec/changes/contracts-expiry-and-owner/design.md b/openspec/changes/contracts-expiry-and-owner/design.md index 203c297dd..d1c439a17 100644 --- a/openspec/changes/contracts-expiry-and-owner/design.md +++ b/openspec/changes/contracts-expiry-and-owner/design.md @@ -6,8 +6,8 @@ Read at development 49e65cb4. | Part | File and line | What changes | |---|---|---| -| Schema | `lib/Settings/softwarecatalogus_register.json:3250` `catalogContract` | `status` enum (`:3428`) gains `Expiring`; new property `responsibleUser`; lifecycle block (`:3531`) renamed to the English states; schema `version` (`:3270`, now 0.1.1) and register `info.version` (`:6`, now 2.5.0) move up | -| Notification rule | `lib/Settings/softwarecatalogus_register.json:3253` `contract-expiry` | one recipient added to `recipients` (`:3258`) | +| Schema | `lib/Settings/softwarecatalogus_register.json:3250` `catalogContract` | lifecycle block (`:3531`) renamed to the English states in the monolith; schema `version` (`:3270`, now 0.1.1) and register `info.version` (`:6`, now 2.5.0) move up | +| Fragment | new `lib/Settings/register.d/contracts-expiry-and-owner.json` (ADR-037) | `Expiring` appended to the `status` enum (`:3428`), the new property `responsibleUser`, and one recipient appended to the `contract-expiry` rule (`:3258`) | | Service | `lib/Service/ContractStatusService.php` | `shouldExpire()` (`:77`) accepts Active and Expiring; new `shouldStartExpiring()` and `shouldReturnToActive()`; `expirePastContracts()` (`:114`) becomes one pass over Active and Expiring contracts | | Job | `lib/BackgroundJob/ContractStatusJob.php:78` | unchanged call, it now logs three counts | | Setting | `lib/Repair/InitializeSettings.php:103` `contract_expiry_window_days` | read by the service through `IAppConfig` | @@ -19,6 +19,10 @@ No new controller, route or store. The pages keep reading OpenRegister directly ## Decisions +### D0. What goes in a fragment and what stays in the monolith + +`SettingsService::loadSettings()` deep-merges every `lib/Settings/register.d/*.json` into the register (`lib/Service/SettingsService.php:1653` to `:1680`). `deepMergeConfig()` (`:7338`) appends lists and only replaces the lists under `authorization` (`:7340`, `:7352`). An appended enum value and an appended recipient are what this change wants, so they go in the fragment. The lifecycle rename replaces values inside `final`, `from` and `to` lists, which an append cannot do, so that edit stays in the monolith together with the version bump. + ### D1. Expiring is a stored status, set by the daily job The archived change 2026-06-14-contract-administration (design decision 3) chose a query instead: "expiring soon" as a filter on `endDate`, never a status. That query was never built, and a query cannot be a status column value, a facet or a lifecycle state that another app reads. The matrix row asks for a status that moves on its own, so this change stores it. diff --git a/openspec/changes/contracts-expiry-and-owner/tasks.md b/openspec/changes/contracts-expiry-and-owner/tasks.md index 982fc2753..7d59116ef 100644 --- a/openspec/changes/contracts-expiry-and-owner/tasks.md +++ b/openspec/changes/contracts-expiry-and-owner/tasks.md @@ -4,9 +4,9 @@ ### Task 1: Add Expiring, responsibleUser and the English lifecycle to the contract schema - **spec_ref**: openspec/changes/contracts-expiry-and-owner/specs/contract-expiry-and-owner/spec.md#requirement-req-ceo-002-the-daily-job-shall-expire-an-expiring-contract-after-its-end-date-and-shall-return-it-to-active-when-its-end-date-moves-past-the-window -- **files**: `lib/Settings/softwarecatalogus_register.json`, `tests/Unit/Settings/ContractLifecycleDeclarationTest.php` +- **files**: `lib/Settings/softwarecatalogus_register.json`, `lib/Settings/register.d/contracts-expiry-and-owner.json`, `tests/Unit/Settings/ContractLifecycleDeclarationTest.php` - **acceptance_criteria**: - - GIVEN the register WHEN `catalogContract.status` is read THEN its enum is Active, Expiring, Expired, In negotiation + - GIVEN the merged register WHEN `catalogContract.status` is read THEN its enum is Active, Expiring, Expired, In negotiation - GIVEN the register WHEN the `catalogContract` lifecycle is read THEN every state is an enum value and approach, extend and expire exist - GIVEN the register WHEN `responsibleUser` is read THEN it is a string with `referenceType` `nextcloud-user` - GIVEN the register WHEN the versions are read THEN the `catalogContract` version and `info.version` are higher than 0.1.1 and 2.5.0, with a changelog line @@ -47,9 +47,9 @@ ### Task 5: Add the responsible user to the contract-expiry recipients - **spec_ref**: openspec/changes/contracts-expiry-and-owner/specs/contract-expiry-and-owner/spec.md#requirement-req-ceo-005-the-contract-expiry-warning-must-include-the-responsible-user-among-its-recipients -- **files**: `lib/Settings/softwarecatalogus_register.json`, `tests/Unit/Settings/ContractLifecycleDeclarationTest.php` +- **files**: `lib/Settings/register.d/contracts-expiry-and-owner.json`, `tests/Unit/Settings/ContractLifecycleDeclarationTest.php` - **acceptance_criteria**: - - GIVEN the `contract-expiry` rule WHEN its recipients are read THEN they include `{"kind": "field", "field": "responsibleUser"}`, the admins group and the manage rights recipient + - GIVEN the merged `contract-expiry` rule WHEN its recipients are read THEN they include `{"kind": "field", "field": "responsibleUser"}`, the admins group and the manage rights recipient - [ ] Implement - [ ] Test (PHPUnit tests/Unit/Settings/ContractLifecycleDeclarationTest.php) From 3f68d6e1a99a653da8fc14e0fa4abf8326a37e5a Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 27 Sep 2026 19:37:46 +0200 Subject: [PATCH 05/20] docs(openspec): contracts-licence-seats, licence metric and seats bought against in use --- .../contracts-licence-seats/.openspec.yaml | 2 + .../changes/contracts-licence-seats/design.md | 62 +++++++++++++++++ .../contracts-licence-seats/proposal.md | 47 +++++++++++++ .../specs/licence-seats/spec.md | 69 +++++++++++++++++++ .../changes/contracts-licence-seats/tasks.md | 62 +++++++++++++++++ 5 files changed, 242 insertions(+) create mode 100644 openspec/changes/contracts-licence-seats/.openspec.yaml create mode 100644 openspec/changes/contracts-licence-seats/design.md create mode 100644 openspec/changes/contracts-licence-seats/proposal.md create mode 100644 openspec/changes/contracts-licence-seats/specs/licence-seats/spec.md create mode 100644 openspec/changes/contracts-licence-seats/tasks.md diff --git a/openspec/changes/contracts-licence-seats/.openspec.yaml b/openspec/changes/contracts-licence-seats/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/contracts-licence-seats/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/contracts-licence-seats/design.md b/openspec/changes/contracts-licence-seats/design.md new file mode 100644 index 000000000..c1b38de7a --- /dev/null +++ b/openspec/changes/contracts-licence-seats/design.md @@ -0,0 +1,62 @@ +# Design: contracts-licence-seats + +Read at development 49e65cb4. + +## Where it fits + +| Part | File and line | What changes | +|---|---|---| +| Fragment | new `lib/Settings/register.d/contracts-licence-seats.json` (ADR-037) | adds `licenceMetric`, `licencesBought` and `licencesInUse` to `catalogContract.properties` (the monolith's block runs from `lib/Settings/softwarecatalogus_register.json:3281` to `:3462`) and raises the schema `version` (`:3270`) | +| Page | `src/manifest.json:556` `ContractDetail` | one entry in `bodyWidgets` (`:578`), after `ct-approval` | +| Component | new `src/components/contracts/ContractSeatsPanel.vue`, registered in `src/customComponents.js` next to `ContractApprovalPanel` (`:22`, `:82`) | reads the contract, renders the library `CnProgressBar` inside `CnWidgetWrapper` | +| Util | `src/utils/licensePosture.js` | new pure `seatPosition(contract)` and `seatRows(contracts, usages, modules)` next to `perVendorRollup` (`:197`) | +| View | `src/views/LicensePostureView.vue` (page `LicensePosture`, `src/manifest.json:1005`) | a fourth section, Seats, after the per-organisation section; it already fetches `catalogContract` (`:432`) | +| Overlay | `openspec/features.overlay.json:149` | `license-and-seat-tracking` status `soon` to `available` once shipped | + +No controller, route or store change: the contract fields are edited through the existing detail form, and the posture view already loads contracts, usages and modules through `objectStore` (`src/views/LicensePostureView.vue:429` to `:432`). + +## Decisions + +### D1. The counts live on the contract + +`licencesBought` is part of what the organisation bought, so it belongs on the contract. `licencesInUse` would be more natural on the `usage`, which is the deployment. But no page creates or edits a `usage` today (matrix row `stackiq:land-usage-record`: `src/manifest.json` has no page on schema `usage`), and a contract points at exactly one usage (`lib/Settings/softwarecatalogus_register.json:3301`). On the contract, the application owner edits both numbers in one form and the comparison needs no join. + +Rejected: `licencesInUse` on `usage`. It would be write-only through the API until the usage page exists. + +### D2. The metric lives on the contract, not on the module + +The row asks for the licence model of an application. The module is the supplier's published record, read by every organisation in the catalogue (its `authorization.read` lets the public read published modules). One municipality's licence terms do not belong on it, and a supplier sells one product per named user to one customer and per inhabitant to another. The contract is where the metric the organisation actually bought is known. `module.licentietype` and `module.licence` stay as they are and keep answering the open or closed source half of the row. + +Rejected: a `licenceMetric` on the module, copied into the contract. Two copies drift. + +### D3. Metrics that cannot be counted are not compared + +`Per organisation` and `Other` have no seat. For those the panel and the seats section show "Not counted" and no bar. For the other four metrics, `seatPosition()` returns one of: `within` (in use at most bought), `over` (in use above bought), `unknown` (either number empty). + +### D4. The panel uses library parts only + +`ContractSeatsPanel` is a body widget for the same reason as `ContractApprovalPanel`: the built-in `stat` widget (`src/manifest.json:565` `ct-value`) shows one aggregated number and cannot set two fields of one object against each other. The panel composes `CnWidgetWrapper` and `CnProgressBar` (`@conduction/nextcloud-vue` 2.57.1, `src/components/CnProgressBar/CnProgressBar.vue`, item fields `count` and `total`) and draws nothing of its own (ADR-012). Colours come from the bar's `variant`, `success` or `error`, which map to Nextcloud variables (ADR-003). + +## Declarative versus imperative + +The comparison is a derived read over one object, computed in the browser like the rest of the posture page (`src/utils/licensePosture.js` header: "Nothing is stored; every figure is derived at query time"). Nothing is stored, so there is no lifecycle, aggregation or notification rule to declare. An over-use notification is a possible follow-up as an `x-openregister-notifications` rule, and is not part of this change. + +## Seed data + +`catalogContract` gains three properties, so the demo descriptor `lib/Settings/stackiq_mock_register.json` (contracts from `:7608`) gets matching values. + +| Field | Contract 1 | Contract 2 | Contract 3 | Contract 4 | +|---|---|---|---|---| +| `@self.slug` | contract-contract-1-1 | contract-contract-2-2 | contract-contract-3-3 | contract-seats-5 | +| `contractType` | SLA | Licence | Maintenance | Licence | +| `licenceMetric` | empty | Per named user | empty | Per inhabitant | +| `licencesBought` | empty | 400 | empty | 58000 | +| `licencesInUse` | empty | 460 | empty | 57120 | + +Contract 2 shows over use, contract 4 shows use within the licence, and the SLA and maintenance contracts show no seats panel content. + +## Risks + +- **A stale in-use number reads as fact.** The panel prints the contract's last update date from `@self.updated`, so the reader sees when the number was entered. +- **Big numbers.** A per inhabitant licence runs into tens of thousands. The panel formats both numbers with the user's locale and the bar works on the ratio, so size does not matter. +- **Validation.** Both counts are integers with `minimum: 0`. OpenRegister rejects a negative number on save; the form shows that error as it does for every other field. diff --git a/openspec/changes/contracts-licence-seats/proposal.md b/openspec/changes/contracts-licence-seats/proposal.md new file mode 100644 index 000000000..7a9393fbd --- /dev/null +++ b/openspec/changes/contracts-licence-seats/proposal.md @@ -0,0 +1,47 @@ +--- +kind: code +depends_on: [] +--- + +# Licence contracts record the licence metric and the seats bought and in use + +## Summary + +A municipality that buys 400 user licences for an application cannot write that down in stackiq, and cannot see that 460 people now use it. This change gives a licence contract a licence metric, the number of licences bought and the number in use, shows the two against each other on the contract, and adds a seats section to the License posture page that lists every licence contract with its use and flags the ones that are over. + +## Why + +This change covers two matrix rows. + +- `stackiq:ctr-seat-count`, "Track the number of licences bought against the number in use." Stackiq rates itself no. GLPI rates yes, from its source at 11.0.9: `install/mysql/glpi-empty.sql:6774` `glpi_softwarelicenses.number` is the bought quantity, and `src/SoftwareLicense.php:158` `computeValidityIndicator` compares it with the assigned items and flags over use (`src/SoftwareLicense.php:1030`). TOPdesk rates yes: "add fields to fill the number of licences you have purchased and still have left ... the Relationship grid widget on the software cards will display details about the licences" (https://docs.topdesk.com/en/managing-licences-in-asset-management.html). +- `stackiq:ctr-licence-model`, "Record the licence model of an application, such as open source, per user or per organisation." Stackiq rates itself partial: an application records open or closed source and which open source licence, but no licence metric. GLPI rates yes, from its source at 11.0.9: every licence has a type, `install/mysql/glpi-empty.sql:6775` `glpi_softwarelicenses.softwarelicensetypes_id`. This row is below the bar on its own and rides with `stackiq:ctr-seat-count`: seat counting needs the metric that says what one seat is. + +## What stackiq has today + +Read at development 49e65cb4. + +- `lib/Settings/softwarecatalogus_register.json:6937` `module.licentietype` holds Closed source or Open source, and `:6965` `module.licence` holds one of five open source licence names. Neither says per user, per device or per organisation. +- `lib/Settings/softwarecatalogus_register.json:3341` `catalogContract.contractType` holds SLA, Licence or Maintenance. A Licence contract has no quantity field. The properties run from `:3281` to `:3462`, and none records a count. +- `lib/Settings/softwarecatalogus_register.json:3301` a contract points at exactly one `usage`, which in turn points at the module. +- `src/views/LicensePostureView.vue` (page `LicensePosture` at `src/manifest.json:1005`, route `/license-posture`) shows the open and closed share of the running portfolio, a per-vendor rollup and a per-organisation report. It counts deployments (`src/utils/licensePosture.js:114` `deploymentCount`), not licences. +- `openspec/features.overlay.json:149` lists `license-and-seat-tracking` as `soon`: "Track license models and seats next to your contracts." +- The archived change 2026-07-07-software-license-posture counted deployments as "the basis for any entitlement conversation" and left the entitlement itself unrecorded. + +## What this change builds + +- Three properties on `catalogContract`: `licenceMetric` (per named user, per concurrent user, per device, per inhabitant, per organisation, other), `licencesBought` and `licencesInUse`. +- A seats panel on the contract detail page that shows licences in use against licences bought, with a clear over-use state. +- A seats section on the License posture page: one row per licence contract with a count, showing the application, the organisation, the metric, bought, in use and the state, with over-use rows first. +- The `license-and-seat-tracking` overlay entry moves from `soon` to `available` once the pages ship. + +## Out of scope + +- Discovering how many licences are in use. Stackiq is not a discovery agent (matrix category), so the application owner records the number. Reading it from an identity provider or a supplier portal is an outside system, and outside systems belong to integriq (ADR-091). +- Licence keys and licence files. Documents go through filinq (ADR-075, ADR-087). +- Cost per seat and true-up invoices. The annualised cost stays with `src/utils/contractCost.js`, and billing belongs to shillinq. +- A licence metric on the module. The module is the supplier's record, and the supplier sells one product under several metrics. + +## Risks + +- A recorded in-use number goes stale. The panel shows when the contract was last changed, from the object's metadata, so a reader can judge how old the number is. +- New properties change the schema: the `catalogContract` version and the register version must move up, or the import skips the change. diff --git a/openspec/changes/contracts-licence-seats/specs/licence-seats/spec.md b/openspec/changes/contracts-licence-seats/specs/licence-seats/spec.md new file mode 100644 index 000000000..b6e9b0a07 --- /dev/null +++ b/openspec/changes/contracts-licence-seats/specs/licence-seats/spec.md @@ -0,0 +1,69 @@ +# licence-seats specification + +**Status**: proposed +**Scope**: stackiq +**OpenSpec changes**: +- contracts-licence-seats + +## Purpose + +An application owner records how a licence contract is measured and how many licences were bought and are in use. A municipal information manager sees on the contract and on the License posture page where use runs over what was bought. + +## ADDED Requirements + +### Requirement: REQ-LSC-001 A contract SHALL record its licence metric and the number of licences bought and in use + +`catalogContract` SHALL carry `licenceMetric` with the values Per named user, Per concurrent user, Per device, Per inhabitant, Per organisation and Other, and the integers `licencesBought` and `licencesInUse`, each with a minimum of 0. All three SHALL be optional and editable in the contract form on `ContractDetail`. + +#### Scenario: An application owner records a user licence +@e2e tests/e2e/spec-coverage/licence-seats.spec.ts + +- **GIVEN** an application owner on `/contracten/:id` for a Licence contract +- **WHEN** they edit the contract, choose Per named user, enter 400 bought and 460 in use, and save +- **THEN** the contract detail SHALL show Per named user, 400 and 460 + +#### Scenario: A negative count is refused +@e2e exclude Schema validation is OpenRegister's; tests/Unit/Settings/LicenceSeatsDeclarationTest.php asserts minimum 0 and integer type on both counts. + +- **GIVEN** the contract form +- **WHEN** an application owner enters -5 licences bought and saves +- **THEN** the save SHALL fail with a validation message on that field +- **AND** the stored contract SHALL keep its previous value + +### Requirement: REQ-LSC-002 The contract detail page MUST show licences in use against licences bought + +`ContractDetail` SHALL render a seats panel that shows in use against bought as a bar and as numbers, with the state Within licence, Over licence by N, or Unknown when a count is empty. For the metrics Per organisation and Other the panel SHALL show Not counted and no bar. The panel SHALL show the date the contract was last changed. + +#### Scenario: Use over the licence is flagged +@e2e tests/e2e/spec-coverage/licence-seats.spec.ts + +- **GIVEN** a contract with Per named user, 400 bought and 460 in use +- **WHEN** a municipal information manager opens `/contracten/:id` +- **THEN** the seats panel SHALL read Over licence by 60 +- **AND** the bar SHALL use the error variant + +#### Scenario: A site licence is not counted +@e2e exclude Covered by tests/vitest/licensePosture.spec.js, which asserts seatPosition() returns not-counted for Per organisation and Other. + +- **GIVEN** a contract with Per organisation and 1 bought +- **WHEN** the seats panel renders +- **THEN** it SHALL read Not counted and draw no bar + +### Requirement: REQ-LSC-003 The License posture page SHALL list every counted licence contract with its seat state, over-use first + +The `LicensePosture` page at `/license-posture` SHALL have a Seats section with one row per contract whose metric is counted and whose `licencesBought` is set. Each row SHALL name the application, the organisation, the metric, bought, in use and the state. Rows over the licence SHALL come first, ordered by how far over they are. The section SHALL respect the reader's read rights on `catalogContract`, because it reads through the same OpenRegister collection as the rest of the page. + +#### Scenario: An information manager finds the contracts over their licence +@e2e tests/e2e/spec-coverage/licence-seats.spec.ts + +- **GIVEN** one contract 60 over its licence and one contract within its licence +- **WHEN** a municipal information manager opens `/license-posture` +- **THEN** the Seats section SHALL list the over-licence contract first with Over licence by 60 +- **AND** it SHALL list the other contract as Within licence + +#### Scenario: Contracts without counts stay out of the section +@e2e exclude Covered by tests/vitest/licensePosture.spec.js, which asserts seatRows() skips contracts without licencesBought or with an uncounted metric. + +- **GIVEN** an SLA contract without any licence fields +- **WHEN** the Seats section renders +- **THEN** that contract SHALL NOT appear diff --git a/openspec/changes/contracts-licence-seats/tasks.md b/openspec/changes/contracts-licence-seats/tasks.md new file mode 100644 index 000000000..f4289b5c1 --- /dev/null +++ b/openspec/changes/contracts-licence-seats/tasks.md @@ -0,0 +1,62 @@ +# Tasks: contracts-licence-seats + +## Implementation tasks + +### Task 1: Add the licence metric and the two counts to the contract schema +- **spec_ref**: openspec/changes/contracts-licence-seats/specs/licence-seats/spec.md#requirement-req-lsc-001-a-contract-shall-record-its-licence-metric-and-the-number-of-licences-bought-and-in-use +- **files**: `lib/Settings/register.d/contracts-licence-seats.json`, `tests/Unit/Settings/LicenceSeatsDeclarationTest.php`, `l10n/en.json`, `l10n/nl.json` +- **acceptance_criteria**: + - GIVEN the merged register WHEN `catalogContract` is read THEN it has `licenceMetric` with six values and `licencesBought` and `licencesInUse` as integers with minimum 0 + - GIVEN the merged register WHEN the `catalogContract` version is read THEN it is higher than 0.1.1 + - GIVEN a Dutch instance WHEN the form renders THEN the three field titles and the six metric values are Dutch +- [ ] Implement +- [ ] Test (PHPUnit tests/Unit/Settings/LicenceSeatsDeclarationTest.php) + +### Task 2: Compute the seat position and the seat rows +- **spec_ref**: openspec/changes/contracts-licence-seats/specs/licence-seats/spec.md#requirement-req-lsc-003-the-license-posture-page-shall-list-every-counted-licence-contract-with-its-seat-state-over-use-first +- **files**: `src/utils/licensePosture.js`, `tests/vitest/licensePosture.spec.js` +- **acceptance_criteria**: + - GIVEN 400 bought and 460 in use WHEN seatPosition() runs THEN it returns over by 60 + - GIVEN Per organisation or Other WHEN seatPosition() runs THEN it returns not counted + - GIVEN an empty count WHEN seatPosition() runs THEN it returns unknown + - GIVEN mixed contracts WHEN seatRows() runs THEN over-licence rows come first, most over first, and uncounted contracts are left out +- [ ] Implement +- [ ] Test (vitest tests/vitest/licensePosture.spec.js) + +### Task 3: Show the seats panel on the contract detail page +- **spec_ref**: openspec/changes/contracts-licence-seats/specs/licence-seats/spec.md#requirement-req-lsc-002-the-contract-detail-page-must-show-licences-in-use-against-licences-bought +- **files**: `src/components/contracts/ContractSeatsPanel.vue`, `src/customComponents.js`, `src/manifest.json`, `l10n/en.json`, `l10n/nl.json` +- **acceptance_criteria**: + - GIVEN a contract over its licence WHEN ContractDetail opens THEN the panel reads Over licence by N with an error bar + - GIVEN a contract with Per organisation WHEN ContractDetail opens THEN the panel reads Not counted + - GIVEN any contract WHEN the panel renders THEN it shows the last change date +- [ ] Implement +- [ ] Test (Playwright tests/e2e/spec-coverage/licence-seats.spec.ts) + +### Task 4: Add the Seats section to the License posture page +- **spec_ref**: openspec/changes/contracts-licence-seats/specs/licence-seats/spec.md#requirement-req-lsc-003-the-license-posture-page-shall-list-every-counted-licence-contract-with-its-seat-state-over-use-first +- **files**: `src/views/LicensePostureView.vue`, `l10n/en.json`, `l10n/nl.json` +- **acceptance_criteria**: + - GIVEN one over-licence and one within-licence contract WHEN /license-posture opens THEN the Seats section lists the over-licence contract first + - GIVEN no counted contracts WHEN the section renders THEN it shows an empty state in English or Dutch +- [ ] Implement +- [ ] Test (Playwright tests/e2e/spec-coverage/licence-seats.spec.ts) + +### Task 5: Seed licence counts, mark the overlay and document the feature +- **spec_ref**: openspec/changes/contracts-licence-seats/specs/licence-seats/spec.md#requirement-req-lsc-001-a-contract-shall-record-its-licence-metric-and-the-number-of-licences-bought-and-in-use +- **files**: `lib/Settings/stackiq_mock_register.json`, `openspec/features.overlay.json`, `docs/features/licence-seats.md` +- **acceptance_criteria**: + - GIVEN a fresh demo import WHEN /license-posture opens THEN the Seats section shows one over-licence and one within-licence contract + - GIVEN the overlay WHEN `license-and-seat-tracking` is read THEN its status is available + - GIVEN the docs WHEN a reader opens the feature page THEN it shows a screenshot of the seats panel and of the Seats section +- [ ] Implement +- [ ] Test (Playwright tests/e2e/spec-coverage/licence-seats.spec.ts against the demo data) + +## Verification + +- `openspec validate contracts-licence-seats --type change --strict` +- PHPUnit: tests/Unit/Settings/LicenceSeatsDeclarationTest.php +- vitest: tests/vitest/licensePosture.spec.js +- Playwright: tests/e2e/spec-coverage/licence-seats.spec.ts and the existing tests/e2e/spec-coverage/license-posture.spec.ts +- Docs in docs/features/licence-seats.md with screenshots (ADR-010) +- English and Dutch strings for the metric values, the panel states and the section title (ADR-005) From 3e6b86d0002c65e4090530522607992055594a9b Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 27 Sep 2026 19:53:16 +0200 Subject: [PATCH 06/20] docs(openspec): insight-exports-and-custom-reports, list exports, own organisation export and custom reports --- .../.openspec.yaml | 2 + .../design.md | 83 ++++++++++++ .../proposal.md | 51 ++++++++ .../catalogue-exports-and-reports/spec.md | 123 ++++++++++++++++++ .../tasks.md | 82 ++++++++++++ 5 files changed, 341 insertions(+) create mode 100644 openspec/changes/insight-exports-and-custom-reports/.openspec.yaml create mode 100644 openspec/changes/insight-exports-and-custom-reports/design.md create mode 100644 openspec/changes/insight-exports-and-custom-reports/proposal.md create mode 100644 openspec/changes/insight-exports-and-custom-reports/specs/catalogue-exports-and-reports/spec.md create mode 100644 openspec/changes/insight-exports-and-custom-reports/tasks.md diff --git a/openspec/changes/insight-exports-and-custom-reports/.openspec.yaml b/openspec/changes/insight-exports-and-custom-reports/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/insight-exports-and-custom-reports/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/insight-exports-and-custom-reports/design.md b/openspec/changes/insight-exports-and-custom-reports/design.md new file mode 100644 index 000000000..7886f50b0 --- /dev/null +++ b/openspec/changes/insight-exports-and-custom-reports/design.md @@ -0,0 +1,83 @@ +# Design: insight-exports-and-custom-reports + +Read at development 49e65cb4, against `@conduction/nextcloud-vue` 2.57.1 (`package.json:45` pins `^2.57.1`) and OpenRegister development 4fee776. + +## Where it fits + +| Part | File and line | What changes | +|---|---|---| +| Fragment | new `lib/Settings/register.d/insight-exports-and-custom-reports.json` (ADR-037) | `exportable: true` on `catalogContract` (`lib/Settings/softwarecatalogus_register.json:3250`), `organization` (`:2020`), `compliancy` (`:7404`) and `moduleVersion` (`:7649`) | +| Pages | `src/manifest.json:527` `Contracten`, `:386` `Organisaties`, `:857` `Komplianties`, `:915` `Moduleversies` | `allowExport: true` in `config` | +| View | `src/views/FacetedCatalogIndexView.vue` | an Export menu in the view's own toolbar (`:51`), next to the Saved views `NcActions` (`:68`); it listens to `quick-filter-change` from its `CnIndexPage` (`:108`) | +| Controller and route | new `lib/Controller/CatalogExportController.php`, route `GET /api/catalog/{schema}/export` in `appinfo/routes.php` | `#[NoAdminRequired]`, `#[NoCSRFRequired]`, schema limited to `module` and `catalogService` | +| Service | new `lib/Service/CatalogExportService.php` | asks `FacetService` for the matched ids, reads the rows with RBAC on, writes CSV or XLSX | +| Helper | new `lib/Service/OrganisationScopeGuard.php` | the rule now private in `PortfolioReportController::isAuthorisedForOrganisation()` (`lib/Controller/PortfolioReportController.php:139`), shared by both controllers | +| Controller | `lib/Controller/SettingsController.php:1685` `exportOrgArchiMate()` | calls the helper instead of `verifyOrgExportPermission()` (`:1737`) | +| Page | `src/manifest.json:403` `OrganisatieDetail` | `actionsComponent: "OrganisationExportAction"`, registered in `src/customComponents.js` | +| Component | new `src/components/organisations/OrganisationExportAction.vue` | the Export as ArchiMate button in the detail page's actions slot | +| Page and view | new `src/manifest.d/custom-reports.json` with page `CustomReports` at `/reports/custom`, view `src/views/reports/CustomReportsView.vue`, dialog `src/modals/reports/CustomReportDialog.vue` | lists, creates, runs and deletes the user's OpenRegister export profiles over catalogue schemas | +| Page | `src/manifest.json:1021` `Reports` | a second card, Custom reports | + +## Decisions + +### D1. List pages use the library's Export menu and OpenRegister's export + +The library renders Export as CSV and Export as Excel when a page sets `allowExport` and the schema is `exportable` (`src/components/CnIndexPage/CnIndexPage.vue:3589` in the library). It sends the browser to `GET /apps/openregister/api/objects/{register}/{schema}/export` (OpenRegister `appinfo/routes.php:1175`), where OpenRegister serialises, filters and checks rights. OpenRegister checks its own `export` verb on that path, falling back to the `read` grant when a schema does not name `export` (`lib/Service/Export/ExportRightService.php:58` and `:65` in OpenRegister). Stackiq writes no CSV code for these pages (ADR-012, ADR-022). + +Rejected: the `showMassExport` mass action. In self-fetch mode it exports the whole schema without filters (`src/components/CnIndexPage/selfModeActions.js:151` `handleMassExport` in the library). + +### D2. The flag must survive the import + +OpenRegister 4fee776 has no `exportable` field on `Schema` (`lib/Db/Schema.php` field list from `:133`), and `hydrate()` swallows the error from a missing setter (`lib/Db/Schema.php:1972` to `:1977`). A schema flagged `exportable` in stackiq's register is imported without the flag, the schema the library fetches has no `exportable`, and `showExportMenu` stays false. The OpenRegister half is to keep the flag on `Schema` and serve it. Stackiq's half is to declare it, which does no harm before then. + +Rejected: putting the flag in the schema's `configuration`, which OpenRegister does keep. The library reads the top-level `exportable` (`CnIndexPage.vue:3590`), so a flag in `configuration` would need a library change as well and would leave two places to set one switch. + +### D3. The filter the user sees must reach the file + +The Export menu forwards `$route.query` only (`onExportClick` at `CnIndexPage.vue:5852` into `src/utils/indexExportHelpers.js:33` `buildExportUrl`). `config.filter`, the active quick filter and the search box live in component state, not in the URL. So the Organisations page, whose `config.filter` keeps only Draft, Active and Inactive (`src/manifest.json:396`) and so hides merged tombstones, would export the tombstones, and the Contracts page with Active selected would export every contract. A `type: index` page is drawn by the library's page renderer, so stackiq has no place to listen to the page's `quick-filter-change` event and write it into the route. This change turns the menu on for Komplianties and Moduleversies as soon as the flag is kept (no page filter, no quick filters), and for Contracten and Organisaties only with the nextcloud-vue release that forwards the merged page filter. The spec asserts the filtered result, so the e2e test fails on a library that does not forward it. + +Rejected: an `actionsComponent` with stackiq's own export on these index pages. The library's `actions` slot on `CnIndexPage` binds no props (`CnIndexPage.vue:105`), so that component cannot see the filter either. + +### D4. The faceted lists get a stackiq endpoint + +On Modules and Diensten the facet selection is not an OpenRegister filter. Two of the four GEMMA dimensions live on the linked `element` object, and the view narrows the list to `{ id: matchedObjectIds }` (`src/views/FacetedCatalogIndexView.vue:24` to `:28`). OpenRegister's export cannot take that id list through a URL at catalogue size. So `CatalogExportService` does what the list does: it calls `FacetService` with the same `_gf_` keys and search, which returns the matched ids bounded by `BASE_OBJECT_LIMIT` times `MAX_BASE_PAGES` (`lib/Service/FacetService.php:82` and `:90`) and scoped to the caller. It then reads those objects through `ObjectServiceInterface::searchObjects()` with RBAC on, adds the fields of the active quick filter, and writes the page's columns as CSV (as `PortfolioReportService::buildCsv()` does, `lib/Service/PortfolioReportService.php:166`) or XLSX. + +Here the view does own its `CnIndexPage`, so it listens to `quick-filter-change` (emitted at `CnIndexPage.vue:4831` in the library) and sends the active quick filter's `filter` object with the request. On `/modules` that carries the BBN and DPIA quick filters (`src/manifest.json:611` onwards). + +Rejected: a proxy to OpenRegister's export with `id[]` in the query. A few hundred ids already pass common URL limits. + +The endpoint honours OpenRegister's `export` verb: when the schema's `authorization` names `export`, the caller must be in one of those groups; otherwise the `read` grant applies, the same default OpenRegister uses. + +### D5. The own-organisation export moves to the organisation page, with an ownership check + +`OrganisatieDetail` gets `actionsComponent: "OrganisationExportAction"`. The library's page renderer maps that key onto the detail page's `actions` slot (`src/components/CnPageRenderer/CnPageRenderer.vue:1441` in the library), and the slot passes the `object` and `objectId` (`src/components/CnDetailPage/CnDetailPage.vue:204`). The component shows Export as ArchiMate when the caller's active organisation is the page's organisation, or the caller is a Nextcloud admin or in `ambtenaar`, and calls the existing `GET /api/archimate/export/organization/{organizationUuid}`. + +The endpoint's guard changes. Today `verifyOrgExportPermission()` (`SettingsController.php:1737`) allows a Nextcloud admin or an organisation admin group, and the group list is always empty (`lib/Service/SettingsService.php:2105` to `:2110`), so only a Nextcloud admin gets through, for any uuid. The new guard is the rule `PortfolioReportController::isAuthorisedForOrganisation()` already applies (`lib/Controller/PortfolioReportController.php:139`): admin or `ambtenaar` for any organisation, otherwise only the caller's own active organisation (user value `core`/`organisation`). The rule moves into `OrganisationScopeGuard` so the two controllers cannot drift. + +Rejected: a second endpoint for the user page. The export logic is the same, and a second door is a second place to forget the check. + +### D6. Custom reports are OpenRegister export profiles + +OpenRegister stores export profiles: a name, an ordered field list, a value mode (`stored` or `rendered`), a format (`csv` or `json`), an optional `filters` object, bound to one register and schema, owned by a user (`lib/Db/ExportProfile.php:97` onwards in OpenRegister, routes `/api/export-profiles` and `/api/export-profiles/{id}/run` at OpenRegister `appinfo/routes.php:1941` to `:1947`). The run checks the export verb and reads through `ExportService::fetchExportObjects()` with the profile's filters (`lib/Service/Export/ExportProfileService.php:236` in OpenRegister). + +Stackiq adds only the page. `CustomReportsView` lists profiles for the catalogue register. The profile index returns every profile to a Nextcloud admin and the caller's own to anyone else (`ExportProfileService::listFor()`, `:109`), so the view also keeps only `owner` equal to the current user. `CustomReportDialog` creates one through the library's `CnFormDialog`: name, catalogue schema, fields in order, value mode, format, and optional filter rows of a field and a value from the chosen schema. Run downloads `/api/export-profiles/{id}/run`. Delete calls the profile's `DELETE`. + +Rejected: a saved view as the report filter. The only saved views on the catalogue pages are the GEMMA facet views, whose `query` holds facet keys and a marker (`src/store/modules/facets.js:415`), not schema fields, so OpenRegister cannot apply them to a profile run. + +Rejected: a stackiq report store and builder. It would duplicate OpenRegister's profiles and its export rights check (ADR-022). + +## Declarative versus imperative + +- The list exports are declarative: two flags per page and schema. +- The faceted export is imperative because the GEMMA facets are stackiq's own computation in `FacetService`; nothing in OpenRegister can express them. +- Custom reports are OpenRegister data (export profiles) driven through its API. No aggregation, lifecycle or notification rule is added. + +## Seed data + +No schema changes shape; `exportable` is a schema flag, not a property. No seed objects are needed. The e2e tests use the demo import (`lib/Settings/stackiq_mock_register.json`) for rows, and create their own profiles. + +## Risks + +- **Two upstream halves.** D2 needs OpenRegister to keep the schema flag, and D3 needs a nextcloud-vue release. Until then the four list pages keep what they have today, no export. The faceted export, the organisation export and custom reports do not wait on either. +- **Large faceted exports.** The faceted export is bounded by the same ceiling as the list. Today `FacetService` only logs a warning when it hits the ceiling (`lib/Service/FacetService.php:461`) and the list does not tell the user. The export writes a last row saying the set was cut, so a file never looks complete when it is not. +- **A widened endpoint.** D5 opens the organisation export from Nextcloud admins to members of that organisation. The uuid comparison runs before any export work, and `tests/Unit/Service/OrganisationScopeGuardTest.php` pins both sides. diff --git a/openspec/changes/insight-exports-and-custom-reports/proposal.md b/openspec/changes/insight-exports-and-custom-reports/proposal.md new file mode 100644 index 000000000..6a2a9ccf3 --- /dev/null +++ b/openspec/changes/insight-exports-and-custom-reports/proposal.md @@ -0,0 +1,51 @@ +--- +kind: code +depends_on: [] +--- + +# Export the catalogue lists, your own organisation and your own reports + +## Summary + +Today a municipal information manager can export exactly one thing from a user page: the portfolio report CSV for one organisation. The Applications, Services and Contracts lists have no export, the ArchiMate export of your own organisation sits in admin settings, and nobody can define a report of their own. This change opts the catalogue list pages into the component library's Export menu, adds an export for the two faceted lists, puts the own-organisation ArchiMate export on the organisation page with an ownership check, and adds a custom reports page over OpenRegister's export profiles. + +## Why + +This change covers three matrix rows. + +- `stackiq:ins-export-list`, "Export a filtered list to a spreadsheet." Stackiq rates itself partial: one fixed report exports to CSV and the filtered catalogue lists cannot be exported. GEMMA Softwarecatalogus rates yes: "Export to CSV" on the filtered package-version list (https://www.softwarecatalogus.nl/pakketversies) and "Ook beschikbaar via knop [Export to csv] op pagina Alle pakketten" (https://www.softwarecatalogus.nl/Beschikbare%20downloads). SAP LeanIX rates yes: "In the inventory, apply filters to narrow down to the fact sheets that you need to export ... export fact sheet data as an Excel file" (https://help.sap.com/docs/leanix/ea/exporting-fact-sheet-data-as-excel-file). GLPI rates yes from its source at 11.0.9: `src/Glpi/Search/Output/Csv.php`, `Ods.php`, `Xlsx.php` and `Pdf.php` export the filtered list. TOPdesk rates yes: "Export to .CSV Export to Excel" (https://docs.topdesk.com/en/asset-dashboard.html). +- `stackiq:share-export`, "Export your own catalogue data for use elsewhere." Stackiq rates itself partial: a full ArchiMate export exists but is only reached from admin settings. GEMMA Softwarecatalogus rates yes: "Mijn pakketten, Mijn koppelingen: Knop [Exporteren]" (https://www.softwarecatalogus.nl/Beschikbare%20downloads). SAP LeanIX rates yes: "export fact sheet data as an Excel file" (https://help.sap.com/docs/leanix/ea/exporting-fact-sheet-data-as-excel-file) and "Export snapshots of your workspace data through the Pathfinder REST API" (https://help.sap.com/docs/leanix/ea/exporting-workspace-snapshots). BlueDolphin rates yes: "working with the data available through the BlueDolphin OData service" (https://help.bluedolphin.io/en/articles/11967710-using-the-odata-feed) and views export as AMEFF (https://help.bluedolphin.io/en/articles/11967514-download-a-view). GLPI rates yes from its source: every search list exports to CSV, PDF, ODS and XLSX. TOPdesk rates yes: tile actions "Export to .CSV Export to Excel" (https://docs.topdesk.com/en/asset-dashboard.html) and "generate reports by using the TOPdesk OData feed" (https://docs.topdesk.com/en/create-odata-reports-for-asset-management.html). +- `stackiq:ins-custom-report`, "Build your own report over the whole portfolio and export it." Stackiq rates itself no. SAP LeanIX rates yes: "GraphQL is used to create custom reports" (https://help.sap.com/docs/leanix/ea/sap-leanix-apis), with a reporting library that can "Export to PDF and PNG files" (https://help.sap.com/docs/leanix/ea/reporting-framework-and-cli). GLPI rates yes from its source at 11.0.9: any list takes arbitrary criteria (`src/Glpi/Search/Input/QueryBuilder.php:72`), selectable columns and export, and the result can be saved (`src/SavedSearch.php:52`). + +`ins-export-list` and `share-export` are partial and built: this change builds export on the filtered catalogue list pages, and an export of your own data from a user page instead of admin settings. `ins-custom-report` is built new. + +## What stackiq has today + +Read at development 49e65cb4, with `@conduction/nextcloud-vue` 2.57.1 and OpenRegister development 4fee776. + +- `lib/Controller/PortfolioReportController.php:105` answers `format=csv` with a `DataDownloadResponse` for one organisation. `src/views/organisaties/PortfolioReport.vue:567` is its button. It is the only export on a user page. +- No page in `src/manifest.json` sets `allowExport`, and no schema in `lib/Settings/softwarecatalogus_register.json` sets `exportable`. The library's native Export menu renders only when both are true (`src/components/CnIndexPage/CnIndexPage.vue:3589` `showExportMenu` in the library). +- OpenRegister does not keep a schema `exportable` flag. `lib/Db/Schema.php` in OpenRegister has no such field, and `hydrate()` calls a setter per key and swallows the error for an unknown one (`lib/Db/Schema.php:1972` to `:1977`). So the flag is dropped on import, the schema the library fetches never carries it, and the Export menu cannot render on any page today. +- The Modules and Diensten pages are `FacetedCatalogIndexView` (`src/views/FacetedCatalogIndexView.vue:108` mounts its `CnIndexPage`). They narrow the list with `{ id: matchedObjectIds }` from `lib/Service/FacetService.php` and keep the facet state in `_gf_` query keys (`:371` `syncUrl`), which OpenRegister's export does not understand. +- `lib/Controller/SettingsController.php:1685` `exportOrgArchiMate()` (`GET /api/archimate/export/organization/{organizationUuid}`, `appinfo/routes.php:98`) is called only from `src/views/settings/sections/ArchiMateImportExport.vue:960`, in admin settings. Its guard `verifyOrgExportPermission()` (`:1737`) lets a Nextcloud admin through, or a member of an organisation admin group. `SettingsService::getOrganizationAdminGroups()` returns an empty list on purpose (`lib/Service/SettingsService.php:2105` to `:2110`), so in practice only a Nextcloud admin can export an organisation, and the guard never compares the uuid with the caller's own organisation. +- The Reports page (`src/manifest.json:1021`, type `reports`) has one card, Portfolio rationalization. + +## What this change builds + +- The Export menu, CSV and Excel, on the Contracts, Organisations, Compliance and Module versions list pages, through the library and OpenRegister's export endpoint. Stackiq sets the two flags; the menu appears once OpenRegister keeps the schema flag. +- An Export menu on the Applications and Services pages that exports the rows the facets, the quick filter and the search leave, through a new stackiq endpoint. +- An Export as ArchiMate action on the organisation detail page for a user whose active organisation it is, and a guard on the endpoint behind it that allows exactly that, plus Nextcloud admins and `ambtenaar` as the portfolio report allows. +- A Custom reports page, reached from a card on the Reports page, where a user defines a report as a named, ordered field list over one catalogue schema with optional field filters, runs it, and downloads CSV or JSON. The reports are OpenRegister export profiles. + +## Out of scope + +- Storing and serving the schema `exportable` flag. That is OpenRegister's half: a field on `Schema`, kept on import and returned by the schema API. Until it lands the four list pages keep what they have today, no export. +- Passing the page filter and the active quick filter into the library's export. In `@conduction/nextcloud-vue` 2.57.1 the Export menu forwards only `$route.query` (`src/utils/indexExportHelpers.js:33` `buildExportUrl`, called from `onExportClick` at `CnIndexPage.vue:5852`), so a page filter or quick filter that is not in the URL is not applied to the file. That is nextcloud-vue's half; this change moves the pin to the release that carries it. +- Charts, PDF layouts and scheduled delivery. OpenRegister's scheduled reports (`/api/scheduled-reports`) can run a profile on a schedule later; this change does not surface them. +- Exporting contact persons. They hold personal data, and who may export them is a decision for the functional administrator through OpenRegister's `export` verb. +- An OData or API feed. The generated API description is `sharing-generated-api-docs`. + +## Risks + +- Two upstream halves gate REQ-CER-001: the OpenRegister schema flag and the nextcloud-vue filter forwarding. The tasks turn the flags on per page only when both have shipped, so no page exports rows the user did not ask for. +- The new guard on `exportOrgArchiMate()` widens the endpoint from Nextcloud admins to members of the organisation, for their own organisation only. The uuid check runs before any export work, and a unit test pins it. diff --git a/openspec/changes/insight-exports-and-custom-reports/specs/catalogue-exports-and-reports/spec.md b/openspec/changes/insight-exports-and-custom-reports/specs/catalogue-exports-and-reports/spec.md new file mode 100644 index 000000000..2de6869f5 --- /dev/null +++ b/openspec/changes/insight-exports-and-custom-reports/specs/catalogue-exports-and-reports/spec.md @@ -0,0 +1,123 @@ +# catalogue-exports-and-reports specification + +**Status**: proposed +**Scope**: stackiq +**OpenSpec changes**: +- insight-exports-and-custom-reports + +## Purpose + +A municipal information manager takes the catalogue out of stackiq in the shape they need: the list they have filtered, their own organisation as an ArchiMate file, or a report they defined themselves. Every export reads through OpenRegister's rights, so nobody gets rows they could not read on screen. + +## ADDED Requirements + +### Requirement: REQ-CER-001 The catalogue list pages SHALL offer export as CSV and Excel of the rows the page shows + +The pages `Contracten`, `Organisaties`, `Komplianties` and `Moduleversies` SHALL show the library's Export menu with Export as CSV and Export as Excel. Their schemas SHALL be flagged `exportable`. The file SHALL hold the rows the list shows under its page filter, active quick filter and search, and only rows the user may read. + +#### Scenario: An information manager exports the compliance list +@e2e tests/e2e/spec-coverage/catalogue-exports.spec.ts + +- **GIVEN** a municipal information manager on `/komplianties` +- **WHEN** they open the Export menu and choose Export as CSV +- **THEN** the browser SHALL download a CSV file +- **AND** every row in the file SHALL be a compliance record the list shows + +#### Scenario: The active quick filter reaches the file +@e2e tests/e2e/spec-coverage/catalogue-exports.spec.ts + +- **GIVEN** a municipal information manager on `/contracten` with the Active quick filter chosen, and the catalogue holds one active and one expired contract +- **WHEN** they choose Export as Excel +- **THEN** the file SHALL hold the active contract +- **AND** it SHALL NOT hold the expired contract + +#### Scenario: Merged organisations stay out of the organisations export +@e2e tests/e2e/spec-coverage/catalogue-exports.spec.ts + +- **GIVEN** an organisation with status merged, which the Organisations page hides through its page filter +- **WHEN** a municipal information manager exports `/organisaties` as CSV +- **THEN** the file SHALL NOT hold the merged organisation + +### Requirement: REQ-CER-002 The Applications and Services pages SHALL export the rows the facets, the quick filter and the search leave + +`FacetedCatalogIndexView` on `/modules` and `/diensten` SHALL offer Export as CSV and Export as Excel. The export SHALL call `GET /api/catalog/{schema}/export` with the current `_gf_` facet keys, the active quick filter and the search. The endpoint SHALL accept only `module` and `catalogService`, SHALL resolve the rows through `FacetService` with the caller's rights, SHALL refuse a caller outside the schema's `export` grant when the schema names one, and SHALL end the file with a row saying the set was cut when the facet ceiling was reached. + +#### Scenario: An application owner exports the applications behind one reference component +@e2e tests/e2e/spec-coverage/catalogue-exports.spec.ts + +- **GIVEN** an application owner on `/modules` with the reference component facet set to one component +- **WHEN** they choose Export as CSV +- **THEN** the file SHALL hold exactly the applications the list shows +- **AND** its columns SHALL be the page's columns + +#### Scenario: The BBN quick filter reaches the file +@e2e tests/e2e/spec-coverage/catalogue-exports.spec.ts + +- **GIVEN** a municipal information manager on `/modules` with the BBN2 quick filter chosen +- **WHEN** they choose Export as Excel +- **THEN** every row in the file SHALL be an application with BBN level BBN2 + +#### Scenario: Another schema is refused +@e2e exclude An API guard; tests/Unit/Controller/CatalogExportControllerTest.php asserts a 400 for any schema other than module and catalogService. + +- **GIVEN** a signed-in user +- **WHEN** they call `GET /api/catalog/contactPerson/export` +- **THEN** stackiq SHALL answer 400 and send no file + +#### Scenario: A narrowed export grant is honoured +@e2e exclude Needs a schema with an export grant; tests/Unit/Service/CatalogExportServiceTest.php asserts the refusal and the read fallback. + +- **GIVEN** the `module` schema names `export` for the group `software-catalog-admins` only, and a user outside that group +- **WHEN** the user calls `GET /api/catalog/module/export` +- **THEN** stackiq SHALL answer 403 and send no file + +### Requirement: REQ-CER-003 A member of an organisation MUST be able to export that organisation as ArchiMate from its detail page, and only that organisation + +The `OrganisatieDetail` page SHALL show an Export as ArchiMate action to a user whose active organisation is the page's organisation, and to Nextcloud admins and members of `ambtenaar`. The action SHALL download the file from `GET /api/archimate/export/organization/{organizationUuid}`. That endpoint SHALL allow the same callers and SHALL refuse any other user before it starts the export. + +#### Scenario: An information manager exports their own organisation +@e2e tests/e2e/spec-coverage/catalogue-exports.spec.ts + +- **GIVEN** a municipal information manager whose active organisation is Gemeente Voorbeeld, on `/organisaties/:id` for Gemeente Voorbeeld +- **WHEN** they choose Export as ArchiMate +- **THEN** the browser SHALL download an ArchiMate XML file for that organisation + +#### Scenario: Another organisation's export is refused +@e2e exclude An authorisation rule; tests/Unit/Service/OrganisationScopeGuardTest.php asserts refusal for a user of another organisation and access for admin and ambtenaar, and tests/Unit/Controller/SettingsControllerOrgExportTest.php asserts the 403. + +- **GIVEN** a user whose active organisation is Gemeente Voorbeeld and who is not an admin or `ambtenaar` +- **WHEN** they call the export for Gemeente Anders +- **THEN** stackiq SHALL answer 403 and send no file + +#### Scenario: The action is hidden on another organisation's page +@e2e tests/e2e/spec-coverage/catalogue-exports.spec.ts + +- **GIVEN** a municipal information manager whose active organisation is Gemeente Voorbeeld and who is not an admin or `ambtenaar` +- **WHEN** they open the detail page of a supplier organisation +- **THEN** the Export as ArchiMate action SHALL NOT be shown + +### Requirement: REQ-CER-004 A user SHALL define, run and delete their own reports over one catalogue schema + +A Custom reports page at `/reports/custom`, reached from a card on the Reports page, SHALL list the user's OpenRegister export profiles for the catalogue register. The user SHALL create a report with a name, one catalogue schema, an ordered list of fields, a value mode, a format (CSV or JSON) and optional filter rows of a field and a value. Run SHALL download the file from OpenRegister's `/api/export-profiles/{id}/run`. Delete SHALL remove the profile. The page SHALL list only the current user's own profiles, also for a Nextcloud admin, to whom OpenRegister returns every profile. + +#### Scenario: An information manager builds a supplier overview report +@e2e tests/e2e/spec-coverage/custom-reports.spec.ts + +- **GIVEN** a municipal information manager on `/reports/custom` +- **WHEN** they create a report named Applications per supplier over Applications with the fields name, provider and licentietype in that order, format CSV, and run it +- **THEN** the browser SHALL download a CSV whose header is name, provider, licentietype in that order +- **AND** the report SHALL be listed on the page afterwards + +#### Scenario: A filter narrows the report +@e2e tests/e2e/spec-coverage/custom-reports.spec.ts + +- **GIVEN** a municipal information manager on `/reports/custom` +- **WHEN** they create a report over Applications with the filter bbnLevel equals BBN2 and run it +- **THEN** every row in the file SHALL be a BBN2 application + +#### Scenario: Another user's reports stay private +@e2e exclude Ownership is OpenRegister's; tests/vitest/customReports.spec.js asserts the page keeps only profiles whose owner is the current user and whose register is the catalogue register. + +- **GIVEN** two users who each created a report +- **WHEN** the first user opens `/reports/custom` +- **THEN** the page SHALL list only the first user's report diff --git a/openspec/changes/insight-exports-and-custom-reports/tasks.md b/openspec/changes/insight-exports-and-custom-reports/tasks.md new file mode 100644 index 000000000..6f169c3d0 --- /dev/null +++ b/openspec/changes/insight-exports-and-custom-reports/tasks.md @@ -0,0 +1,82 @@ +# Tasks: insight-exports-and-custom-reports + +## Implementation tasks + +### Task 1: Share the organisation scope rule and guard the organisation export with it +- **spec_ref**: openspec/changes/insight-exports-and-custom-reports/specs/catalogue-exports-and-reports/spec.md#requirement-req-cer-003-a-member-of-an-organisation-must-be-able-to-export-that-organisation-as-archimate-from-its-detail-page-and-only-that-organisation +- **files**: `lib/Service/OrganisationScopeGuard.php`, `lib/Controller/PortfolioReportController.php`, `lib/Controller/SettingsController.php`, `tests/Unit/Service/OrganisationScopeGuardTest.php`, `tests/Unit/Controller/SettingsControllerOrgExportTest.php` +- **acceptance_criteria**: + - GIVEN a user in `admin` or `ambtenaar` WHEN the guard checks any organisation uuid THEN it allows + - GIVEN a user whose active organisation is A WHEN the guard checks A THEN it allows, and WHEN it checks B THEN it refuses + - GIVEN a refused user WHEN they call the organisation ArchiMate export THEN stackiq answers 403 before `ArchiMateService::exportOrgArchiMate()` runs + - GIVEN the portfolio report WHEN the same users call it THEN the answers are unchanged +- [ ] Implement +- [ ] Test (PHPUnit tests/Unit/Service/OrganisationScopeGuardTest.php and tests/Unit/Controller/SettingsControllerOrgExportTest.php) + +### Task 2: Add the Export as ArchiMate action to the organisation detail page +- **spec_ref**: openspec/changes/insight-exports-and-custom-reports/specs/catalogue-exports-and-reports/spec.md#requirement-req-cer-003-a-member-of-an-organisation-must-be-able-to-export-that-organisation-as-archimate-from-its-detail-page-and-only-that-organisation +- **files**: `src/components/organisations/OrganisationExportAction.vue`, `src/customComponents.js`, `src/manifest.json`, `l10n/en.json`, `l10n/nl.json` +- **acceptance_criteria**: + - GIVEN a user whose active organisation is the page's organisation WHEN `/organisaties/:id` opens THEN the action shows and downloads an ArchiMate XML file + - GIVEN a user of another organisation who is not admin or `ambtenaar` WHEN the page opens THEN the action is not shown +- [ ] Implement +- [ ] Test (Playwright tests/e2e/spec-coverage/catalogue-exports.spec.ts) + +### Task 3: Add the faceted catalogue export endpoint +- **spec_ref**: openspec/changes/insight-exports-and-custom-reports/specs/catalogue-exports-and-reports/spec.md#requirement-req-cer-002-the-applications-and-services-pages-shall-export-the-rows-the-facets-the-quick-filter-and-the-search-leave +- **files**: `lib/Controller/CatalogExportController.php`, `lib/Service/CatalogExportService.php`, `appinfo/routes.php`, `tests/Unit/Controller/CatalogExportControllerTest.php`, `tests/Unit/Service/CatalogExportServiceTest.php` +- **acceptance_criteria**: + - GIVEN a schema other than `module` or `catalogService` WHEN the endpoint is called THEN it answers 400 + - GIVEN facet keys, a quick filter and a search WHEN the endpoint runs THEN the rows are the ids `FacetService` matched, narrowed by the quick filter fields, read with RBAC on + - GIVEN a schema whose `authorization` names `export` and a caller outside it WHEN the endpoint is called THEN it answers 403 + - GIVEN the facet ceiling was reached WHEN the file is written THEN its last row says the set was cut +- [ ] Implement +- [ ] Test (PHPUnit tests/Unit/Controller/CatalogExportControllerTest.php and tests/Unit/Service/CatalogExportServiceTest.php) + +### Task 4: Add the Export menu to the Applications and Services pages +- **spec_ref**: openspec/changes/insight-exports-and-custom-reports/specs/catalogue-exports-and-reports/spec.md#requirement-req-cer-002-the-applications-and-services-pages-shall-export-the-rows-the-facets-the-quick-filter-and-the-search-leave +- **files**: `src/views/FacetedCatalogIndexView.vue`, `src/utils/catalogExportUrl.js`, `tests/vitest/catalogExportUrl.spec.js`, `l10n/en.json`, `l10n/nl.json` +- **acceptance_criteria**: + - GIVEN a reference component facet and the BBN2 quick filter on `/modules` WHEN the user chooses Export as CSV THEN the request carries the `_gf_` keys, the quick filter and the search + - GIVEN the export finishes WHEN the file opens THEN its columns are the page's columns +- [ ] Implement +- [ ] Test (vitest tests/vitest/catalogExportUrl.spec.js and Playwright tests/e2e/spec-coverage/catalogue-exports.spec.ts) + +### Task 5: Opt the list pages and schemas into the library's Export menu +- **spec_ref**: openspec/changes/insight-exports-and-custom-reports/specs/catalogue-exports-and-reports/spec.md#requirement-req-cer-001-the-catalogue-list-pages-shall-offer-export-as-csv-and-excel-of-the-rows-the-page-shows +- **files**: `lib/Settings/register.d/insight-exports-and-custom-reports.json`, `src/manifest.json`, `package.json`, `tests/Unit/Settings/CatalogExportFlagsTest.php` +- **acceptance_criteria**: + - GIVEN the merged register WHEN `catalogContract`, `organization`, `compliancy` and `moduleVersion` are read THEN each has `exportable: true` + - GIVEN an OpenRegister release that keeps the schema flag WHEN `/komplianties` and `/moduleversies` open THEN the Export menu shows + - GIVEN a nextcloud-vue release that forwards the page filter and the quick filter WHEN the pin moves to it THEN `/contracten` and `/organisaties` get `allowExport` in the same commit, and not before +- [ ] Implement +- [ ] Test (PHPUnit tests/Unit/Settings/CatalogExportFlagsTest.php and Playwright tests/e2e/spec-coverage/catalogue-exports.spec.ts) + +### Task 6: Add the Custom reports page over OpenRegister export profiles +- **spec_ref**: openspec/changes/insight-exports-and-custom-reports/specs/catalogue-exports-and-reports/spec.md#requirement-req-cer-004-a-user-shall-define-run-and-delete-their-own-reports-over-one-catalogue-schema +- **files**: `src/manifest.d/custom-reports.json`, `src/views/reports/CustomReportsView.vue`, `src/modals/reports/CustomReportDialog.vue`, `src/customComponents.js`, `src/manifest.json`, `tests/vitest/customReports.spec.js`, `l10n/en.json`, `l10n/nl.json` +- **acceptance_criteria**: + - GIVEN the Reports page WHEN it opens THEN it shows a Custom reports card that opens `/reports/custom` + - GIVEN a new report with three fields in order and format CSV WHEN the user runs it THEN the CSV header holds those fields in that order + - GIVEN a filter row bbnLevel equals BBN2 WHEN the report runs THEN every row is a BBN2 application + - GIVEN a Nextcloud admin with other users' profiles in OpenRegister WHEN the page opens THEN it lists only the admin's own profiles +- [ ] Implement +- [ ] Test (vitest tests/vitest/customReports.spec.js and Playwright tests/e2e/spec-coverage/custom-reports.spec.ts) + +### Task 7: Document the exports and the custom reports +- **spec_ref**: openspec/changes/insight-exports-and-custom-reports/specs/catalogue-exports-and-reports/spec.md#requirement-req-cer-004-a-user-shall-define-run-and-delete-their-own-reports-over-one-catalogue-schema +- **files**: `docs/features/catalogue-exports-and-reports.md`, `openspec/features.overlay.json` +- **acceptance_criteria**: + - GIVEN the docs WHEN a reader opens the feature page THEN it shows a screenshot of the faceted Export menu, the organisation action and the Custom reports page + - GIVEN the overlay WHEN `portfolio-reporting` is read THEN its status reflects what shipped +- [ ] Implement +- [ ] Test (docs build and a manual read of the screenshots against the running app) + +## Verification + +- `openspec validate insight-exports-and-custom-reports --type change --strict` +- PHPUnit: tests/Unit/Service/OrganisationScopeGuardTest.php, tests/Unit/Controller/SettingsControllerOrgExportTest.php, tests/Unit/Controller/CatalogExportControllerTest.php, tests/Unit/Service/CatalogExportServiceTest.php, tests/Unit/Settings/CatalogExportFlagsTest.php, and the existing PortfolioReportController tests +- vitest: tests/vitest/catalogExportUrl.spec.js and tests/vitest/customReports.spec.js +- Playwright: tests/e2e/spec-coverage/catalogue-exports.spec.ts and tests/e2e/spec-coverage/custom-reports.spec.ts +- Docs in docs/features/catalogue-exports-and-reports.md with screenshots (ADR-010) +- English and Dutch strings for the menu entries, the action, the page, the dialog and the cut-off row (ADR-005) From 24a9e694e4a9e292a29fa7557f11ee156a99e846 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 27 Sep 2026 19:56:55 +0200 Subject: [PATCH 07/20] docs(openspec): insight-supplier-facet, supplier as a fifth facet on applications and services --- .../insight-supplier-facet/.openspec.yaml | 2 + .../changes/insight-supplier-facet/design.md | 66 +++++++++++++++ .../insight-supplier-facet/proposal.md | 46 +++++++++++ .../specs/supplier-facet/spec.md | 82 +++++++++++++++++++ .../changes/insight-supplier-facet/tasks.md | 55 +++++++++++++ 5 files changed, 251 insertions(+) create mode 100644 openspec/changes/insight-supplier-facet/.openspec.yaml create mode 100644 openspec/changes/insight-supplier-facet/design.md create mode 100644 openspec/changes/insight-supplier-facet/proposal.md create mode 100644 openspec/changes/insight-supplier-facet/specs/supplier-facet/spec.md create mode 100644 openspec/changes/insight-supplier-facet/tasks.md diff --git a/openspec/changes/insight-supplier-facet/.openspec.yaml b/openspec/changes/insight-supplier-facet/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/insight-supplier-facet/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/insight-supplier-facet/design.md b/openspec/changes/insight-supplier-facet/design.md new file mode 100644 index 000000000..e01b58b96 --- /dev/null +++ b/openspec/changes/insight-supplier-facet/design.md @@ -0,0 +1,66 @@ +# Design: insight-supplier-facet + +Read at development 49e65cb4, with `@conduction/nextcloud-vue` 2.57.1 and OpenRegister development 4fee776. + +## Where it fits + +| Part | File and line | What changes | +|---|---|---| +| Service | `lib/Service/FacetService.php:109` `DIMENSIONS` | adds `supplier`; `buildDimensionValueMap()` (`:648`) fills it; `computeFacets()` (`:898`) takes a label map; `buildCacheKey()` (`:360`) adds the dimension list | +| Service | `lib/Service/FacetService.php` new private `resolveSupplierLabels()` | one bounded batch read of the `organization` objects the base set points at, next to `fetchModulesByIdentifiers()` (`:587`) | +| Controller | `lib/Controller/FacetController.php:147` `parseFilters()` | adds `supplier` to its list | +| Client | `src/services/facets.js:22` `FACET_DIMENSIONS` | adds `supplier`, and `src/services/facets.spec.js:31` moves with it | +| View | `src/views/FacetedCatalogIndexView.vue:149` `DIMENSION_LABELS` | adds Supplier; the sidebar schema comes from `src/utils/facetSchema.js` `buildFacetDimensionSchema()`, so no template change | +| Store | `src/store/modules/facets.js` | nothing: it loops over `FACET_DIMENSIONS` (`:88`, `:135`, `:299`, `:334`), so the URL key `_gf_supplier` and saved views follow | +| Seed | `lib/Settings/stackiq_mock_register.json` | `provider` on the demo modules and services | + +No new route, no schema change and no page change. The Modules and Diensten pages (`src/manifest.json:592` and `:647`) already mount `FacetedCatalogIndexView`. + +## Decisions + +### D1. Supplier is a FacetService dimension, not an OpenRegister facet + +`module.provider` is `facetable: true` (`lib/Settings/softwarecatalogus_register.json:6921`), so OpenRegister could count it natively. But the list on these pages is narrowed by `{ id: matchedObjectIds }`, which `FacetService` computes over its own bounded base set (`lib/Service/FacetService.php:269`). A native provider count would be taken over a different set, would not narrow the other four facets and would not be narrowed by them. As a fifth `FacetService` dimension, supplier is counted disjunctively like the others (`computeFacets()` excludes a dimension's own selection when counting it, `:898` to `:930`), goes into `matchedObjectIds`, and uses the same cache and the same RBAC-scoped base set. + +Rejected: turning on the library's own facet sidebar for provider next to `CnFacetSidebar`. The view explains why `CnIndexPage`'s embedded facets cannot share the narrowing (`src/views/FacetedCatalogIndexView.vue:11` to `:28`), and two sidebars would count two different sets. + +### D2. The value is the organisation id, the label is its name + +The four existing dimensions use the display name as both value and label (`computeFacets()` writes `'value' => $value, 'label' => $value`). For supplier that would merge two organisations with the same name and break a saved view when a supplier is renamed. So the supplier value is the organisation id from `provider` (read with `extractRelatedIdentifiers()`, `:988`, which accepts a uuid string or an object with `id`), and the label is `organization.name` from one bounded batch read (`_limit` `ELEMENT_LOOKUP_LIMIT`, `:95`), through the organisation schema id in the voorzieningen config (key `organisatie_schema`, `lib/Service/SettingsService.php:135`). An organisation without a name gets its id as label, the fallback `elementDisplayName()` uses (`:1055`). + +`computeFacets()` gets an optional label map per dimension; the other four pass none and keep `label = value`. + +Rejected: resolving the name from Nextcloud Contacts through `contactsUid`. `organization.name` already mirrors that identity and is what the ArchiMate export writes (its description in the register), and a Contacts lookup per supplier per facet request would be a second, unbounded source. + +### D3. A service facets on its own provider + +For `/diensten`, `FacetService` resolves the GEMMA dimensions through the service's linked modules (`resolveModulesPerObject()`, `:532`). Supplier does not follow that path: it reads `catalogService.provider` (`:1436` in the register) from the service itself, because that is the Provider column the Services list shows next to it. Reading the modules' suppliers would put a service under a supplier the list does not show. + +### D4. The set moves together, and the tests prove it + +The comment at `src/services/facets.js:22` records a drift where the frontend sent `standard[]` to a backend still reading `standaard[]`. This change moves all four declarations in one commit. `tests/Unit/Service/FacetServiceTest.php` asserts `DIMENSIONS` and `FacetController::parseFilters()` hold the same five names, and `src/services/facets.spec.js` asserts `FACET_DIMENSIONS` holds them in display order. + +## Declarative versus imperative + +This is aggregation, and it stays imperative in `FacetService`. The GEMMA facets are stackiq's own computation over linked `element` objects, which no `x-openregister-aggregations` rule can express (ADR-031), and supplier must share that computation's base set and narrowing (D1). + +## Seed data + +No schema changes. The demo descriptor `lib/Settings/stackiq_mock_register.json` gets a `provider` on the modules and services it seeds in the `stackiq` register (the file repeats the same slugs under `vng-gemma`; those copies stay as they are), written as OpenRegister seed references (`@ref:`, resolved on import by `lib/Service/Configuration/ImportHandler.php:3343` in OpenRegister). + +| Object slug | Schema | `provider` | +|---|---|---| +| module-voorbeeld-name-1-1 | module | `@ref:organization-voorbeeld-name-2-2` | +| module-voorbeeld-name-2-2 | module | `@ref:organization-voorbeeld-name-2-2` | +| module-voorbeeld-name-3-3 | module | `@ref:organization-voorbeeld-name-3-3` | +| service-voorbeeld-name-1-1 | catalogService | `@ref:organization-voorbeeld-name-2-2` | +| service-voorbeeld-name-2-2 | catalogService | `@ref:organization-voorbeeld-name-2-2` | +| service-voorbeeld-name-3-3 | catalogService | `@ref:organization-voorbeeld-name-3-3` | + +On a fresh demo instance the Supplier facet then shows Voorbeeld Name 2 with 2 and Voorbeeld Name 3 with 1 on `/modules`. + +## Risks + +- **Cached four-dimension answers.** The cache lives 30 minutes (`CACHE_TTL`, `:77`) and its key today holds schema, filters, search, organisation and user (`:360` to `:385`). The dimension list joins the key, so a response without `supplier` is never served after the deploy. +- **Many suppliers.** A catalogue with hundreds of suppliers gives a long facet. `CnFacetSidebar` already lists values by count, highest first (`arsort` in `computeFacets()`), so the common suppliers lead. +- **Organisation reads.** The label read is one extra bounded query per cache miss, not one per object. diff --git a/openspec/changes/insight-supplier-facet/proposal.md b/openspec/changes/insight-supplier-facet/proposal.md new file mode 100644 index 000000000..5add58fe6 --- /dev/null +++ b/openspec/changes/insight-supplier-facet/proposal.md @@ -0,0 +1,46 @@ +--- +kind: code +depends_on: [] +--- + +# Supplier as a facet on the Applications and Services pages + +## Summary + +A municipal information manager on the Applications page can narrow the list by reference component, standard, application service and domain, with a count next to every value. They cannot narrow it by supplier, which is the other example the matrix row names and the first facet GEMMA Softwarecatalogus users expect. This change adds Supplier as a fifth facet on `/modules` and `/diensten`, computed by the same service, counted the same way and kept in the URL and in saved views like the other four. + +## Why + +This change covers one matrix row. + +- `stackiq:ins-faceted-search`, "Search the catalogue and narrow the results with facets such as reference component and supplier." Stackiq rates itself partial: search and the four GEMMA facets work, and supplier is only a column. GEMMA Softwarecatalogus rates yes: "Aan de linkerkant staan zogenaamde filter mogelijkheden. Deze werken ook in combinatie ... Achter de te zetten filters staat een getal" (https://www.softwarecatalogus.nl/node/13683), and its package version list offers the facets Leverancier, Standaard, Referentiecomponent, Status planning, Domein, Doelgroep and Bedrijfsfunctie (https://www.softwarecatalogus.nl/pakketversies). SAP LeanIX rates yes: "Apply a filter using the facet filter column" (https://help.sap.com/docs/leanix/ea/filtering-in-report-urls), with inventory filters by lifecycle, subscription, tags and fields (https://help.sap.com/docs/leanix/ea/advanced-filter-options). BlueDolphin rates yes: "narrow down repository data based on object properties" (https://help.bluedolphin.io/en/articles/11967727-add-filters-to-the-data). GLPI rates yes from its source at 11.0.9: every list has a criteria builder (`src/Glpi/Search/Input/QueryBuilder.php:72`), for example on the manufacturer of an appliance. + +The row is partial and built: this change builds the missing half, supplier as a facet. + +## What stackiq has today + +Read at development 49e65cb4. + +- `lib/Service/FacetService.php:109` `DIMENSIONS` is `referenceComponent`, `standard`, `applicationService` and `domain`. `GET /api/facets/{schema}` (`appinfo/routes.php:195`) returns counts for those four and the matched object ids. +- The same four names are repeated in `lib/Controller/FacetController.php:147` `parseFilters()`, `src/services/facets.js:22` `FACET_DIMENSIONS` and `src/views/FacetedCatalogIndexView.vue:149` `DIMENSION_LABELS`. The comment at `src/services/facets.js:22` records that the set once drifted between frontend and backend and filtering by standard silently returned everything. +- `module.provider` (`lib/Settings/softwarecatalogus_register.json:6921`) is a related `organization`, titled Supplier, marked `facetable: true`. `catalogService.provider` (`:1436`) is the same relation, titled Provider. On both pages provider is only a column (`src/manifest.json` Modules and Diensten `columns`). +- The archived change 2026-07-23-gemma-faceted-search scoped the sidebar to the four GEMMA dimensions. It records no reason for leaving supplier out; supplier is simply not a GEMMA dimension. +- In the demo descriptor `lib/Settings/stackiq_mock_register.json` every module and service has an empty `provider` (`{}`), so no supplier facet would show a value on a demo instance. + +## What this change builds + +- A fifth dimension, `supplier`, in `FacetService`: for an application the organisation in `module.provider`, for a service the organisation in `catalogService.provider`, shown by the organisation's name. +- The same name in the controller, the frontend dimension list and the label map, so the set moves together. +- Supplier in the facet sidebar on `/modules` and `/diensten`, with counts that follow the other facets and the search, a `_gf_supplier` key in the URL, and saved views that keep it. +- Demo modules and services with a supplier, so the facet shows values on a fresh instance. + +## Out of scope + +- The other GEMMA Softwarecatalogus facets (Status planning, Doelgroep, Bedrijfsfunctie). Each needs its own data decision. +- The supplier of the applications behind a service. A service facet uses the service's own provider, which is what the Services list shows. +- Changes to how organisations get their name. Organisation identity can live in Nextcloud Contacts through `contactsUid`; this change reads `organization.name` and falls back as described in the design. + +## Risks + +- An organisation without a `name` shows under its identifier, the same fallback the reference component facet uses. The demo data names every organisation. +- Facet answers are cached for 30 minutes (`lib/Service/FacetService.php:77`). The cache key gets the dimension list, so an answer cached before the deploy, without supplier, is not served after it. diff --git a/openspec/changes/insight-supplier-facet/specs/supplier-facet/spec.md b/openspec/changes/insight-supplier-facet/specs/supplier-facet/spec.md new file mode 100644 index 000000000..08cacf397 --- /dev/null +++ b/openspec/changes/insight-supplier-facet/specs/supplier-facet/spec.md @@ -0,0 +1,82 @@ +# supplier-facet specification + +**Status**: proposed +**Scope**: stackiq +**OpenSpec changes**: +- insight-supplier-facet + +## Purpose + +A municipal information manager narrows the Applications and Services lists by supplier, next to the four GEMMA facets, and sees how many applications or services each supplier has in the current selection. + +## ADDED Requirements + +### Requirement: REQ-SFC-001 The facet endpoint SHALL return a supplier facet with a count per organisation + +`GET /api/facets/{schema}` SHALL return a `supplier` bucket next to `referenceComponent`, `standard`, `applicationService` and `domain`, always present and empty when no object has a supplier. For `module` a supplier SHALL be the organisation in `provider`; for `catalogService` it SHALL be the service's own `provider`. Each entry SHALL carry the organisation id as `value`, the organisation name as `label` (the id when the organisation has no name) and the count. The count SHALL cover the objects the caller may read under the other selected facets and the search, and SHALL NOT be narrowed by the supplier selection itself. + +#### Scenario: Counts per supplier on the applications facet +@e2e exclude The endpoint shape is asserted in tests/Unit/Service/FacetServiceTest.php, which feeds three modules with two suppliers and checks the bucket values, labels and counts. + +- **GIVEN** three applications, two supplied by Voorbeeld Name 2 and one by Voorbeeld Name 3 +- **WHEN** a municipal information manager's page calls `GET /api/facets/module` +- **THEN** the `supplier` bucket SHALL hold Voorbeeld Name 2 with 2 and Voorbeeld Name 3 with 1 +- **AND** each entry's `value` SHALL be the organisation id + +#### Scenario: Two suppliers with the same name stay apart +@e2e exclude A data edge; tests/Unit/Service/FacetServiceTest.php asserts two organisations named alike give two entries with different values. + +- **GIVEN** two organisations both named Acme, each supplying one application +- **WHEN** the facet endpoint runs for `module` +- **THEN** the `supplier` bucket SHALL hold two Acme entries with count 1 each + +#### Scenario: A service is counted under its own provider +@e2e exclude Covered by tests/Unit/Service/FacetServiceTest.php, which gives a service a provider different from its linked module's and asserts the service's provider is counted. + +- **GIVEN** a service provided by Voorbeeld Name 2 that links an application supplied by Voorbeeld Name 3 +- **WHEN** the facet endpoint runs for `catalogService` +- **THEN** the service SHALL count under Voorbeeld Name 2 only + +### Requirement: REQ-SFC-002 The Applications and Services pages SHALL offer Supplier as a facet that narrows the list + +`FacetedCatalogIndexView` on `/modules` and `/diensten` SHALL show a Supplier facet in its sidebar with the labels and counts from REQ-SFC-001. Choosing one or more suppliers SHALL narrow the list to objects of those suppliers, combined with the other facets and the search. The choice SHALL be kept in the URL under `_gf_supplier` and in a saved facet view. + +#### Scenario: An information manager narrows applications to one supplier +@e2e tests/e2e/spec-coverage/supplier-facet.spec.ts + +- **GIVEN** a municipal information manager on `/modules` on a demo instance +- **WHEN** they choose Voorbeeld Name 2 in the Supplier facet +- **THEN** the list SHALL show only applications supplied by Voorbeeld Name 2 +- **AND** the URL SHALL carry `_gf_supplier` + +#### Scenario: Supplier combines with a GEMMA facet +@e2e tests/e2e/spec-coverage/supplier-facet.spec.ts + +- **GIVEN** a municipal information manager on `/modules` with one reference component chosen +- **WHEN** they open the Supplier facet +- **THEN** its counts SHALL cover only the applications of that reference component + +#### Scenario: A saved view keeps the supplier +@e2e tests/e2e/spec-coverage/supplier-facet.spec.ts + +- **GIVEN** a municipal information manager who chose a supplier on `/diensten` and saved the selection as a view +- **WHEN** they open the page fresh and apply that view +- **THEN** the Supplier facet SHALL show the same supplier chosen and the list SHALL be narrowed to it + +### Requirement: REQ-SFC-003 The facet dimension set MUST be the same in the service, the controller and the client + +`FacetService::DIMENSIONS`, `FacetController::parseFilters()` and `FACET_DIMENSIONS` in `src/services/facets.js` MUST hold the same five names. A cached facet answer MUST NOT be served after the set changes. + +#### Scenario: The backend reads every dimension the client sends +@e2e exclude A contract between two files; tests/Unit/Controller/FacetControllerTest.php asserts parseFilters() reads a supplier[] parameter, and src/services/facets.spec.js asserts FACET_DIMENSIONS holds the same five names. + +- **GIVEN** the client sends `supplier[]` with one organisation id +- **WHEN** `FacetController` parses the request +- **THEN** `FacetService` SHALL receive that supplier filter and narrow `matchedObjectIds` by it + +#### Scenario: An answer cached before the deploy is not reused +@e2e exclude Cache behaviour; tests/Unit/Service/FacetServiceTest.php asserts the cache key changes when the dimension list changes. + +- **GIVEN** a facet answer cached with four dimensions +- **WHEN** the same request arrives after the deploy +- **THEN** `FacetService` SHALL compute a new answer that holds `supplier` diff --git a/openspec/changes/insight-supplier-facet/tasks.md b/openspec/changes/insight-supplier-facet/tasks.md new file mode 100644 index 000000000..d2a028e32 --- /dev/null +++ b/openspec/changes/insight-supplier-facet/tasks.md @@ -0,0 +1,55 @@ +# Tasks: insight-supplier-facet + +## Implementation tasks + +### Task 1: Compute the supplier dimension in FacetService +- **spec_ref**: openspec/changes/insight-supplier-facet/specs/supplier-facet/spec.md#requirement-req-sfc-001-the-facet-endpoint-shall-return-a-supplier-facet-with-a-count-per-organisation +- **files**: `lib/Service/FacetService.php`, `tests/Unit/Service/FacetServiceTest.php` +- **acceptance_criteria**: + - GIVEN three modules with two suppliers WHEN getFacets('module') runs THEN the supplier bucket holds two entries with the organisation id as value, the name as label and counts 2 and 1 + - GIVEN two organisations with the same name WHEN the bucket is built THEN they stay two entries + - GIVEN a service whose provider differs from its module's supplier WHEN getFacets('catalogService') runs THEN the service counts under its own provider + - GIVEN a supplier selection WHEN the facets are computed THEN matchedObjectIds holds only that supplier's objects and the supplier counts ignore the supplier selection + - GIVEN an organisation without a name WHEN the label is built THEN the label is its id + - GIVEN any request WHEN the organisation labels are read THEN it is one bounded query with an explicit limit +- [ ] Implement +- [ ] Test (PHPUnit tests/Unit/Service/FacetServiceTest.php) + +### Task 2: Move the dimension set together and key the cache on it +- **spec_ref**: openspec/changes/insight-supplier-facet/specs/supplier-facet/spec.md#requirement-req-sfc-003-the-facet-dimension-set-must-be-the-same-in-the-service-the-controller-and-the-client +- **files**: `lib/Service/FacetService.php`, `lib/Controller/FacetController.php`, `src/services/facets.js`, `src/services/facets.spec.js`, `tests/Unit/Controller/FacetControllerTest.php`, `tests/Unit/Service/FacetServiceTest.php` +- **acceptance_criteria**: + - GIVEN a request with supplier[] WHEN FacetController parses it THEN the supplier filter reaches FacetService + - GIVEN the three declarations WHEN the tests run THEN they hold the same five names + - GIVEN two dimension lists WHEN buildCacheKey() runs for the same request THEN the keys differ +- [ ] Implement +- [ ] Test (PHPUnit tests/Unit/Controller/FacetControllerTest.php and tests/Unit/Service/FacetServiceTest.php, vitest src/services/facets.spec.js) + +### Task 3: Show Supplier in the facet sidebar +- **spec_ref**: openspec/changes/insight-supplier-facet/specs/supplier-facet/spec.md#requirement-req-sfc-002-the-applications-and-services-pages-shall-offer-supplier-as-a-facet-that-narrows-the-list +- **files**: `src/views/FacetedCatalogIndexView.vue`, `tests/vitest/facetSchema.spec.js`, `tests/vitest/facetStore.spec.js`, `l10n/en.json`, `l10n/nl.json` +- **acceptance_criteria**: + - GIVEN /modules WHEN the sidebar renders THEN it shows Supplier with the label from each entry, not its id + - GIVEN a supplier chosen WHEN the URL updates THEN it carries _gf_supplier, and a reload restores the choice + - GIVEN a saved facet view with a supplier WHEN it is applied THEN the supplier is chosen again + - GIVEN a Dutch instance WHEN the sidebar renders THEN the facet title reads Leverancier +- [ ] Implement +- [ ] Test (vitest tests/vitest/facetSchema.spec.js and tests/vitest/facetStore.spec.js, Playwright tests/e2e/spec-coverage/supplier-facet.spec.ts) + +### Task 4: Seed suppliers and document the facet +- **spec_ref**: openspec/changes/insight-supplier-facet/specs/supplier-facet/spec.md#requirement-req-sfc-002-the-applications-and-services-pages-shall-offer-supplier-as-a-facet-that-narrows-the-list +- **files**: `lib/Settings/stackiq_mock_register.json`, `docs/features/supplier-facet.md` +- **acceptance_criteria**: + - GIVEN a fresh demo import WHEN /modules opens THEN the Supplier facet shows Voorbeeld Name 2 with 2 and Voorbeeld Name 3 with 1 + - GIVEN the docs WHEN a reader opens the feature page THEN it shows a screenshot of the Supplier facet on /modules +- [ ] Implement +- [ ] Test (Playwright tests/e2e/spec-coverage/supplier-facet.spec.ts against the demo data) + +## Verification + +- `openspec validate insight-supplier-facet --type change --strict` +- PHPUnit: tests/Unit/Service/FacetServiceTest.php and tests/Unit/Controller/FacetControllerTest.php +- vitest: src/services/facets.spec.js, tests/vitest/facetSchema.spec.js and tests/vitest/facetStore.spec.js +- Playwright: tests/e2e/spec-coverage/supplier-facet.spec.ts +- Docs in docs/features/supplier-facet.md with a screenshot (ADR-010) +- English and Dutch strings for the facet title (ADR-005) From a0139d82a8e11b32971b30fd3772fa4944ffffea Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 27 Sep 2026 20:02:20 +0200 Subject: [PATCH 08/20] docs(openspec): insight-knowledge-base, articles in Collectives linked to applications --- .../insight-knowledge-base/.openspec.yaml | 2 + .../changes/insight-knowledge-base/design.md | 52 +++++++++++++ .../insight-knowledge-base/proposal.md | 47 ++++++++++++ .../specs/application-knowledge-base/spec.md | 75 +++++++++++++++++++ .../changes/insight-knowledge-base/tasks.md | 41 ++++++++++ 5 files changed, 217 insertions(+) create mode 100644 openspec/changes/insight-knowledge-base/.openspec.yaml create mode 100644 openspec/changes/insight-knowledge-base/design.md create mode 100644 openspec/changes/insight-knowledge-base/proposal.md create mode 100644 openspec/changes/insight-knowledge-base/specs/application-knowledge-base/spec.md create mode 100644 openspec/changes/insight-knowledge-base/tasks.md diff --git a/openspec/changes/insight-knowledge-base/.openspec.yaml b/openspec/changes/insight-knowledge-base/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/insight-knowledge-base/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/insight-knowledge-base/design.md b/openspec/changes/insight-knowledge-base/design.md new file mode 100644 index 000000000..f43608fb4 --- /dev/null +++ b/openspec/changes/insight-knowledge-base/design.md @@ -0,0 +1,52 @@ +# Design: insight-knowledge-base + +Read at development 49e65cb4, with `@conduction/nextcloud-vue` 2.57.1 and OpenRegister development 4fee776. + +## Where it fits + +| Part | File and line | What changes | +|---|---|---| +| Page | `src/manifest.json:491` `ModuleDetail` | a widget `md-knowledge`, `type: integration`, `integrationId: collectives`, `requiredApp: collectives`, title Knowledge articles, and a layout row below `md-compliance` and `md-versions` (`:510` and `:511`) | +| Page and menu | new `src/manifest.d/insight-knowledge-base.json` (ADR-024, ADR-037) | page `KnowledgeBase` at `/knowledge`, `type: dashboard`, one `kb-search` widget; menu entry `KnowledgeBaseMenu` with `visibleIf.appInstalled: collectives`; the page has `requiresApp` Collectives | +| Menu layout | `src/menu-layout.json` `relocations` | `"KnowledgeBaseMenu": "Modules"`, so the entry is a child of Applications and not a new top-level entry (ADR-097) | +| Strings | `l10n/en.json`, `l10n/nl.json` | panel title, page title, hint and empty texts | + +No schema, controller, route, service or store in stackiq. The data is Nextcloud Collectives pages; the link rows are OpenRegister's (`lib/Service/CollectiveLinkService.php`, `lib/Db/CollectiveLinkMapper.php:51` `findByObjectUuid` in OpenRegister). + +## Decisions + +### D1. Articles are Collectives pages, linked through the OpenRegister leaf + +ADR-022's leaf catalogue maps "knowledge / wiki pages" to Collectives, leaf id `collectives`, and says an app MUST consume the leaf before building its own model. The leaf fits the row: a page is an article with a title, body, history and sharing in Collectives; OpenRegister links it to an object (`POST /api/objects/{register}/{schema}/{id}/collectives`, or `.../collectives/new` to create and link, OpenRegister `appinfo/routes.php:888` and `:889`); and the library draws the linked pages on the detail page with collective, emoji, last change and a deep link (`src/integrations/builtin/collectives.js`, tab `CnCollectivesTab`, card `CnCollectivesCard`). None of ADR-022's exceptions applies: there is no legal or statutory requirement the leaf misses. + +Rejected: a `knowledgeArticle` schema in the stackiq register with a title, markdown body, applications and category. It would be the "parallel data model" ADR-022 names as an anti-pattern, and it would need its own editor, history and sharing, which Collectives already has. + +Rejected: the `xwiki` leaf. It needs an outside xWiki and credentials held by integriq (ADR-091). Collectives runs inside Nextcloud. + +### D2. The application page keeps Documentation and adds Knowledge articles + +`md-files` (`src/manifest.json:501`) stays: attached files such as a manual PDF are documents, not articles. `md-knowledge` is placed in a new full-width row below `md-compliance` and `md-versions` (`gridY` 12, width 12), so the ADR-062 grid of the existing rows does not move. The widget declares `requiredApp: collectives`; `CnDetailPage` keeps such a widget and shows a set-up state when the app is missing (`src/components/CnDetailPage/CnDetailPage.vue:3780` in the library), which tells an administrator what to install instead of leaving a gap. + +### D3. The Knowledge base page is a `kb-search` widget on OpenRegister's page search + +The library's `kb-search` widget (`src/components/CnKbSearchWidget/index.js:14`) runs a provider; the built-in `default` provider sends `GET ?=&limit=` and reads `{ results }` (`src/utils/kbSearchProviders.js` `defaultKbProvider` and `normaliseKbResults`). OpenRegister's `GET /apps/openregister/api/integrations/collectives/available?search=` returns `{ results, total }` with `title` and `url` per page, over the collectives the user is a member of (`lib/Controller/CollectiveLinksController.php:272` in OpenRegister). So the page is configuration only: `content.endpoint` that URL, `content.queryParam` `search`. When Collectives is missing the endpoint answers 501 (`:273` to `:277`), the provider throws, and the widget shows its unavailable text. + +Rejected: a stackiq search endpoint over the linked pages. OpenRegister exposes links per object only (`findByObjectUuid`); a cross-object listing would need a new OpenRegister route, and the per-user collective search already answers "find the article". + +### D4. The menu entry is a child of Applications + +ADR-097 caps the main menu at six top-level entries and stackiq has fifteen. The fragment declares `KnowledgeBaseMenu`, and `src/menu-layout.json` relocates it under `Modules`, the way `Komplianties` and `ComplianceMatrix` are relocated under `ReportsCompliance` today. `visibleIf.appInstalled: collectives` hides it where Collectives is absent, the same key the Integrations entry uses for integriq (`src/manifest.d/connection-registry.json`). + +## Declarative versus imperative + +The change adds a relation widget and a search widget, both declared in the manifest (ADR-024, ADR-031). The relation between an application and an article is OpenRegister's link table behind the leaf; stackiq writes no code for it. + +## Seed data + +No schema changes. The demo import cannot create Collectives pages, because they belong to a user's collective. The e2e test creates a collective and a page through the Collectives app on the test instance, then links it from the application page. + +## Risks + +- **Collectives on CI.** The e2e test needs the Collectives app on the test instance. Where it is absent the test asserts the set-up state and the hidden menu entry instead, so the suite stays meaningful on both. +- **Title search only.** The stackiq page matches titles (OpenRegister `lib/Service/CollectiveLinkService.php:537`). The hint under the search box says so and links to Collectives for full text search. +- **A layout row next to other changes.** `landscape-application-page` also adds rows to `ModuleDetail`. Whichever lands second moves its `gridY` below the other's; both are appended rows, so nothing overlaps. diff --git a/openspec/changes/insight-knowledge-base/proposal.md b/openspec/changes/insight-knowledge-base/proposal.md new file mode 100644 index 000000000..75f933c59 --- /dev/null +++ b/openspec/changes/insight-knowledge-base/proposal.md @@ -0,0 +1,47 @@ +--- +kind: code +depends_on: [] +--- + +# Knowledge articles on applications, kept in Nextcloud Collectives + +## Summary + +An application owner who knows how an application is set up, how to recover it or what to tell new users has nowhere in stackiq to write that down. The application page has a Documentation files panel, and files are not articles anyone can search. This change keeps knowledge articles in Nextcloud Collectives, the knowledge base Nextcloud already ships, links them to the application through OpenRegister's `collectives` leaf, lists them on the application page, and adds a Knowledge base page under Applications where a user searches the articles without leaving stackiq. + +## Why + +This change covers one matrix row. + +- `stackiq:ins-kb`, "Keep knowledge articles about applications in a searchable knowledge base." Stackiq rates itself no. GLPI rates yes from its source at 11.0.9: `src/KnowbaseItem.php:57` knowledge base articles with categories and visibility, linked to items through `src/KnowbaseItem_Item.php:45` and shown on the appliance Knowledge base tab (`src/Appliance.php:104`); on the lab /front/knowbaseitem.php opens the knowledge base with Search and Browse. TOPdesk rates yes: "The Knowledge Base is set up and managed by your organization's knowledge managers. Every operator is able to use information from the Knowledge Base" (https://docs.topdesk.com/en/knowledge-management.html). + +The row has no stackiq half today and is built new. + +## What stackiq has today + +Read at development 49e65cb4, with `@conduction/nextcloud-vue` 2.57.1 and OpenRegister development 4fee776. + +- No article schema in `lib/Settings/softwarecatalogus_register.json`. Its schemas are sector, suite, catalogService, vulnerability, contactPerson, organization, usage, catalogContract, connection, software-review, element, view, model, property-definition, relation, module, compliancy, bioMeasure, moduleVersion and sbomComponent. +- `ModuleDetail` (`src/manifest.json:491`) has `md-files`, an `integration` widget on the `files` leaf titled Documentation (`:501`). Files can be attached, but they are not articles and stackiq does not search them. +- OpenRegister already ships a `collectives` leaf. It links an object to Nextcloud Collectives pages, creates a new page in a collective and links it, and lists the linked pages (routes `/api/objects/{register}/{schema}/{id}/collectives` and `/api/integrations/collectives/available` in OpenRegister `appinfo/routes.php:885` to `:890`, `lib/Service/CollectiveLinkService.php`). The library registers the matching tab and card (`src/integrations/builtin/collectives.js:37`, id `collectives`, required app `collectives`). +- The library also ships a `kb-search` dashboard widget (`src/components/CnKbSearchWidget/index.js:14`) whose default provider calls a configured endpoint with a configured query parameter (`src/utils/kbSearchProviders.js` `defaultKbProvider`). +- ADR-022 maps "knowledge / wiki pages" to the Collectives leaf and requires an app to consume the leaf rather than build its own store. + +## What this change builds + +- A Knowledge articles panel on the application page: the `collectives` leaf, where an application owner links an existing Collectives page or creates one, and every reader sees the linked articles with their collective, last change and a link into Collectives. +- A Knowledge base page at `/knowledge`, a child of the Applications menu entry, with a search box over the Collectives pages the user can read. A result opens the article in Collectives. +- Without the Collectives app the menu entry is hidden, and the panel on the application page shows the library's set-up state, which tells an administrator what to install. + +## Out of scope + +- The article store, editor, versions and sharing. They are Nextcloud Collectives. Who can read an article is the collective's membership, not a stackiq role. +- Full text search in article bodies from the stackiq page. OpenRegister's page search matches titles (`lib/Service/CollectiveLinkService.php:537` in OpenRegister); searching bodies is Collectives' own search, one click away. +- Limiting the search to one collective chosen by an administrator. A follow-up can add that as a provider option once there is demand. +- Articles on services, contracts or organisations. The same leaf fits their detail pages later; the row asks for applications. +- An xWiki knowledge base. OpenRegister has an `xwiki` leaf for organisations that use xWiki; this change uses Collectives because it runs inside Nextcloud and needs no outside credentials (ADR-091). + +## Risks + +- The knowledge base needs the Collectives app. On an instance without it the feature is absent, not broken: the menu entry is hidden, the panel shows a set-up state, and the page says Collectives is needed if reached by URL. +- Articles live under Collectives' access rules. A user who is not a member of the collective can see a linked title on the application page, and Collectives refuses when they open it. This change does not copy Collectives' membership into stackiq. diff --git a/openspec/changes/insight-knowledge-base/specs/application-knowledge-base/spec.md b/openspec/changes/insight-knowledge-base/specs/application-knowledge-base/spec.md new file mode 100644 index 000000000..ba5ee8ee8 --- /dev/null +++ b/openspec/changes/insight-knowledge-base/specs/application-knowledge-base/spec.md @@ -0,0 +1,75 @@ +# application-knowledge-base specification + +**Status**: proposed +**Scope**: stackiq +**OpenSpec changes**: +- insight-knowledge-base + +## Purpose + +An application owner keeps knowledge articles about an application in Nextcloud Collectives and links them to the application in stackiq. A municipal information manager finds those articles on the application page, or searches for them from the Knowledge base page under Applications. + +## ADDED Requirements + +### Requirement: REQ-AKB-001 The application detail page SHALL show the knowledge articles linked to the application + +`ModuleDetail` SHALL carry a Knowledge articles widget on OpenRegister's `collectives` leaf. It SHALL list the Collectives pages linked to the application with title, collective, last change and a link that opens the page in Collectives. The existing Documentation files panel SHALL stay. + +#### Scenario: An information manager reads the articles of an application +@e2e tests/e2e/spec-coverage/application-knowledge-base.spec.ts + +- **GIVEN** an application with one linked Collectives page titled Restore procedure +- **WHEN** a municipal information manager opens `/modules/:id` +- **THEN** the Knowledge articles panel SHALL list Restore procedure with its collective +- **AND** the Documentation panel SHALL still be on the page + +### Requirement: REQ-AKB-002 An application owner SHALL link an existing article or create a new one from the application page + +From the Knowledge articles widget an application owner SHALL link a Collectives page they can read, or create a page in one of their collectives and link it in one step. Unlinking SHALL remove the link and keep the page in Collectives. + +#### Scenario: An application owner writes a new article for an application +@e2e tests/e2e/spec-coverage/application-knowledge-base.spec.ts + +- **GIVEN** an application owner on `/modules/:id` who is a member of the collective Application knowledge +- **WHEN** they choose to create an article in that collective titled Onboarding new users +- **THEN** a page Onboarding new users SHALL exist in Collectives +- **AND** the Knowledge articles panel SHALL list it + +#### Scenario: Unlinking keeps the article +@e2e tests/e2e/spec-coverage/application-knowledge-base.spec.ts + +- **GIVEN** an application with a linked article +- **WHEN** the application owner unlinks it +- **THEN** the panel SHALL no longer list it +- **AND** the page SHALL still open in Collectives + +### Requirement: REQ-AKB-003 The Knowledge base page SHALL search the articles the user can read and open them in Collectives + +A page `KnowledgeBase` at `/knowledge`, reached from a menu entry that is a child of Applications, SHALL show a search box. Typing SHALL list the Collectives pages whose title matches, across the collectives the user is a member of, each with a link that opens the page in Collectives. A hint SHALL say the search matches titles. + +#### Scenario: An information manager finds an article by title +@e2e tests/e2e/spec-coverage/application-knowledge-base.spec.ts + +- **GIVEN** a municipal information manager who is a member of a collective with a page Restore procedure +- **WHEN** they open `/knowledge` and type restore +- **THEN** the results SHALL list Restore procedure +- **AND** choosing it SHALL open the page in Collectives + +#### Scenario: The entry sits under Applications +@e2e tests/e2e/spec-coverage/application-knowledge-base.spec.ts + +- **GIVEN** an instance with Collectives installed +- **WHEN** a municipal information manager opens stackiq +- **THEN** Knowledge base SHALL be a child of the Applications menu entry and not a top-level entry + +### Requirement: REQ-AKB-004 Without the Collectives app the knowledge base MUST be absent, not broken + +When the Collectives app is not installed the Knowledge base menu entry SHALL be hidden, `/knowledge` SHALL say that Collectives is needed, and the Knowledge articles widget on `ModuleDetail` SHALL show the library's set-up state instead of an error. + +#### Scenario: A Nextcloud admin without Collectives sees a set-up state +@e2e tests/e2e/spec-coverage/application-knowledge-base.spec.ts + +- **GIVEN** an instance without the Collectives app +- **WHEN** a Nextcloud admin opens `/modules/:id` +- **THEN** the Knowledge articles panel SHALL show a set-up state that names Collectives +- **AND** the menu SHALL have no Knowledge base entry diff --git a/openspec/changes/insight-knowledge-base/tasks.md b/openspec/changes/insight-knowledge-base/tasks.md new file mode 100644 index 000000000..75d5faed6 --- /dev/null +++ b/openspec/changes/insight-knowledge-base/tasks.md @@ -0,0 +1,41 @@ +# Tasks: insight-knowledge-base + +## Implementation tasks + +### Task 1: Put the Collectives leaf on the application page +- **spec_ref**: openspec/changes/insight-knowledge-base/specs/application-knowledge-base/spec.md#requirement-req-akb-001-the-application-detail-page-shall-show-the-knowledge-articles-linked-to-the-application +- **files**: `src/manifest.json`, `l10n/en.json`, `l10n/nl.json` +- **acceptance_criteria**: + - GIVEN an application with a linked Collectives page WHEN /modules/:id opens THEN the Knowledge articles panel lists it with collective and last change + - GIVEN an application owner WHEN they use the panel THEN they can link an existing page, create and link a new one, and unlink one + - GIVEN the page WHEN it renders THEN md-files is still there and md-knowledge sits in a new full-width row below the existing rows + - GIVEN an instance without Collectives WHEN /modules/:id opens THEN the panel shows the set-up state +- [ ] Implement +- [ ] Test (Playwright tests/e2e/spec-coverage/application-knowledge-base.spec.ts) + +### Task 2: Add the Knowledge base page and its menu entry under Applications +- **spec_ref**: openspec/changes/insight-knowledge-base/specs/application-knowledge-base/spec.md#requirement-req-akb-003-the-knowledge-base-page-shall-search-the-articles-the-user-can-read-and-open-them-in-collectives +- **files**: `src/manifest.d/insight-knowledge-base.json`, `src/menu-layout.json`, `tests/vitest/manifestFragments.spec.js`, `l10n/en.json`, `l10n/nl.json` +- **acceptance_criteria**: + - GIVEN the merged manifest WHEN it is built THEN KnowledgeBase exists at /knowledge with one kb-search widget whose endpoint is the OpenRegister collectives page search and whose queryParam is search + - GIVEN the merged menu WHEN it is built THEN KnowledgeBaseMenu is a child of Modules and the top-level count does not grow + - GIVEN a title match WHEN the user types on /knowledge THEN the result links into Collectives + - GIVEN no Collectives app WHEN stackiq opens THEN the entry is hidden and /knowledge says Collectives is needed +- [ ] Implement +- [ ] Test (vitest tests/vitest/manifestFragments.spec.js and Playwright tests/e2e/spec-coverage/application-knowledge-base.spec.ts) + +### Task 3: Document the knowledge base +- **spec_ref**: openspec/changes/insight-knowledge-base/specs/application-knowledge-base/spec.md#requirement-req-akb-004-without-the-collectives-app-the-knowledge-base-must-be-absent-not-broken +- **files**: `docs/features/application-knowledge-base.md` +- **acceptance_criteria**: + - GIVEN the docs WHEN a reader opens the feature page THEN it explains that articles live in Collectives, shows the panel and the search page in screenshots, and says what an administrator installs first +- [ ] Implement +- [ ] Test (docs build and a manual read against the running app) + +## Verification + +- `openspec validate insight-knowledge-base --type change --strict` +- vitest: tests/vitest/manifestFragments.spec.js +- Playwright: tests/e2e/spec-coverage/application-knowledge-base.spec.ts, on an instance with Collectives and on one without +- Docs in docs/features/application-knowledge-base.md with screenshots (ADR-010) +- English and Dutch strings for the panel title, the page title, the hint and the empty and unavailable texts (ADR-005) From c9eaac2160a4316f02ffa090483a9b5b2a13b329 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 27 Sep 2026 20:07:30 +0200 Subject: [PATCH 09/20] docs(openspec): operations-sync-status-and-progress, shared progress, last run and a sync page for functional administrators --- .../.openspec.yaml | 2 + .../design.md | 66 +++++++++++++++ .../proposal.md | 54 +++++++++++++ .../specs/sync-status-and-progress/spec.md | 80 +++++++++++++++++++ .../tasks.md | 70 ++++++++++++++++ 5 files changed, 272 insertions(+) create mode 100644 openspec/changes/operations-sync-status-and-progress/.openspec.yaml create mode 100644 openspec/changes/operations-sync-status-and-progress/design.md create mode 100644 openspec/changes/operations-sync-status-and-progress/proposal.md create mode 100644 openspec/changes/operations-sync-status-and-progress/specs/sync-status-and-progress/spec.md create mode 100644 openspec/changes/operations-sync-status-and-progress/tasks.md diff --git a/openspec/changes/operations-sync-status-and-progress/.openspec.yaml b/openspec/changes/operations-sync-status-and-progress/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/operations-sync-status-and-progress/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/operations-sync-status-and-progress/design.md b/openspec/changes/operations-sync-status-and-progress/design.md new file mode 100644 index 000000000..49d37c998 --- /dev/null +++ b/openspec/changes/operations-sync-status-and-progress/design.md @@ -0,0 +1,66 @@ +# Design: operations-sync-status-and-progress + +Read at development 49e65cb4, with `@conduction/nextcloud-vue` 2.57.1. + +## Where it fits + +| Part | File and line | What changes | +|---|---|---| +| Service | `lib/Service/ProgressTracker.php` (constructor `:82`, `saveProgress()`, `getProgress()` `:332`, `PHASES` `:40`) | state in `ICacheFactory::createDistributed('stackiq_progress')` instead of `ISession`; a `current_` key per operation type; sync phases `processing_contacts` and `processing_users` | +| Wiring | `lib/AppInfo/Application.php:593` to `:600` | the factory passes `ICacheFactory` instead of `ISession` | +| Service | `lib/Service/OrganizationSyncService.php:2976` `performScheduledSync()`, `:2881` `performOptimizedManualSync()`, `:1674` `recordSyncTime()` | start an `organisation_sync` operation, report per batch, complete it, and write app config `last_sync_result` next to `last_sync_time` | +| Job | `lib/BackgroundJob/OrganizationContactSyncJob.php:75` and `:99` | interval as a class constant `INTERVAL_SECONDS`; `run()` returns early when the switch is off | +| Settings | `lib/Service/SettingsService.php:6827` `getCronjobConfig()`, `:6878` `getAvailableCronjobs()` | new `isCronjobEnabled(string $jobId)`; the metadata interval reads `OrganizationContactSyncJob::INTERVAL_SECONDS` | +| Controller and route | new `lib/Controller/SyncStatusController.php`, `GET /api/sync/status` in `appinfo/routes.php` | schedule, last run and current run, for admins and functional administrators | +| Policy | new `lib/Service/SyncAccessPolicy.php` | one place that answers "may this user see the sync": Nextcloud admin or member of `functioneel-beheerder` | +| Controller | `lib/Controller/SettingsController.php:1289` `getProgress()` | read rule: owner, admin, or policy for `organisation_sync` | +| Page | new `src/manifest.d/operations-sync-status-and-progress.json`: page `SyncStatus` at `/organisaties/synchronisation`, `type: custom`, component `SyncStatusView`; menu entry `SyncStatusMenu` with `permission: sync.view` | | +| Menu layout | `src/menu-layout.json` `relocations` | `"SyncStatusMenu": "Organisaties"` (ADR-097) | +| View | new `src/views/sync/SyncStatusView.vue`, registered in `src/customComponents.js` | `CnProgressBar` and `CnWidgetWrapper` from the library; polls while a run is going | +| Shell | `lib/Controller/DashboardController.php:54` `page()`, `src/App.vue:181` `permissions()` | initial state `permissions` (`user`, plus `admin`, plus `sync.view`), read by the shell instead of `window.OC.currentUser.permissions` | + +## Decisions + +### D1. Progress moves from the session to the distributed cache + +`ProgressTracker` writes `progress_` into `ISession`. A PHP session belongs to one browser: the five-minute job runs from cron with no user session, and a page in a second tab of another user can never read it. The distributed cache (the same mechanism `FacetService` uses, `lib/Service/FacetService.php:131`) is shared by every request and by cron. Entries live for one hour after their last write. The public methods and the response shape stay as they are, so `SbomImportService` and `MergeOrganisatieService` keep working unchanged. + +A `current_` entry points at the running operation of a type, so the page can find the running sync without knowing its id; `completeOperation()` clears it. + +Rejected: a database table of runs. A run in progress is transient state that nobody needs after an hour, and the last result is one small record (D3). + +Rejected: the SSE stream (`streamProgress()`, `:1360`) for the page. Polling `GET /api/sync/status` every three seconds while a run is going works behind every proxy and needs no long-lived PHP worker; the stream stays for its current callers. + +### D2. The job honours the switch + +`CronjobConfiguration.vue:56` lets an admin switch the sync off and stores it in `cronjob_config`, but `OrganizationContactSyncJob::run()` (`:99`) never reads it. A page that says Off while the job keeps running would be worse than no page. `run()` asks `SettingsService::isCronjobEnabled('organization_contact_sync')` (default true when the key is absent, the same default `getCronjobConfig()` uses at `:6847`) and logs and returns when it is false. `isCronjobEnabled()` reads only the `enabled` key, so it does not depend on the deprecated user and organisation context in the same config. + +The interval shown is the one the job sets. Today it is written twice, `setInterval(seconds: 300)` in the job and `'interval' => 300` in `getAvailableCronjobs()` (`:6883`); both read one class constant so they cannot disagree. + +### D3. The last run is a small record in app config + +Next to `last_sync_time`, `recordSyncTime()` also writes `last_sync_result`: started at, finished at, trigger (scheduled or manual), the counts the sync already returns (organisations processed, entities created and updated, contact persons processed, users created) and the number of errors. It is operational state of the app, like `last_sync_time`, not catalogue data, so it stays in app config and not in OpenRegister. + +### D4. Who sees the sync + +`SyncAccessPolicy` allows a Nextcloud admin or a member of `functioneel-beheerder`, the group `GroupHandler` keeps in step with the contact person role Functioneel-beheerder (`lib/Service/Stackiq/GroupHandler.php:170`). `SyncStatusController` uses it with `#[NoAdminRequired]` and returns 403 otherwise. `getProgress()` keeps its owner check and adds: an admin may read any operation, and the policy may read `organisation_sync` operations. An operation id alone never grants access. + +When `organisations-role-mapping-and-access-review` makes the role's group configurable, the policy reads the mapped group; it is the only place that names the group. + +### D5. The menu shows the entry only to those users + +`CnAppNav` checks an entry's `permission` against the shell's `permissions` list, and lets everything through when the list is empty (`src/components/CnAppNav/CnAppNav.vue:997` in the library). Stackiq's shell reads `window.OC.currentUser.permissions` (`src/App.vue:182`), which Nextcloud does not set, so today the list is always empty. `DashboardController::page()` provides initial state `permissions`: `user` for every signed-in user, `admin` for Nextcloud admins, and `sync.view` for users the policy allows. The list is never empty, so an entry with a permission is hidden from users without it. + +## Declarative versus imperative + +Progress and the schedule are runtime state of a background job, not object lifecycle, aggregation, notification or relation behaviour. Nothing here can be an `x-openregister-*` rule (ADR-031), so it stays in the service and the job. + +## Seed data + +No schema changes and no seed objects. + +## Risks + +- **Cache without a distributed backend.** On an instance with only a local cache, cron and web requests may not share entries. The page then shows the last run and no live bar, and says that live progress needs a shared cache such as Redis. +- **The switch starts to work.** A migration step logs every instance where `cronjob_config` has the sync switched off, so an administrator can see why syncs stop after the upgrade. +- **Permission enforcement.** D5 makes the Integrations entry's `permission: admin` (`src/manifest.d/connection-registry.json:15`) take effect for the first time, which is what that entry declares. diff --git a/openspec/changes/operations-sync-status-and-progress/proposal.md b/openspec/changes/operations-sync-status-and-progress/proposal.md new file mode 100644 index 000000000..c4ca21874 --- /dev/null +++ b/openspec/changes/operations-sync-status-and-progress/proposal.md @@ -0,0 +1,54 @@ +--- +kind: code +depends_on: [] +--- + +# See the synchronisation schedule, its last run and a running sync + +## Summary + +Stackiq synchronises organisations, contact persons and users every five minutes. Only a Nextcloud admin can see that it happens, in admin settings, and even they see the last run time and a spinner, never how far a running sync is. The functional administrator of the catalogue, who answers "why is our new colleague not in the catalogue yet", sees nothing. This change records the progress of a synchronisation where any request can read it, keeps the result of the last run, makes the schedule's on and off switch real, and adds a Synchronisation page under Organisations for the Nextcloud admin and the functional administrator. + +## Why + +This change covers two matrix rows. + +- `stackiq:ins-progress`, "Follow the progress of a long synchronisation or import." Stackiq rates itself partial: a progress API exists but no page reads it. SAP LeanIX rates yes: "A real-time progress widget in the inventory side-panel keeps you informed of import status" (https://updates.leanix.net/announcements/product-update-march-2026), and integration runs are followed in Administration, Integrations, Sync Log (https://help.sap.com/docs/leanix/ea/collibra-data-catalog-integration). GLPI rates yes from its source at 11.0.9: massive actions show a progress bar (`src/MassiveAction.php:1294` `displayProgressBar`) and long web operations report through `src/Glpi/Controller/ProgressController.php:50` `/progress/check/{key}`. +- `stackiq:ops-scheduled-sync`, "Run the organisation and contact synchronisation on a schedule and see when it last ran." Stackiq rates itself partial: the sync runs on a schedule and shows its last run time, but only to an admin. SAP LeanIX rates yes: "enable automated nightly runs for an inbound Integration API processor" (https://help.sap.com/docs/leanix/ea/configuring-automated-nightly-runs-for-inbound-processors). BlueDolphin rates yes: "This data can then be synchronized with BlueDolphin based on a scheduled task, periodically (for example, every hour)" (https://help.bluedolphin.io/en/articles/11967472-welcome-to-bluedolphin). + +Both rows are partial and built. For `ins-progress` this change builds a page that shows the progress of a running synchronisation; for `ops-scheduled-sync` it builds the schedule and last run for the organisation's functional administrator, not only the Nextcloud admin. + +The row `stackiq:arch-import-progress` (follow and cancel a running ArchiMate import) is marked building elsewhere and is not part of this change. + +## What stackiq has today + +Read at development 49e65cb4. + +- `lib/Controller/SettingsController.php:1289` `getProgress()` and `:1360` `streamProgress()` (`appinfo/routes.php:119` and `:120`) serve `lib/Service/ProgressTracker.php`. No file under `src/` calls `/api/progress`. +- `ProgressTracker` keeps its state in the PHP session (`ISession`, constructor at `:82`, `saveProgress()` writes `progress_` into the session, `getProgress()` at `:332` reads it back). A session belongs to one browser, so a background job has none to write to and another user can never read it. Its phases are ArchiMate import phases (`:40` `PHASES`). Only `SbomImportService` (`:144`) and `MergeOrganisatieService` (`:242`) start operations. +- `lib/BackgroundJob/OrganizationContactSyncJob.php:75` sets a 300 second interval and `run()` (`:99`) calls `OrganizationSyncService::performScheduledSync()` (`lib/Service/OrganizationSyncService.php:2976`) every time. It never reads the enable switch that `src/views/settings/sections/CronjobConfiguration.vue:56` shows and stores in app config `cronjob_config`; only the deprecated context helpers read that key (`lib/Service/SettingsService.php:6827`, `:6984`). The switch changes nothing. +- The last run is one timestamp, app config `last_sync_time`, written by `recordSyncTime()` (`lib/Service/OrganizationSyncService.php:1674`) and read into the status at `:1609`. Counts and errors of that run are logged, not kept. +- `src/views/settings/sections/OrganizationSynchronization.vue:211` shows Last sync inside admin settings. The ArchiMate import shows a spinner and then the final count (`src/views/settings/sections/ArchiMateImportExport.vue:103`). +- The functional administrator role exists as the Nextcloud group `functioneel-beheerder`, created by `lib/Service/Stackiq/GroupHandler.php:170` from the contact person role Functioneel-beheerder. + +## What this change builds + +- `ProgressTracker` stores progress in Nextcloud's distributed cache instead of the session, so a background job can write it and a page in another request can read it, with the sync phases added. +- The scheduled and the manual organisation sync report progress per batch and keep a last run record: start, end, trigger, counts and number of errors. +- The job honours the enable switch. +- A Synchronisation page at `/organisaties/synchronisation`, a child of the Organisations menu entry, for Nextcloud admins and functional administrators: the schedule (interval, on or off), the last run, and a progress bar while a run is going. +- A permission list for the app shell, so the menu shows that entry only to those users. + +## Out of scope + +- Following and cancelling the ArchiMate import: row `stackiq:arch-import-progress`. +- Changing the schedule from the page. The interval and switch stay in admin settings; the page reads them. +- A history of past runs. The page shows the last run; a run log can follow if administrators ask for it. +- Starting a sync from the page. The manual sync stays an admin settings action. +- Synchronisation with outside systems. Those runs belong to integriq (ADR-091). + +## Risks + +- Moving progress out of the session changes who can read it. The read rule becomes explicit: the owner of an operation, a Nextcloud admin, and for the sync operation also the functional administrator. An operation id alone never grants access. +- Honouring the switch means a switch that was off by mistake now stops the sync. The migration step logs every instance where it is off, and the page shows Off in plain sight. +- The permission list turns on the menu `permission` check for the first time; the Integrations entry (`src/manifest.d/connection-registry.json:15`, `permission: admin`) then really hides for non-admins, which is what it declares. diff --git a/openspec/changes/operations-sync-status-and-progress/specs/sync-status-and-progress/spec.md b/openspec/changes/operations-sync-status-and-progress/specs/sync-status-and-progress/spec.md new file mode 100644 index 000000000..8fa91cadb --- /dev/null +++ b/openspec/changes/operations-sync-status-and-progress/specs/sync-status-and-progress/spec.md @@ -0,0 +1,80 @@ +# sync-status-and-progress specification + +**Status**: proposed +**Scope**: stackiq +**OpenSpec changes**: +- operations-sync-status-and-progress + +## Purpose + +A functional administrator or a Nextcloud admin sees whether the organisation and contact synchronisation is on, how often it runs, what its last run did, and how far a running sync is, on a page under Organisations. + +## ADDED Requirements + +### Requirement: REQ-SSP-001 Progress of a long operation SHALL be readable from any request, and only by users allowed to read it + +`ProgressTracker` SHALL keep progress in Nextcloud's distributed cache, so a background job can write it and another request can read it. `GET /api/progress/{operationId}` SHALL answer the operation's owner and Nextcloud admins, and for an `organisation_sync` operation also members of the functional administrator group. Anyone else SHALL get 404, the same answer as for an unknown id. + +#### Scenario: A running sync started by cron is readable +@e2e exclude Needs a cron run in the middle of a request; tests/Unit/Service/ProgressTrackerTest.php asserts a second tracker instance on the same cache reads the first one's progress, and tests/Unit/Controller/SettingsControllerProgressTest.php asserts the read rule. + +- **GIVEN** the scheduled sync running from cron with operation type `organisation_sync` +- **WHEN** a functional administrator's page calls `GET /api/progress/{operationId}` for it +- **THEN** stackiq SHALL answer 200 with the phase, the processed and total items and the percentage + +#### Scenario: Another user cannot read an operation by guessing its id +@e2e exclude An authorisation rule; tests/Unit/Controller/SettingsControllerProgressTest.php asserts 404 for a user who is not the owner, not an admin and not allowed by the sync policy. + +- **GIVEN** an SBOM import started by an application owner +- **WHEN** another user without admin rights calls `GET /api/progress/{operationId}` with its id +- **THEN** stackiq SHALL answer 404 + +### Requirement: REQ-SSP-002 The organisation sync SHALL report its progress and keep the result of its last run + +The scheduled and the manual organisation sync SHALL start an `organisation_sync` operation, report progress per batch through the organisations, contact persons and users phases, and complete it. When a run ends it SHALL store the start, the end, the trigger (scheduled or manual), the counts and the number of errors as the last run. + +#### Scenario: A finished run leaves its result +@e2e exclude The sync touches every organisation; tests/Unit/Service/OrganizationSyncServiceTest.php asserts performScheduledSync() starts and completes an operation and writes last_sync_result with trigger scheduled and its counts. + +- **GIVEN** a scheduled run that processes 12 organisations and 30 contact persons with 1 error +- **WHEN** the run ends +- **THEN** the last run record SHALL hold trigger scheduled, 12 organisations, 30 contact persons and 1 error + +### Requirement: REQ-SSP-003 The scheduled sync MUST NOT run while its switch is off + +`OrganizationContactSyncJob` SHALL read the enable switch stored by the admin cronjob settings before each run and SHALL skip the run, with a log line, when it is off. The interval the job uses and the interval stackiq shows SHALL come from one constant. + +#### Scenario: A Nextcloud admin switches the sync off +@e2e exclude A background job; tests/Unit/BackgroundJob/OrganizationContactSyncJobTest.php asserts run() does not call performScheduledSync() when isCronjobEnabled() returns false, and does when the key is absent. + +- **GIVEN** a Nextcloud admin who switched Organization Contact Sync off in admin settings +- **WHEN** cron reaches the job +- **THEN** the job SHALL NOT synchronise +- **AND** the Synchronisation page SHALL show the schedule as Off + +### Requirement: REQ-SSP-004 The Synchronisation page SHALL show the schedule, the last run and a running sync to Nextcloud admins and functional administrators + +A page `SyncStatus` at `/organisaties/synchronisation`, reached from a menu entry under Organisations, SHALL show the interval and whether the sync is on, the last run with its time, trigger, counts and errors, and while a run is going a progress bar with its phase. It SHALL read `GET /api/sync/status`, which SHALL answer Nextcloud admins and members of the functional administrator group and SHALL refuse others with 403. The menu entry SHALL be shown only to those users. + +#### Scenario: A functional administrator checks when the sync last ran +@e2e tests/e2e/spec-coverage/sync-status.spec.ts + +- **GIVEN** a functional administrator who is not a Nextcloud admin, and a sync that last ran ten minutes ago +- **WHEN** they open Organisations and choose Synchronisation +- **THEN** the page SHALL show every 5 minutes, On, and the last run with its time, trigger and counts + +#### Scenario: A running sync shows its progress +@e2e tests/e2e/spec-coverage/sync-status.spec.ts + +- **GIVEN** a Nextcloud admin who starts a manual sync in admin settings +- **WHEN** they open `/organisaties/synchronisation` in another tab +- **THEN** the page SHALL show a progress bar with the current phase +- **AND** when the run ends the bar SHALL give way to the new last run + +#### Scenario: A regular user neither sees nor reaches the page +@e2e tests/e2e/spec-coverage/sync-status.spec.ts + +- **GIVEN** a municipal information manager who is not an admin and not a functional administrator +- **WHEN** they open stackiq +- **THEN** the Organisations menu SHALL have no Synchronisation entry +- **AND** `GET /api/sync/status` SHALL answer 403 diff --git a/openspec/changes/operations-sync-status-and-progress/tasks.md b/openspec/changes/operations-sync-status-and-progress/tasks.md new file mode 100644 index 000000000..5f91d89fa --- /dev/null +++ b/openspec/changes/operations-sync-status-and-progress/tasks.md @@ -0,0 +1,70 @@ +# Tasks: operations-sync-status-and-progress + +## Implementation tasks + +### Task 1: Keep progress in the distributed cache and tighten who may read it +- **spec_ref**: openspec/changes/operations-sync-status-and-progress/specs/sync-status-and-progress/spec.md#requirement-req-ssp-001-progress-of-a-long-operation-shall-be-readable-from-any-request-and-only-by-users-allowed-to-read-it +- **files**: `lib/Service/ProgressTracker.php`, `lib/AppInfo/Application.php`, `lib/Service/SyncAccessPolicy.php`, `lib/Controller/SettingsController.php`, `tests/Unit/Service/ProgressTrackerTest.php`, `tests/Unit/Controller/SettingsControllerProgressTest.php` +- **acceptance_criteria**: + - GIVEN two tracker instances on the same cache WHEN one writes progress THEN the other reads it + - GIVEN a running operation of a type WHEN the current entry is read THEN it names that operation, and after completeOperation() it is empty + - GIVEN a caller who is not the owner, not an admin and not allowed by the policy WHEN they read an operation THEN they get 404 + - GIVEN SbomImportService and MergeOrganisatieService WHEN their existing tests run THEN they pass unchanged +- [ ] Implement +- [ ] Test (PHPUnit tests/Unit/Service/ProgressTrackerTest.php and tests/Unit/Controller/SettingsControllerProgressTest.php) + +### Task 2: Report sync progress and keep the last run +- **spec_ref**: openspec/changes/operations-sync-status-and-progress/specs/sync-status-and-progress/spec.md#requirement-req-ssp-002-the-organisation-sync-shall-report-its-progress-and-keep-the-result-of-its-last-run +- **files**: `lib/Service/OrganizationSyncService.php`, `tests/Unit/Service/OrganizationSyncServiceTest.php` +- **acceptance_criteria**: + - GIVEN a scheduled or manual sync WHEN it runs THEN it starts an organisation_sync operation, moves through the organisations, contact persons and users phases, and completes it + - GIVEN a run that ends WHEN recordSyncTime() runs THEN last_sync_result holds start, end, trigger, counts and error count +- [ ] Implement +- [ ] Test (PHPUnit tests/Unit/Service/OrganizationSyncServiceTest.php) + +### Task 3: Make the job honour its switch and share one interval +- **spec_ref**: openspec/changes/operations-sync-status-and-progress/specs/sync-status-and-progress/spec.md#requirement-req-ssp-003-the-scheduled-sync-must-not-run-while-its-switch-is-off +- **files**: `lib/BackgroundJob/OrganizationContactSyncJob.php`, `lib/Service/SettingsService.php`, `lib/Migration/` (a repair step that logs a switched-off sync), `tests/Unit/BackgroundJob/OrganizationContactSyncJobTest.php` +- **acceptance_criteria**: + - GIVEN the switch off WHEN run() is called THEN performScheduledSync() is not called and a log line says why + - GIVEN no cronjob_config key WHEN run() is called THEN the sync runs + - GIVEN the job and getAvailableCronjobs() WHEN their interval is read THEN both return INTERVAL_SECONDS +- [ ] Implement +- [ ] Test (PHPUnit tests/Unit/BackgroundJob/OrganizationContactSyncJobTest.php) + +### Task 4: Add the sync status endpoint +- **spec_ref**: openspec/changes/operations-sync-status-and-progress/specs/sync-status-and-progress/spec.md#requirement-req-ssp-004-the-synchronisation-page-shall-show-the-schedule-the-last-run-and-a-running-sync-to-nextcloud-admins-and-functional-administrators +- **files**: `lib/Controller/SyncStatusController.php`, `appinfo/routes.php`, `tests/Unit/Controller/SyncStatusControllerTest.php` +- **acceptance_criteria**: + - GIVEN an admin or a functional administrator WHEN they call GET /api/sync/status THEN they get interval, enabled, last run and the current run or null + - GIVEN any other signed-in user WHEN they call it THEN they get 403 +- [ ] Implement +- [ ] Test (PHPUnit tests/Unit/Controller/SyncStatusControllerTest.php) + +### Task 5: Add the Synchronisation page and the permission list +- **spec_ref**: openspec/changes/operations-sync-status-and-progress/specs/sync-status-and-progress/spec.md#requirement-req-ssp-004-the-synchronisation-page-shall-show-the-schedule-the-last-run-and-a-running-sync-to-nextcloud-admins-and-functional-administrators +- **files**: `src/manifest.d/operations-sync-status-and-progress.json`, `src/menu-layout.json`, `src/views/sync/SyncStatusView.vue`, `src/customComponents.js`, `lib/Controller/DashboardController.php`, `src/App.vue`, `tests/vitest/syncStatusView.spec.js`, `l10n/en.json`, `l10n/nl.json` +- **acceptance_criteria**: + - GIVEN a functional administrator WHEN they open Organisations THEN Synchronisation is a child entry and the page shows interval, On or Off and the last run + - GIVEN a running sync WHEN the page is open THEN it polls every three seconds and shows the phase and percentage, and stops polling when the run ends + - GIVEN a regular user WHEN the menu renders THEN the entry is hidden, and the Integrations entry is hidden for non-admins + - GIVEN a Dutch instance WHEN the page renders THEN every label is Dutch +- [ ] Implement +- [ ] Test (vitest tests/vitest/syncStatusView.spec.js and Playwright tests/e2e/spec-coverage/sync-status.spec.ts) + +### Task 6: Document the Synchronisation page +- **spec_ref**: openspec/changes/operations-sync-status-and-progress/specs/sync-status-and-progress/spec.md#requirement-req-ssp-004-the-synchronisation-page-shall-show-the-schedule-the-last-run-and-a-running-sync-to-nextcloud-admins-and-functional-administrators +- **files**: `docs/features/sync-status-and-progress.md` +- **acceptance_criteria**: + - GIVEN the docs WHEN a reader opens the feature page THEN it shows the page with a last run and with a running sync, and says who can see it +- [ ] Implement +- [ ] Test (docs build and a manual read against the running app) + +## Verification + +- `openspec validate operations-sync-status-and-progress --type change --strict` +- PHPUnit: tests/Unit/Service/ProgressTrackerTest.php, tests/Unit/Controller/SettingsControllerProgressTest.php, tests/Unit/Service/OrganizationSyncServiceTest.php, tests/Unit/BackgroundJob/OrganizationContactSyncJobTest.php, tests/Unit/Controller/SyncStatusControllerTest.php, and the existing SBOM import and organisation merge tests +- vitest: tests/vitest/syncStatusView.spec.js +- Playwright: tests/e2e/spec-coverage/sync-status.spec.ts +- Docs in docs/features/sync-status-and-progress.md with screenshots (ADR-010) +- English and Dutch strings for the page, the phases, the states and the menu entry (ADR-005) From aff90b51e55b5a4b1374fceffc987b58c382804b Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 27 Sep 2026 20:10:06 +0200 Subject: [PATCH 10/20] docs(openspec): sharing-generated-api-docs, generated OpenAPI documents read in the app --- .../sharing-generated-api-docs/.openspec.yaml | 2 + .../sharing-generated-api-docs/design.md | 43 +++++++++++++++ .../sharing-generated-api-docs/proposal.md | 41 +++++++++++++++ .../specs/generated-api-docs/spec.md | 52 +++++++++++++++++++ .../sharing-generated-api-docs/tasks.md | 42 +++++++++++++++ 5 files changed, 180 insertions(+) create mode 100644 openspec/changes/sharing-generated-api-docs/.openspec.yaml create mode 100644 openspec/changes/sharing-generated-api-docs/design.md create mode 100644 openspec/changes/sharing-generated-api-docs/proposal.md create mode 100644 openspec/changes/sharing-generated-api-docs/specs/generated-api-docs/spec.md create mode 100644 openspec/changes/sharing-generated-api-docs/tasks.md diff --git a/openspec/changes/sharing-generated-api-docs/.openspec.yaml b/openspec/changes/sharing-generated-api-docs/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/sharing-generated-api-docs/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/sharing-generated-api-docs/design.md b/openspec/changes/sharing-generated-api-docs/design.md new file mode 100644 index 000000000..da2f8a4f7 --- /dev/null +++ b/openspec/changes/sharing-generated-api-docs/design.md @@ -0,0 +1,43 @@ +# Design: sharing-generated-api-docs + +Read at development `9a5ece6a`, OpenRegister development `4fee776`. + +## Context + +Two APIs serve the catalogue. The objects themselves (applications, services, usages, connections, contracts) are OpenRegister objects in the `stackiq` register, served by OpenRegister's objects API and described by OpenRegister's generated OpenAPI document (`GET /apps/openregister/api/registers/{id}/oas`, `openregister appinfo/routes.php:1670`, public and rate limited at `lib/Controller/OasController.php:82-92`). Stackiq adds its own endpoints on top (`appinfo/routes.php`, 132 routes), among them public ones for offers (`AanbodController`), offered usage (`AangebodenGebruikController`, including `GET /api/koppelingen-gebruik/{uuid}` at :269) and views (`ViewController`). Those are described only by hand (`ViewController.php:373`, `AangebodenGebruikController.php:866`, `docs/API_REFERENCE.md`), and `openapi.json` holds no paths. + +## D1. Generate stackiq's own spec with the Nextcloud extractor + +Add `nextcloud/openapi-extractor` as a dev dependency (in `vendor-bin/openapi-extractor/composer.json`, the Nextcloud convention, so it does not touch the runtime lock) and point `composer.json:43` at `vendor-bin/openapi-extractor/vendor/bin/generate-spec`. Annotate the external controllers so the extractor can read them: `#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]` on public and user-facing controllers, `#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]` on admin settings controllers, and psalm return shapes (`@return JSONResponse<200, array{...}, array{}>`) with `@psalm-type` definitions in a new `lib/ResponseDefinitions.php`, the file the extractor reads for shared schemas. + +A new composer script `openapi:check` regenerates into a temp file and fails when it differs from the committed `openapi.json`; `check:strict` runs it after `phpstan`. + +Rejected: writing the spec by hand. The hand-written endpoints are the problem the row names: they drift, and nothing checks them. + +## D2. One documentation page, two documents + +New page `ApiDocumentation` (`/api-docs`), a custom view `src/views/ApiDocumentationView.vue`, with a footer menu entry beside Documentation (`src/manifest.json:183`). Two tabs through the library's `CnTabs`: + +- Catalogue objects: fetches `/apps/openregister/api/registers/{stackiq register id}/oas` (the id from the app's register resolver). +- Stackiq endpoints: fetches the bundled `openapi.json`, served by a small `GET /api/openapi` route (`ApiDocsController`, public, rate limited like OpenRegister's). + +A reader component `src/components/api/OpenApiReader.vue` renders a document in the app: operations grouped by tag, each with method, path, parameters, request body and responses, and schemas with their properties, plus a Download JSON button. + +Rejected: opening the hosted Redoc viewer as OpenRegister's admin screen does. It loads remote script from a third party and sends the document URL there, which a government instance should not do by default. + +## D3. The hand-written endpoints + +`GET /api/views/docs` and `GET /api/aangeboden-gebruik/docs` keep answering, with their current body plus a `documentation` link to `/apps/stackiq/api-docs`, and their docblocks marked deprecated. `docs/API_REFERENCE.md` gets a first paragraph that points to the page and the two OpenAPI files. + +## Declarative versus imperative + +The catalogue objects' document is OpenRegister's, generated from the schemas. Stackiq's own document is generated from annotations at build time. The page only reads. + +## Seed data + +None. + +## Risks + +- The extractor refuses controllers with untyped responses; the first run lists them. Task 1 annotates the public controllers first, then the user-facing ones. +- The OpenRegister document grows with the register; the reader renders operations lazily per tag. diff --git a/openspec/changes/sharing-generated-api-docs/proposal.md b/openspec/changes/sharing-generated-api-docs/proposal.md new file mode 100644 index 000000000..569513be7 --- /dev/null +++ b/openspec/changes/sharing-generated-api-docs/proposal.md @@ -0,0 +1,41 @@ +--- +kind: code +depends_on: [] +--- + +# Generated documentation of the catalogue API + +## Summary + +A developer at a municipality or a supplier reads the catalogue API from generated documentation instead of hand-written pages: the catalogue objects as OpenRegister describes them, and stackiq's own endpoints as the code describes them. Both open from one API documentation page in stackiq, and both download as OpenAPI files. The generated file in the repository stops being empty. + +## Why + +Row from the stackiq matrix: + +- `stackiq:share-api-docs`, "Read generated documentation of the catalogue API." Rated partial, built. Four competitors rate yes: SAP LeanIX (https://help.sap.com/docs/leanix/ea/sap-leanix-apis, "we provide the OpenAPI explorer"), BlueDolphin (https://help.bluedolphin.io/en/articles/11967733-quick-start-guide, Swagger documentation of the public API), GLPI (source read at 11.0.9, `src/Glpi/Api/HL/Controller/CoreController.php:322` serves a Swagger UI over the spec `src/Glpi/Api/HL/OpenAPIGenerator.php` builds) and TOPdesk (https://developers.topdesk.com/). The missing half: a generated OpenAPI description of the catalogue API. + +No tender, feature request or roadmap row names it. + +## What stackiq has today + +- `openapi.json` at the repository root has an `info` block and no paths. `composer.json:43` names a script `openapi` that calls `generate-spec`, which no installed package provides, and no controller carries the annotations an extractor reads. +- Two hand-written JSON documentation endpoints: `GET /api/views/docs` (`lib/Controller/ViewController.php:373`, login only) and `GET /api/aangeboden-gebruik/docs` (`lib/Controller/AangebodenGebruikController.php:866`, public), plus markdown in `docs/API_REFERENCE.md` and `docs/View_API.md` on the docs site. No page in `src/` calls either endpoint. +- `appinfo/routes.php` registers 132 routes on stackiq's own controllers. +- OpenRegister already generates an OpenAPI document per register: `GET /apps/openregister/api/registers/{id}/oas` (openregister `appinfo/routes.php:1670`, public with a rate limit, `lib/Controller/OasController.php:82-92`), and opens it in a hosted Redoc viewer from its own admin screens (`src/views/register/RegistersIndex.vue:670`). Stackiq does not surface it. + +## What this change builds + +1. Generated `openapi.json` for stackiq's external endpoints (offers, offered usage, usages, views, contact persons, facets, portfolio report, publication), through `nextcloud/openapi-extractor` and the annotations it reads, with a check in `composer check:strict` that the committed file is current. +2. An API documentation page in stackiq (footer menu, next to Documentation) with two tabs: Catalogue objects (OpenRegister's document for the stackiq register) and Stackiq endpoints (the generated file), rendered in the app and downloadable as JSON. +3. The two hand-written documentation endpoints answer with a pointer to the generated documents, and `docs/API_REFERENCE.md` links to the page. + +## Out of scope + +- A try-it console with credentials: the matrix category says stackiq is not a developer portal, and integriq's `access-developer-portal-and-subscriptions` covers keys and subscriptions for its gateway. +- Documenting admin-only settings endpoints; they are internal. +- Versioning the API; OpenRegister's `/api/versions/{version}/oas` covers its own contract. + +## Risks + +- Annotating many controllers is broad work. The tasks split it by controller so each pull request stays small, and the freshness check starts once the first controllers are annotated. diff --git a/openspec/changes/sharing-generated-api-docs/specs/generated-api-docs/spec.md b/openspec/changes/sharing-generated-api-docs/specs/generated-api-docs/spec.md new file mode 100644 index 000000000..0aed07aa1 --- /dev/null +++ b/openspec/changes/sharing-generated-api-docs/specs/generated-api-docs/spec.md @@ -0,0 +1,52 @@ +# generated-api-docs specification + +**Status**: proposed +**Scope**: stackiq +**OpenSpec changes**: +- sharing-generated-api-docs + +## Purpose + +The catalogue API is documented by generated OpenAPI documents that a developer reads in stackiq and downloads. Matrix row `stackiq:share-api-docs`. + +## ADDED Requirements + +### Requirement: REQ-GAD-001 Stackiq's own endpoints are described by a generated OpenAPI document + +Stackiq SHALL generate `openapi.json` from its controllers with `nextcloud/openapi-extractor`, covering every public and user-facing endpoint and leaving admin settings endpoints out, and `composer check:strict` SHALL fail when the committed file differs from a fresh generation. + +#### Scenario: A new endpoint cannot ship undocumented +@e2e exclude Build-time check; composer openapi:check runs inside check:strict. + +- **GIVEN** a developer adds a user-facing endpoint and does not regenerate the document +- **WHEN** `composer check:strict` runs +- **THEN** it fails and names the missing path + +### Requirement: REQ-GAD-002 A developer reads both documents on one page in stackiq + +Stackiq SHALL offer an API documentation page, linked from the footer menu, with the OpenRegister document of the stackiq register and stackiq's own document, each rendered in the app with operations, parameters, responses and schemas, and each downloadable as JSON. The page SHALL NOT load a third-party viewer. + +#### Scenario: A supplier's developer reads the offered usage endpoint +@e2e tests/e2e/workflows/api-docs.spec.ts + +- **GIVEN** a developer at a supplier signed in to stackiq +- **WHEN** they open API documentation, then Stackiq endpoints +- **THEN** they see `GET /api/koppelingen-gebruik/{uuid}` with its parameters and response shape + +#### Scenario: Downloading the catalogue objects document +@e2e tests/e2e/workflows/api-docs.spec.ts + +- **GIVEN** the API documentation page +- **WHEN** the developer opens Catalogue objects and clicks Download JSON +- **THEN** an OpenAPI document for the stackiq register downloads + +### Requirement: REQ-GAD-003 The hand-written documentation points to the generated documents + +`GET /api/views/docs` and `GET /api/aangeboden-gebruik/docs` SHALL keep answering and SHALL carry a link to the API documentation page, and the API reference on the docs site SHALL point to the page and the two documents. + +#### Scenario: An old integration finds the new documents +@e2e exclude API body check; tests/Unit/Controller/AangebodenGebruikControllerTest.php asserts the documentation link. + +- **GIVEN** an integration that reads `GET /api/aangeboden-gebruik/docs` +- **WHEN** it calls the endpoint +- **THEN** the answer holds its current documentation and a link to `/apps/stackiq/api-docs` diff --git a/openspec/changes/sharing-generated-api-docs/tasks.md b/openspec/changes/sharing-generated-api-docs/tasks.md new file mode 100644 index 000000000..1cd2121b5 --- /dev/null +++ b/openspec/changes/sharing-generated-api-docs/tasks.md @@ -0,0 +1,42 @@ +# Tasks: sharing-generated-api-docs + +## Implementation tasks + +### Task 1: Extractor and the public controllers +- **spec_ref**: openspec/changes/sharing-generated-api-docs/specs/generated-api-docs/spec.md#requirement-req-gad-001-stackiqs-own-endpoints-are-described-by-a-generated-openapi-document +- **files**: `vendor-bin/openapi-extractor/composer.json`, `composer.json`, `lib/ResponseDefinitions.php`, `lib/Controller/AanbodController.php`, `lib/Controller/AangebodenGebruikController.php`, `lib/Controller/ViewController.php`, `openapi.json` +- **acceptance_criteria**: + - GIVEN the annotated controllers WHEN composer openapi runs THEN openapi.json lists the offer, offered usage and view paths +- [ ] Implement +- [ ] Test (`composer openapi:check` exit 0; PHPUnit unchanged) + +### Task 2: The remaining external controllers and the freshness check +- **spec_ref**: openspec/changes/sharing-generated-api-docs/specs/generated-api-docs/spec.md#requirement-req-gad-001-stackiqs-own-endpoints-are-described-by-a-generated-openapi-document +- **files**: `lib/Controller/GebruikController.php`, `lib/Controller/ContactpersonenController.php`, `lib/Controller/FacetController.php`, `lib/Controller/PortfolioReportController.php`, `lib/Controller/PublicationController.php`, settings controllers (ignore scope), `composer.json` (openapi:check in check:strict) +- **acceptance_criteria**: + - GIVEN a controller change without regenerating WHEN check:strict runs THEN openapi:check fails and names the difference +- [ ] Implement +- [ ] Test (`composer check:strict` including openapi:check) + +### Task 3: API documentation page +- **spec_ref**: openspec/changes/sharing-generated-api-docs/specs/generated-api-docs/spec.md#requirement-req-gad-002-a-developer-reads-both-documents-on-one-page-in-stackiq +- **files**: `src/views/ApiDocumentationView.vue`, `src/components/api/OpenApiReader.vue`, `src/customComponents.js`, `src/manifest.json` (page and footer entry), `lib/Controller/ApiDocsController.php`, `appinfo/routes.php`, `l10n/en.json`, `l10n/nl.json` +- **acceptance_criteria**: + - GIVEN the page WHEN the developer opens Catalogue objects THEN the operations of the stackiq register show grouped by schema + - GIVEN the page WHEN the developer clicks Download JSON on Stackiq endpoints THEN openapi.json downloads +- [ ] Implement +- [ ] Test (vitest `tests/vitest/openApiReader.spec.js`; Playwright `tests/e2e/workflows/api-docs.spec.ts`) + +### Task 4: Point the hand-written docs at the generated ones +- **spec_ref**: openspec/changes/sharing-generated-api-docs/specs/generated-api-docs/spec.md#requirement-req-gad-003-the-hand-written-documentation-points-to-the-generated-documents +- **files**: `lib/Controller/ViewController.php`, `lib/Controller/AangebodenGebruikController.php`, `docs/API_REFERENCE.md`, `docs/features/api-documentation.md`, `docs/images/api-documentation.png` +- **acceptance_criteria**: + - GIVEN GET /api/aangeboden-gebruik/docs WHEN it answers THEN the body carries a documentation link to the API documentation page +- [ ] Implement +- [ ] Test (PHPUnit `tests/Unit/Controller/AangebodenGebruikControllerTest.php` docs case; docs build with a screenshot) + +## Verification + +- `openspec validate sharing-generated-api-docs --type change --strict` passes. +- `composer check:strict` (with openapi:check) and `npm run lint` pass; the vitest and Playwright cases above pass. +- English and Dutch strings for every new label (ADR-005); docs with a screenshot (ADR-010). From 91d57453f0b4a052949da1a34c6f8c11aa730cd7 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 27 Sep 2026 20:11:16 +0200 Subject: [PATCH 11/20] docs(openspec): sharing-compliance-documents, compliance documents with an audience --- .../.openspec.yaml | 2 + .../sharing-compliance-documents/design.md | 54 +++++++++++++++++++ .../sharing-compliance-documents/proposal.md | 40 ++++++++++++++ .../specs/shared-compliance-documents/spec.md | 35 ++++++++++++ .../sharing-compliance-documents/tasks.md | 35 ++++++++++++ 5 files changed, 166 insertions(+) create mode 100644 openspec/changes/sharing-compliance-documents/.openspec.yaml create mode 100644 openspec/changes/sharing-compliance-documents/design.md create mode 100644 openspec/changes/sharing-compliance-documents/proposal.md create mode 100644 openspec/changes/sharing-compliance-documents/specs/shared-compliance-documents/spec.md create mode 100644 openspec/changes/sharing-compliance-documents/tasks.md diff --git a/openspec/changes/sharing-compliance-documents/.openspec.yaml b/openspec/changes/sharing-compliance-documents/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/sharing-compliance-documents/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/sharing-compliance-documents/design.md b/openspec/changes/sharing-compliance-documents/design.md new file mode 100644 index 000000000..6da18534b --- /dev/null +++ b/openspec/changes/sharing-compliance-documents/design.md @@ -0,0 +1,54 @@ +# Design: sharing-compliance-documents + +Read at development `9a5ece6a`, OpenRegister development `4fee776`. + +## Context + +Compliance in stackiq today is claims (`compliancy`, `lib/Settings/softwarecatalogus_register.json:7406` schema), public, one per standard or BIO measure, with an evidence URL and files. DPIA facts sit on the product (`module.dpiaStatus` and friends). A document with its own audience does not fit a public claim, and a pentest report is not a claim about a standard. + +## D1. The complianceDocument schema + +`lib/Settings/register.d/compliance-documents.json`, added to the `stackiq` register: + +| property | type | notes | +|---|---|---| +| `module` | `$ref module`, required | the product the document is about | +| `documentType` | enum `DPIA`, `processing agreement`, `pentest report`, `assurance report`, `certificate`, `ENSIA statement`, `other` | facetable | +| `title`, `summary` | string | the summary is readable by everyone who may read the document | +| `issuedOn`, `validUntil` | date | | +| `publisher` | `$ref organization` | set to the active organisation on create | +| `audience` | enum `public`, `government`, `named`, default `named` | | +| `sharedWithOrganisations` | array of `$ref organization` | used when `audience` is `named`; the publisher is always included | + +`allowFiles: true` with tags matching the types. `x-openregister-quality` is not added here; `landscape-completeness-score` scores modules and usages only. + +## D2. Read rules follow the audience + +`authorization.read` on the schema: + +- `{ "group": "public", "match": { "audience": "public" } }` +- for the catalogue's government groups (`ambtenaar`, `gebruik-beheerder`, `gebruik-raadpleger`, `functioneel-beheerder`): `{ "match": { "audience": "government" } }` +- for every catalogue group: `{ "match": { "sharedWithOrganisations": { "$contains": "$organisation" } } }` +- the publisher's own organisation: `{ "match": { "_organisation": "$organisation" } }` + +Create and update: the groups that may edit a module (suppliers for their products) and the municipal groups (a municipality that ran its own DPIA). Update and delete are limited to the publisher's organisation by the `_organisation` match. + +Rejected: adding an audience to `compliancy`. Claims are public by design and read by the compliance matrix (`src/utils/complianceMatrix.js`); mixing restricted documents in would hide cells from readers without saying why. + +## D3. Pages + +- `ModuleDetail` (`src/manifest.json:491`): an `object-list` `md-compliance-documents` over `complianceDocument` with filter `{ module: @objectId }`, columns type, title, valid until, publisher; `allowCreate: true` with `module` filled in. +- `src/manifest.d/compliance-documents.json`: an index `ComplianceDocuments` (`/compliance-documents`) with `filterMenu` on type and a quick filter "Expiring within 90 days" (`validUntil` within P90D), and a detail page with data, files and history. A menu child of the existing Compliance entry (ADR-097). +- The create form shows a confirmation when `audience` is set to public on a pentest report. + +## Declarative versus imperative + +All declarative: schema, read rules and pages (ADR-031). The form confirmation is a small handler on the library form. + +## Seed data + +Two demo documents on a demo product: a public processing agreement template and a pentest report shared with one demo municipality, so the read rules show in the demo. + +## Risks + +- The `$contains` read rule runs on a JSON array column; the schema test asserts OpenRegister builds a query for it, and an e2e case checks that an organisation outside the list sees nothing. diff --git a/openspec/changes/sharing-compliance-documents/proposal.md b/openspec/changes/sharing-compliance-documents/proposal.md new file mode 100644 index 000000000..54ba55c4f --- /dev/null +++ b/openspec/changes/sharing-compliance-documents/proposal.md @@ -0,0 +1,40 @@ +--- +kind: code +depends_on: [] +--- + +# Share DPIAs, processing agreements and pentest reports between organisations + +## Summary + +A supplier or a municipality publishes a compliance document about a product (a DPIA, a processing agreement, a pentest report, an assurance report or a certificate) and says who may read it: everyone, every government organisation in the catalogue, or named organisations. Other municipalities find the documents on the product's page and reuse them instead of asking the supplier for the same paperwork again. Each document carries its type, its date and how long it stays valid. + +## Why + +Row from the stackiq matrix: + +- `stackiq:share-compliance-documents`, "Share documents such as DPIAs, processing agreements and pentest reports with other organisations." Rated partial, built. Feature request: https://github.com/VNG-Realisatie/Softwarecatalogus/issues/41 (VNG Softwarecatalogus). No competitor rates yes; the feature request is the demand that makes the missing half matter under the rule for partial rows. The missing half: sharing processing agreements and pentest reports with other organisations as such. + +## What stackiq has today + +- A compliance claim (`compliancy`, `lib/Settings/softwarecatalogus_register.json:7406` schema) links a product to a standard or BIO measure with an evidence URL and files (`allowFiles`, tag `testraport`), read by everyone (`authorization.read: public`), shown on `KompliantieDetail` (`src/manifest.json:878`). +- The product carries its DPIA as a status, dates and a Nextcloud Files reference (`module.dpiaStatus`, `dpiaDate`, `dpiaNextAssessment`, `dpiaDocumentRef`) and a reference to the processing register entry (`verwerkingsregisterRef`). +- Nothing records a processing agreement, a pentest report or an assurance report, and nothing lets a publisher choose who reads a document: a claim is public or it does not exist. +- OpenRegister read rules can match array membership (`$contains`, `openregister lib/Db/MagicMapper/MagicRbacHandler.php:1085` onwards), which a named-organisations share needs. + +## What this change builds + +1. A `complianceDocument` schema: the product, the type (DPIA, processing agreement, pentest report, assurance report such as ISAE 3402 or SOC 2, certificate such as ISO 27001, ENSIA statement, other), issued on, valid until, the publishing organisation, a summary, the file, and the audience (public, government organisations, named organisations). +2. Read rules that follow the audience, so a pentest report shared with three municipalities is invisible to everyone else. +3. A Compliance documents section on the product page, with the type, validity and publisher, and Add for the supplier and for a municipality that did its own assessment. +4. A Compliance documents list with filters on type and on documents that expire within 90 days. + +## Out of scope + +- Verifying that a document is authentic or reviewing its content (`stackiq:comp-verified-vs-claimed`, deferred). +- Generating a processing register from the catalogue (`stackiq:comp-processing-register-generate`, deferred). +- Sharing with parties outside the catalogue without an account: portaliq's contribution contract (open change `portal-contribution`). + +## Risks + +- A pentest report shared publicly by mistake exposes findings. Public is not the default: a new document starts at named organisations with the publisher only, and the form warns before public is chosen for a pentest report. diff --git a/openspec/changes/sharing-compliance-documents/specs/shared-compliance-documents/spec.md b/openspec/changes/sharing-compliance-documents/specs/shared-compliance-documents/spec.md new file mode 100644 index 000000000..7c9ef8977 --- /dev/null +++ b/openspec/changes/sharing-compliance-documents/specs/shared-compliance-documents/spec.md @@ -0,0 +1,35 @@ +# shared-compliance-documents specification + +**Status**: proposed +**Scope**: stackiq +**OpenSpec changes**: +- sharing-compliance-documents + +## Purpose + +Organisations publish compliance documents about a product and choose who reads them, so others reuse them. Matrix row `stackiq:share-compliance-documents`. + +## ADDED Requirements + +### Requirement: REQ-SCD-001 An organisation publishes a compliance document about a product + +A supplier, or an organisation that uses the product, SHALL publish a compliance document about a product with its type (DPIA, processing agreement, pentest report, assurance report, certificate, ENSIA statement or other), title, summary, issue date, validity and file. The product page SHALL list the documents the user may read, and a documents list SHALL filter on type and on documents that expire within 90 days. + +#### Scenario: A supplier publishes a processing agreement for all government readers +@e2e tests/e2e/workflows/compliance-documents.spec.ts + +- **GIVEN** a supplier of product X +- **WHEN** the supplier opens the page of X, adds a processing agreement valid until next year with audience government, and saves +- **THEN** a municipal information manager opening the page of X sees the processing agreement with its validity and publisher + +### Requirement: REQ-SCD-002 The audience decides who reads a document + +A document SHALL be readable by anyone when its audience is public, by users of government organisations in the catalogue when it is government, and only by the organisations listed in `sharedWithOrganisations` when it is named. The publisher's organisation SHALL always read and edit its own documents, and a new document SHALL start with audience named. + +#### Scenario: A pentest report stays with the municipalities it was shared with +@e2e tests/e2e/workflows/compliance-documents.spec.ts + +- **GIVEN** a supplier shared a pentest report on product X with municipality A only +- **WHEN** an information manager of municipality B opens the page of X +- **THEN** the pentest report is not listed +- **AND** an information manager of municipality A sees it diff --git a/openspec/changes/sharing-compliance-documents/tasks.md b/openspec/changes/sharing-compliance-documents/tasks.md new file mode 100644 index 000000000..cac954d92 --- /dev/null +++ b/openspec/changes/sharing-compliance-documents/tasks.md @@ -0,0 +1,35 @@ +# Tasks: sharing-compliance-documents + +## Implementation tasks + +### Task 1: Schema and audience rules +- **spec_ref**: openspec/changes/sharing-compliance-documents/specs/shared-compliance-documents/spec.md#requirement-req-scd-002-the-audience-decides-who-reads-a-document +- **files**: `lib/Settings/register.d/compliance-documents.json`, `lib/Settings/stackiq_mock_register.json` +- **acceptance_criteria**: + - GIVEN a pentest report shared with municipality A WHEN municipality B lists compliance documents THEN it is not listed + - GIVEN a public processing agreement WHEN an anonymous visitor reads the product's documents through the API THEN it is listed +- [ ] Implement +- [ ] Test (PHPUnit `tests/Unit/Settings/ComplianceDocumentsFragmentTest.php`, `tests/Unit/Settings/SchemaRbacTest.php` cases for the three audiences) + +### Task 2: Product page section and the documents list +- **spec_ref**: openspec/changes/sharing-compliance-documents/specs/shared-compliance-documents/spec.md#requirement-req-scd-001-an-organisation-publishes-a-compliance-document-about-a-product +- **files**: `src/manifest.json` (ModuleDetail list), `src/manifest.d/compliance-documents.json`, `src/customComponents.js` (public audience confirmation), `l10n/en.json`, `l10n/nl.json` +- **acceptance_criteria**: + - GIVEN a supplier on its product page WHEN it adds a processing agreement with audience government THEN the product page lists it for a municipal user + - GIVEN documents with different validity WHEN the user picks Expiring within 90 days THEN only those remain +- [ ] Implement +- [ ] Test (Playwright `tests/e2e/workflows/compliance-documents.spec.ts`, including an organisation outside a named share) + +### Task 3: Documentation +- **spec_ref**: openspec/changes/sharing-compliance-documents/specs/shared-compliance-documents/spec.md#requirement-req-scd-001-an-organisation-publishes-a-compliance-document-about-a-product +- **files**: `docs/features/compliance-documents.md`, `docs/images/compliance-documents.png` +- **acceptance_criteria**: + - GIVEN the docs site WHEN a reader opens Compliance documents THEN publishing, the three audiences and expiry are explained with a screenshot +- [ ] Implement +- [ ] Test (docs build, screenshot with Playwright) + +## Verification + +- `openspec validate sharing-compliance-documents --type change --strict` passes. +- `composer check:strict` and `npm run lint` pass; the PHPUnit and Playwright cases above pass. +- English and Dutch strings for every new label (ADR-005); docs with a screenshot (ADR-010). From 612642801a7dc3a64475152810cddd4c3027b882 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 27 Sep 2026 20:11:39 +0200 Subject: [PATCH 12/20] docs(openspec): operations-technology-components, organisation technology with runs-on relations and end of support --- .../.openspec.yaml | 2 + .../design.md | 91 +++++++++++++++++++ .../proposal.md | 55 +++++++++++ .../specs/technology-components/spec.md | 78 ++++++++++++++++ .../operations-technology-components/tasks.md | 63 +++++++++++++ 5 files changed, 289 insertions(+) create mode 100644 openspec/changes/operations-technology-components/.openspec.yaml create mode 100644 openspec/changes/operations-technology-components/design.md create mode 100644 openspec/changes/operations-technology-components/proposal.md create mode 100644 openspec/changes/operations-technology-components/specs/technology-components/spec.md create mode 100644 openspec/changes/operations-technology-components/tasks.md diff --git a/openspec/changes/operations-technology-components/.openspec.yaml b/openspec/changes/operations-technology-components/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/operations-technology-components/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/operations-technology-components/design.md b/openspec/changes/operations-technology-components/design.md new file mode 100644 index 000000000..97e690af5 --- /dev/null +++ b/openspec/changes/operations-technology-components/design.md @@ -0,0 +1,91 @@ +# Design: operations-technology-components + +Read at development 49e65cb4, and the lead's merged changes at development 9a5ece6a (`connections-catalogue-pages`, `landscape-usage-registration`). + +## Where it fits + +| Part | File and line | What changes | +|---|---|---| +| Fragment | new `lib/Settings/register.d/operations-technology-components.json` (ADR-037) | new schema `technologyComponent`; `registers.stackiq.schemas` gets it; `registers.stackiq.configuration.schemas.technologyComponent` gets `magicMapping` and `autoCreateTable`; `usage.properties.runsOn` and a higher `usage` version | +| Settings | `lib/Service/SettingsService.php:121` `LEGACY_SCHEMA_KEY` | nothing: a new slug resolves by the default `_schema` rule to `technologyComponent_schema` | +| Service | `lib/Service/EolSyncService.php`, after `saveStampedVersion()` (`:419`) | stamps `endOfSupport`, `eolSource` and `eolUpdatedOn` on every component whose `software` is the stamped version | +| Pages and menu | new `src/manifest.d/operations-technology-components.json` | `Technologie` at `/technologie` (`type: index`) and `TechnologieDetail` at `/technologie/:id` (`type: detail`), menu entry `TechnologieMenu` | +| Menu layout | `src/menu-layout.json` `relocations` | `"TechnologieMenu": "Modules"` (ADR-097) | +| Page | `GebruikDetail` in `src/manifest.d/usages.json` (from `landscape-usage-registration`) | a body widget `UsageRunsOnPanel` | +| Component | new `src/components/usages/UsageRunsOnPanel.vue`, registered in `src/customComponents.js` | lists the components the usage runs on with their end of support, and marks the ones past it | +| Util | new `src/utils/technologyLifecycle.js` | pure `supportState(component, today)`: `supported`, `ending` (within 180 days), `ended`, `unknown` | +| Seed | `lib/Settings/stackiq_mock_register.json` | three components and `runsOn` on one usage | + +## The schema + +`technologyComponent`, in the `stackiq` register. + +| Property | Type | Notes | +|---|---|---| +| `name` | string, required | the object name | +| `class` | enum, required | Physical server, Virtual machine, Container platform, Database, Middleware, Runtime or framework, Operating system, Network device, Storage, End-user device, Other | +| `archimateType` | enum | Node, Device, System software, Technology service, Communication network; derived default per class, editable | +| `organization` | `$ref` organization, required | the owning organisation, `related-object` | +| `status` | enum | Planned, In use, To be phased out, Phased out; default In use | +| `manufacturer`, `model`, `serialNumber`, `assetTag`, `location` | string | for hardware | +| `software` | `$ref` moduleVersion | the version of a System software module this component is, for example PostgreSQL 13 | +| `endOfSupport` | date | typed for hardware; stamped by the EOL sync when `software` is set | +| `eolSource`, `eolUpdatedOn` | string, datetime | provenance, the same names `moduleVersion` uses | +| `runsOn` | array of `$ref` technologyComponent | a virtual machine runs on a host, a database on a virtual machine | + +`configuration`: `objectNameField` `name`, `objectDescriptionField` `class`, `autoPublish` false, `allowFiles` false, and an `x-openregister-lifecycle` on `status` with the English enum values (plan to In use, phase out, retire), so every transition matches a row. The `authorization.read` rule copies `catalogContract`'s organisation scoping (`_organisation` equals `$organisation` per group) plus `software-catalog-admins`; suppliers and anonymous visitors get nothing. + +`usage.runsOn`: array of `$ref` technologyComponent, `related-object`, title Runs on. + +## Decisions + +### D1. One schema with a class, not one schema per kind + +A server, a database and a laptop share name, owner, status, lifecycle and relations; they differ in which optional fields are filled. One `technologyComponent` with a `class` keeps one index, one detail page and one relation type, and a new kind is an enum value. GLPI's many asset types each carry their own table and form; stackiq has no reason to copy that. + +Rejected: technology as more `module` types. A `module` is the supplier's published product, readable by the public (its `authorization.read`); an organisation's servers must not be. + +Rejected: technology as `element` objects in the `vng-gemma` register. That register holds the GEMMA reference model, and `lib/Settings/GEMMA_release.xml` has no technology elements for an organisation's data to hang from. + +### D2. Software lifecycle comes from the catalogue, hardware lifecycle is typed + +For platform software the catalogue already knows the product (a `module` of type System software), its versions (`moduleVersion`) and, when mapped, their end of support from the feed (`EolSyncService`, `findMappedModules()` at `:290`). A database component points at its version through `software`; after the sync stamps that version, it stamps the same date on the component with `eolSource`. Hardware has no feed, so its owner types `endOfSupport`. + +Rejected: reading the version's date at display time only. Then the index could not sort or filter on end of support, and a component whose `software` is unset would need a second code path anyway. + +### D3. An application in use runs on components; the product does not + +The relation sits on `usage`, the organisation's deployment, and not on `module`, for the same reason as D1: one product runs on different platforms in different municipalities. `usage.runsOn` points at the organisation's own components. The application's exposure is the worst `supportState` of the components it runs on, including what those run on, one level down (a database on a virtual machine on an ageing host). + +### D4. Relations between components are one relation type + +`runsOn` covers hosting, which is what obsolescence and impact need. Other link types (backup of, cluster member of) are not asked for by the rows and would each need a meaning; a later change can add them as a relation with a type. + +### D5. Pages use the library's index and detail types + +`Technologie` is a `type: index` page with columns name, class, organisation, status and end of support, quick filters Hardware (the five hardware classes) and Platform software, and sort on `endOfSupport`. `TechnologieDetail` is a `type: detail` page (ADR-062, ADR-096): a data widget, an `object-list` Runs on this component (filter `runsOn` equals `@objectId` on `technologyComponent`), an `object-list` Applications in use here (filter `runsOn` equals `@objectId` on `usage`, `rowRoute: GebruikDetail`), lifecycle actions and a History tab. Only `UsageRunsOnPanel` is custom, because a built-in object list cannot show a derived state per row; it composes `CnWidgetWrapper` and draws nothing else of its own (ADR-012). + +## Declarative versus imperative + +- The component lifecycle is declarative: an `x-openregister-lifecycle` block on `status`. +- The relations are declarative: `$ref` properties with `related-object` handling, shown through the detail page's object lists. +- The end-of-support stamp is imperative because it extends the existing EOL sync, which already runs imperatively in `EolSyncService`; no `x-openregister-*` rule copies a field from a related object. +- A notification before a component's end of support could be a declarative `x-openregister-notifications` rule, as `moduleVersion` has for its own `dateEndSupport` (`lib/Settings/softwarecatalogus_register.json:7667`). It is a follow-up, not part of this change. + +## Seed data + +`technologyComponent` is new, and `usage` gains `runsOn`, so the demo descriptor `lib/Settings/stackiq_mock_register.json` gets matching objects in the `stackiq` register, with OpenRegister seed references (`@ref:`). + +| `@self.slug` | `name` | `class` | `organization` | `software` | `endOfSupport` | `runsOn` | +|---|---|---|---|---|---|---| +| technology-host-1 | Host 01 | Physical server | `@ref:organization-voorbeeld-name-1-1` | empty | 2029-12-31 | empty | +| technology-vm-1 | App server 01 | Virtual machine | `@ref:organization-voorbeeld-name-1-1` | empty | empty | `@ref:technology-host-1` | +| technology-db-1 | Database 01 | Database | `@ref:organization-voorbeeld-name-1-1` | `@ref:moduleversion-moduleversion-3-3` | stamped from the version, 2026-03-03 | `@ref:technology-vm-1` | + +`usage-usage-3-3` gets `runsOn: [@ref:technology-db-1, @ref:technology-vm-1]`. Its Runs on panel then shows Database 01 as past end of support. + +## Risks + +- **Sensitive data.** Serial numbers, hosts and locations help an attacker. The read rule is organisation-scoped, the schema is never published, and nothing in this change adds a public route. +- **Stale hardware dates.** A typed `endOfSupport` is only as good as the person who typed it; the detail page shows `eolSource` so a reader sees whether a date came from the feed or by hand. +- **A fragment and the usage schema.** Adding `runsOn` to `usage` from a fragment appends a property, which the deep merge supports; the fragment also raises the `usage` version, or the import skips the change (the register changelog 2.4.4 at `lib/Settings/softwarecatalogus_register.json:7` records why). diff --git a/openspec/changes/operations-technology-components/proposal.md b/openspec/changes/operations-technology-components/proposal.md new file mode 100644 index 000000000..5823019fe --- /dev/null +++ b/openspec/changes/operations-technology-components/proposal.md @@ -0,0 +1,55 @@ +--- +kind: code +depends_on: + - connections-catalogue-pages + - landscape-usage-registration +--- + +# Record the servers, databases and devices your applications run on + +## Summary + +Stackiq records applications, their versions, suites and the connections between them. It cannot record what they run on: the database server, the virtual machine, the laptop fleet, the network switch. A municipal information manager who hears that PostgreSQL 13 is out of support cannot find which of their applications depend on it. This change adds technology components owned by an organisation, with a class, a lifecycle and relations to each other, links an application in use to the components it runs on, and gives each component its end of support date, copied from the end-of-life feed when the component is a known software version. Stackiq records these; it does not discover them. + +## Why + +This change covers three matrix rows. + +- `stackiq:ops-hardware-assets`, "Register hardware such as laptops and servers alongside software." Stackiq rates itself no. GLPI rates yes from its source at 11.0.9: `src/autoload/CFG_GLPI.php:208` `asset_types` lists Computer, Monitor, NetworkEquipment and the other hardware types managed next to software. TOPdesk rates yes: "Think of a router that provides a computer with access to your network, or a printer" (https://docs.topdesk.com/en/linking-assets-to-other-assets.html). +- `stackiq:ops-ci-relations`, "Record configuration items and their relations in a CMDB." Stackiq rates itself partial: applications, versions, suites and connections are recorded with relations, but there are no configuration item classes beyond applications. GLPI rates yes from its source: the asset types plus appliances, with relations as appliance membership (`src/Appliance_Item.php:45`) and impact relations (`install/mysql/glpi-empty.sql:1247` `glpi_impactrelations`). TOPdesk rates yes: "You register these functionalities as custom link types, and these link types are shown in the graphical overview of assets" (https://docs.topdesk.com/en/linking-assets-to-other-assets.html). +- `stackiq:life-tech-obsolescence`, "Track the lifecycle of underlying technology, such as a database or framework, not only applications." Stackiq rates itself no. SAP LeanIX rates yes: "SAP LeanIX helps you gain an overview of your application landscape's obsolescence risk exposure" (https://help.sap.com/docs/leanix/ea/obsolescence-risk-management). This row is below the bar on its own and rides with `stackiq:ops-hardware-assets`: its missing half is the relation from an application to the platform it runs on, with that platform's lifecycle, which the technology components record. + +`ops-hardware-assets` is built new. `ops-ci-relations` is partial and built: this change builds configuration item classes beyond applications, and their relations. + +## What stackiq has today + +Read at development 49e65cb4, with the lead's merged changes on development 9a5ece6a. + +- The stackiq register holds sector, suite, module, catalogService, vulnerability, contactPerson, organization, usage, catalogContract, connection, software-review, compliancy, moduleVersion, sbomComponent and bioMeasure (`lib/Settings/softwarecatalogus_register.json:817` onwards). None of them is hardware or infrastructure. +- `module` (`:6777`) is the application as the supplier offers it. `module.type` holds Application or System software, and `module.eolProductSlug` maps it to the end-of-life feed. `moduleVersion` (`:7649`) holds `dateEndSupport`, `eolSource` and `eolUpdatedOn`. +- `lib/Service/EolSyncService.php:290` `findMappedModules()` reads every module with an `eolProductSlug`, and the service stamps the matching versions' end of support from the feed. Nothing links a version of system software to the applications that run on it. +- `sbomComponent` (`:7920`) records the libraries inside a version, with name, version, purl, licences and CVE ids, and no lifecycle. +- `usage` (`:2654`) is an organisation's deployment of an application. `landscape-usage-registration` gives it the pages `Gebruik` and `GebruikDetail`. +- `connection` (`:3563`) links two applications. `connections-catalogue-pages` gives it the pages `Koppelingen` and `KoppelingDetail`. +- The GEMMA reference model in `lib/Settings/GEMMA_release.xml` has no technology layer elements (one `Artifact`, no `Node`, `Device` or `SystemSoftware`), so technology is organisation data, not reference data. + +## What this change builds + +- A `technologyComponent` schema: name, class (server, virtual machine, container platform, database, middleware, runtime or framework, operating system, network device, storage, end-user device, other), the ArchiMate technology type it maps to, the owning organisation, status, manufacturer, model, serial number, asset tag, location, the software version it is (a `moduleVersion` of a System software module), an end of support date, and the components it runs on. +- `usage.runsOn`: the components an application in use runs on. +- A Technology index and detail page under Applications. The detail page shows the components this one runs on, the components running on it, and the applications in use that run on it. +- The end-of-life sync copies a version's end of support onto the components that are that version, so a database server shows PostgreSQL 13's end of support without anyone typing it. +- On `GebruikDetail`, a Runs on list that marks a component past its end of support. + +## Out of scope + +- Discovering hardware or software on the network, or reading it from an inventory agent or a monitoring tool. The matrix category says stackiq is not a discovery agent. An import from an outside CMDB belongs to integriq (ADR-091). +- Exporting technology components in the ArchiMate export. The schema records the ArchiMate type so the export can map it later. +- Impact analysis across connections and technology in a graph. `connections-diagram-and-graph-export` draws the application landscape; adding technology to that view is a follow-up. +- Warranty, purchase and depreciation of hardware. Contracts cover what was bought; money belongs to shillinq. + +## Risks + +- Infrastructure details help an attacker. The schema's read rule is the organisation's own users and the catalogue admins, never suppliers or the public. +- The `moduleVersion` lifecycle still names Dutch states while its rows hold English values (`x-openregister-lifecycle` at `lib/Settings/softwarecatalogus_register.json:7887`). This change reads `dateEndSupport` and uses no `moduleVersion` transition, so it does not fix that; `lifecycle-maintenance-and-supplier-roadmap` owns it. +- A second place for platform software. System software stays a `module` in the catalogue (the product); a `technologyComponent` is one organisation's installed instance of it. The design keeps the two apart by pointing from the component to the version. diff --git a/openspec/changes/operations-technology-components/specs/technology-components/spec.md b/openspec/changes/operations-technology-components/specs/technology-components/spec.md new file mode 100644 index 000000000..5fc7d3f15 --- /dev/null +++ b/openspec/changes/operations-technology-components/specs/technology-components/spec.md @@ -0,0 +1,78 @@ +# technology-components specification + +**Status**: proposed +**Scope**: stackiq +**OpenSpec changes**: +- operations-technology-components + +## Purpose + +A municipal information manager records the servers, virtual machines, databases, network devices and end-user devices their organisation runs, how they depend on each other, and which applications in use run on them. They see when a component reaches its end of support and which applications that affects. Stackiq records these; it does not discover them. + +## ADDED Requirements + +### Requirement: REQ-TCO-001 An organisation SHALL record technology components with a class, a status and an owner + +The `stackiq` register SHALL hold a `technologyComponent` schema with a required name, class and owning organisation, a status with a working lifecycle, the hardware fields manufacturer, model, serial number, asset tag and location, an optional `software` version, an end of support date and the components it runs on. Only users of the owning organisation and catalogue admins SHALL read a component. + +#### Scenario: An information manager registers a database server +@e2e tests/e2e/spec-coverage/technology-components.spec.ts + +- **GIVEN** a municipal information manager of Gemeente Voorbeeld on `/technologie` +- **WHEN** they add Database 01 with class Database, running on App server 01, and save +- **THEN** `/technologie/:id` SHALL show Database 01 with class Database, owner Gemeente Voorbeeld and Runs on App server 01 + +#### Scenario: A supplier cannot read an organisation's components +@e2e exclude An authorisation rule held by OpenRegister; tests/Unit/Settings/TechnologyComponentDeclarationTest.php asserts the read rule is organisation-scoped and names no supplier or public group. + +- **GIVEN** a technology component of Gemeente Voorbeeld +- **WHEN** a supplier's user asks OpenRegister for it +- **THEN** it SHALL NOT be returned + +#### Scenario: A component moves through its lifecycle +@e2e tests/e2e/spec-coverage/technology-components.spec.ts + +- **GIVEN** a component with status In use +- **WHEN** the information manager chooses Phase out on its detail page +- **THEN** its status SHALL be To be phased out + +### Requirement: REQ-TCO-002 The Technology pages SHALL list components and show their relations both ways + +A `Technologie` index at `/technologie`, reached from a menu entry under Applications, SHALL list components with name, class, organisation, status and end of support, with quick filters for hardware and platform software. `TechnologieDetail` SHALL show the component's data, the components it runs on, the components running on it, and the applications in use that run on it, each opening its own page. + +#### Scenario: An information manager sees what runs on a host +@e2e tests/e2e/spec-coverage/technology-components.spec.ts + +- **GIVEN** Host 01 with App server 01 running on it, and an application in use running on App server 01 +- **WHEN** a municipal information manager opens Host 01 +- **THEN** the page SHALL list App server 01 under Runs on this component +- **AND** opening App server 01 SHALL list that application in use + +### Requirement: REQ-TCO-003 An application in use SHALL record the components it runs on and show their support state + +`usage` SHALL carry `runsOn`, a list of technology components of the same organisation. `GebruikDetail` SHALL show those components with their end of support and a state of supported, ending within 180 days, ended, or unknown, and SHALL show the worst state including the components they run on, one level down. + +#### Scenario: An application runs on a database past its end of support +@e2e tests/e2e/spec-coverage/technology-components.spec.ts + +- **GIVEN** the demo application in use running on Database 01, whose end of support is 2026-03-03, and today is later +- **WHEN** a municipal information manager opens `/gebruik/:id` +- **THEN** the Runs on panel SHALL list Database 01 as ended + +#### Scenario: The state follows the host underneath +@e2e exclude A pure derivation; tests/vitest/technologyLifecycle.spec.js asserts the worst state is taken over the listed components and the components they run on. + +- **GIVEN** an application in use running on a virtual machine without an end of support date, on a host whose support ended +- **WHEN** the Runs on panel computes the state +- **THEN** the worst state SHALL be ended + +### Requirement: REQ-TCO-004 The end-of-life sync SHALL stamp a component's end of support from the software version it is + +When `EolSyncService` stamps a version's end of support from the feed, it SHALL also stamp `endOfSupport`, `eolSource` and `eolUpdatedOn` on every technology component whose `software` is that version. A component without `software` SHALL keep its typed date. + +#### Scenario: A database gets PostgreSQL's end of support +@e2e exclude Needs the feed register from integriq; tests/Unit/Service/EolSyncServiceTest.php asserts that stamping a version also stamps the components pointing at it and leaves others alone. + +- **GIVEN** a component Database 01 whose `software` is PostgreSQL 13, mapped to the feed +- **WHEN** the EOL sync stamps PostgreSQL 13 with an end of support +- **THEN** Database 01 SHALL carry the same `endOfSupport` with `eolSource` set to the feed diff --git a/openspec/changes/operations-technology-components/tasks.md b/openspec/changes/operations-technology-components/tasks.md new file mode 100644 index 000000000..a7cdb10a3 --- /dev/null +++ b/openspec/changes/operations-technology-components/tasks.md @@ -0,0 +1,63 @@ +# Tasks: operations-technology-components + +## Implementation tasks + +### Task 1: Add the technologyComponent schema and usage.runsOn +- **spec_ref**: openspec/changes/operations-technology-components/specs/technology-components/spec.md#requirement-req-tco-001-an-organisation-shall-record-technology-components-with-a-class-a-status-and-an-owner +- **files**: `lib/Settings/register.d/operations-technology-components.json`, `tests/Unit/Settings/TechnologyComponentDeclarationTest.php`, `l10n/en.json`, `l10n/nl.json` +- **acceptance_criteria**: + - GIVEN the merged register WHEN technologyComponent is read THEN it has the properties in the design, is in the stackiq register with magicMapping and autoCreateTable, and its lifecycle states equal its status enum + - GIVEN the merged register WHEN its read rule is read THEN it is organisation-scoped and names no supplier or public group + - GIVEN the merged register WHEN usage is read THEN it has runsOn and a higher version than before + - GIVEN a Dutch instance WHEN the form renders THEN the class and status values are Dutch +- [ ] Implement +- [ ] Test (PHPUnit tests/Unit/Settings/TechnologyComponentDeclarationTest.php) + +### Task 2: Add the Technology index and detail pages under Applications +- **spec_ref**: openspec/changes/operations-technology-components/specs/technology-components/spec.md#requirement-req-tco-002-the-technology-pages-shall-list-components-and-show-their-relations-both-ways +- **files**: `src/manifest.d/operations-technology-components.json`, `src/menu-layout.json`, `l10n/en.json`, `l10n/nl.json` +- **acceptance_criteria**: + - GIVEN the merged menu WHEN it is built THEN Technology is a child of Applications and the top-level count does not grow + - GIVEN a host with a virtual machine on it WHEN the host's detail page opens THEN the machine is listed under Runs on this component + - GIVEN a component WHEN its detail page opens THEN the applications in use that run on it are listed and open GebruikDetail + - GIVEN a component with status In use WHEN Phase out is chosen THEN its status is To be phased out +- [ ] Implement +- [ ] Test (Playwright tests/e2e/spec-coverage/technology-components.spec.ts) + +### Task 3: Show the Runs on panel with the support state on the usage page +- **spec_ref**: openspec/changes/operations-technology-components/specs/technology-components/spec.md#requirement-req-tco-003-an-application-in-use-shall-record-the-components-it-runs-on-and-show-their-support-state +- **files**: `src/utils/technologyLifecycle.js`, `src/components/usages/UsageRunsOnPanel.vue`, `src/customComponents.js`, `src/manifest.d/usages.json`, `tests/vitest/technologyLifecycle.spec.js`, `l10n/en.json`, `l10n/nl.json` +- **acceptance_criteria**: + - GIVEN an end of support in the past, within 180 days, later, or empty WHEN supportState() runs THEN it returns ended, ending, supported or unknown + - GIVEN a virtual machine without a date on an ended host WHEN the worst state is computed THEN it is ended + - GIVEN the demo usage WHEN /gebruik/:id opens THEN Database 01 is listed as ended +- [ ] Implement +- [ ] Test (vitest tests/vitest/technologyLifecycle.spec.js and Playwright tests/e2e/spec-coverage/technology-components.spec.ts) + +### Task 4: Stamp end of support on components from the EOL sync +- **spec_ref**: openspec/changes/operations-technology-components/specs/technology-components/spec.md#requirement-req-tco-004-the-end-of-life-sync-shall-stamp-a-components-end-of-support-from-the-software-version-it-is +- **files**: `lib/Service/EolSyncService.php`, `tests/Unit/Service/EolSyncServiceTest.php` +- **acceptance_criteria**: + - GIVEN a stamped version with two components pointing at it WHEN the sync saves the version THEN both components carry the date, eolSource and eolUpdatedOn + - GIVEN a component without software WHEN the sync runs THEN its typed endOfSupport is untouched + - GIVEN many components WHEN they are looked up THEN the lookup has an explicit limit +- [ ] Implement +- [ ] Test (PHPUnit tests/Unit/Service/EolSyncServiceTest.php) + +### Task 5: Seed components and document the feature +- **spec_ref**: openspec/changes/operations-technology-components/specs/technology-components/spec.md#requirement-req-tco-003-an-application-in-use-shall-record-the-components-it-runs-on-and-show-their-support-state +- **files**: `lib/Settings/stackiq_mock_register.json`, `docs/features/technology-components.md` +- **acceptance_criteria**: + - GIVEN a fresh demo import WHEN /technologie opens THEN it lists Host 01, App server 01 and Database 01 + - GIVEN the docs WHEN a reader opens the feature page THEN it shows the index, a detail page and the Runs on panel in screenshots, and says stackiq records and does not discover +- [ ] Implement +- [ ] Test (Playwright tests/e2e/spec-coverage/technology-components.spec.ts against the demo data) + +## Verification + +- `openspec validate operations-technology-components --type change --strict` +- PHPUnit: tests/Unit/Settings/TechnologyComponentDeclarationTest.php and tests/Unit/Service/EolSyncServiceTest.php +- vitest: tests/vitest/technologyLifecycle.spec.js +- Playwright: tests/e2e/spec-coverage/technology-components.spec.ts +- Docs in docs/features/technology-components.md with screenshots (ADR-010) +- English and Dutch strings for the classes, the states, the pages and the panel (ADR-005) From 4095916eb77290c8359653bc4ded14f6025cb845 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 27 Sep 2026 20:13:26 +0200 Subject: [PATCH 13/20] docs(openspec): sharing-itsm-exchange, service desk exchange through integriq flows --- .../sharing-itsm-exchange/.openspec.yaml | 2 + .../changes/sharing-itsm-exchange/design.md | 44 +++++++++++++++++++ .../changes/sharing-itsm-exchange/proposal.md | 43 ++++++++++++++++++ .../specs/itsm-exchange/spec.md | 35 +++++++++++++++ .../changes/sharing-itsm-exchange/tasks.md | 43 ++++++++++++++++++ 5 files changed, 167 insertions(+) create mode 100644 openspec/changes/sharing-itsm-exchange/.openspec.yaml create mode 100644 openspec/changes/sharing-itsm-exchange/design.md create mode 100644 openspec/changes/sharing-itsm-exchange/proposal.md create mode 100644 openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md create mode 100644 openspec/changes/sharing-itsm-exchange/tasks.md diff --git a/openspec/changes/sharing-itsm-exchange/.openspec.yaml b/openspec/changes/sharing-itsm-exchange/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/sharing-itsm-exchange/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/sharing-itsm-exchange/design.md b/openspec/changes/sharing-itsm-exchange/design.md new file mode 100644 index 000000000..1bd4daf3c --- /dev/null +++ b/openspec/changes/sharing-itsm-exchange/design.md @@ -0,0 +1,44 @@ +# Design: sharing-itsm-exchange + +Read at development `9a5ece6a`, OpenRegister development `4fee776`, integriq development `413357e`. + +## Context + +Stackiq's outside connections are declared in `lib/Settings/connections.json` and shown by integriq's connection registry. Outside calls and their credentials belong to integriq (ADR-091, ADR-064); stackiq never holds a service desk token. Integriq synchronises through OpenRegister flows made of steps (fetch page, map, contract, save), and its `SourceCallNode` calls a configured source from a flow (integriq `lib/Flow/SourceCallNode.php`). Stackiq authors flows on its own Flows page (`src/manifest.json:1057`) and its store accepts `openregister.flows` configuration sets (`src/manifest.json`, `store.types`). + +## D1. External references on the usage + +`lib/Settings/register.d/itsm-exchange.json` adds to `usage`: `externalReferences`, an array of objects `{ system: string, recordId: string, url: string (uri), syncedAt: date-time }`, visible on the page, `hideOnForm: true` (written by the inbound flow, not typed by hand), and a derived `serviceDeskUrl` for the list column. + +The usage is the right object: a service desk's application record describes the application as this organisation runs it, with its own version and owners, not the supplier's product. + +## D2. The itsm connection + +A fourth entry in `lib/Settings/connections.json`: `key: itsm`, title "Service desk", `reportedOnly: true`, a `switch` on a new app setting `itsm_exchange_enabled`, and `sourceTemplate` naming integriq's service desk templates once integriq publishes them. The flows report their outcome through `ConnectionReportService` (`lib/Service/ConnectionReportService.php`, from `adopt-connection-registry`), so the Integrations page shows the last run. + +## D3. Two flow templates and a set-up action + +Two flow templates ship in `lib/Settings/flows/` (`itsm-outbound.json`, `itsm-inbound.json`), with three mapping presets (TOPdesk assets, ServiceNow CMDB CIs, GLPI appliances): + +- **Outbound**: trigger `object.updated` and `object.created` on `usage` (OpenRegister `TriggerObjectNode`), a map step that builds the service desk payload (application name, supplier, version, lifecycle status, business owner, technical owner, BBN level), and integriq's source call. A `recordId` in the answer is written back to `externalReferences`. +- **Inbound**: trigger on a nightly schedule, integriq's fetch page over the service desk's application records, a match step on `recordId`, else on name and supplier, and a save step that updates `externalReferences` and `syncedAt`. Unmatched records are listed in the flow run. + +A "Set up service desk exchange" action in the admin settings (a new section `section-itsm`, the anchor the connection entry links to) asks for the integriq source and the mapping preset, fills them into the templates, validates them with OpenRegister (`POST /apps/openregister/api/flow/validate`, openregister `appinfo/routes.php:803`) and creates the flows (`POST /api/flows`, :861), scoped to stackiq so they appear on its Flows page. Controller `lib/Controller/ItsmExchangeController.php`, service `lib/Service/ItsmExchangeService.php`, admin only (`#[AuthorizedAdminSetting]`). + +Rejected: a stackiq PHP client per service desk. It would hold credentials and outside calls in stackiq, which ADR-091 moves to integriq, and it would duplicate integriq's synchronisation. Rejected too: a Store configuration set, because stackiq's Store lists sets from publishers' sources, and this exchange needs the administrator's own source filled in before it can run. + +## D4. Pages + +The usage page shows the service desk link from `externalReferences` in its data widget, and Applications in use (`src/manifest.d/usages.json`) gets a Service desk column that opens the record in a new tab. + +## Declarative versus imperative + +Declarative: fields, the connection entry, and the flows and mappings as data run by OpenRegister and integriq (ADR-031, ADR-065). The set-up action only fills in and creates the flows; no stackiq code calls outside. + +## Seed data + +None; the configuration set is installed on purpose. + +## Risks + +- Integriq's service desk source templates do not exist yet (its connector catalogue lists ServiceNow among planned categories). Until they do, the administrator configures a generic REST source in integriq, and the flows work against it. diff --git a/openspec/changes/sharing-itsm-exchange/proposal.md b/openspec/changes/sharing-itsm-exchange/proposal.md new file mode 100644 index 000000000..439cf3faf --- /dev/null +++ b/openspec/changes/sharing-itsm-exchange/proposal.md @@ -0,0 +1,43 @@ +--- +kind: code +depends_on: + - landscape-usage-registration +--- + +# Exchange the application landscape with the organisation's service desk + +## Summary + +A functional administrator connects stackiq to the organisation's service management tool, such as TOPdesk, ServiceNow or GLPI. The applications the organisation uses go to the service desk as configuration items, with supplier, version, status and owners, and the service desk's record id and link come back onto each application in use. Service desk staff then log calls against the same applications the catalogue holds, and the catalogue shows where each one lives in the service desk. + +## Why + +Row from the stackiq matrix: + +- `stackiq:share-itsm-integration`, "Exchange application data with the organisation's service management tool." Rated no. Four competitors rate yes: SAP LeanIX (https://www.leanix.net/hubfs/Legal/Metrics-and-Feature-List-EAM-SAP-LeanIX-v3.1.pdf, "ServiceNow integration ... to synchronize infrastructure and software asset information"), BlueDolphin (https://help.bluedolphin.io/en/articles/11967779-add-an-integration-in-bluedolphin, "out-of-the-box integrations with ITSM platforms like TOPdesk, ServiceNow, and JIRA"), GLPI (source read at 11.0.9, application records used directly by tickets, `src/Appliance.php:105`) and TOPdesk (https://docs.topdesk.com/en/linking-assets-to-cards.html, assets linked to calls and changes). + +No tender, feature request or roadmap row names it. The matrix category keeps stackiq from being a service desk itself (`stackiq:ops-tickets` and the other service desk rows are decided no); exchanging with one is this row. + +## What stackiq has today + +- No ITSM connector: `lib/Settings/connections.json` declares `email`, `federation` and `eol-feed` only, and `lib/` and `src/` hold no TOPdesk, ServiceNow or ITSM code. +- The connection registry (open change `adopt-connection-registry`) shows stackiq's outside connections on the Integrations page, backed by integriq. +- Stackiq's Flows page (`src/manifest.json:1057`) lists OpenRegister flows scoped to stackiq, and OpenRegister validates and creates flows through its API (`/api/flow/validate`, `/api/flows`). +- Integriq runs synchronisation as flow steps (fetch, map, contract, save) over its sources, with credentials held by integriq (integriq open change `flow-native-synchronization`, ADR-064, ADR-091). + +## What this change builds + +1. On `usage`: `externalReferences`, a list of `{ system, recordId, url, syncedAt }`, so an application in use knows its service desk record. +2. An `itsm` entry in `lib/Settings/connections.json`, so the Integrations page shows whether the exchange is set up and when it last ran. +3. Two flow templates with mapping presets for TOPdesk, ServiceNow and GLPI, and a set-up action for the administrator that fills in the integriq source and creates the flows: outbound (a usage created or changed goes to the service desk through integriq's source call) and inbound (a nightly read of the service desk's application records that writes their id and link back onto the matching usages). +4. The service desk link on the usage page and a Service desk column on Applications in use. + +## Out of scope + +- The service desk sources and their credentials: integriq's half. Integriq holds the TOPdesk, ServiceNow or GLPI source and its secret (ADR-064), and its connector catalogue gets the source templates. Stackiq names the source by reference only. +- Logging calls or incidents in stackiq: decided no (`stackiq:ops-tickets`, the matrix category). +- Discovering installed software from the service desk's inventory: the matrix category says stackiq is not a discovery agent. + +## Risks + +- Matching an existing service desk record to a usage is by name and supplier on the first run; a record that does not match stays unlinked and is listed in the flow run for a person to link by hand. diff --git a/openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md b/openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md new file mode 100644 index 000000000..4b59cdaf4 --- /dev/null +++ b/openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md @@ -0,0 +1,35 @@ +# itsm-exchange specification + +**Status**: proposed +**Scope**: stackiq +**OpenSpec changes**: +- sharing-itsm-exchange + +## Purpose + +The organisation's applications in use are exchanged with its service management tool through integriq, and each carries a link to its service desk record. Matrix row `stackiq:share-itsm-integration`. + +## ADDED Requirements + +### Requirement: REQ-ITX-001 The organisation's applications in use reach its service desk + +Stackiq SHALL let an administrator set up, from the admin settings, an outbound flow that sends a usage's application, supplier, version, status, owners and BBN level to the service desk source the administrator configured in integriq when the usage is created or changed, and an inbound flow that reads the service desk's application records nightly and links them to matching usages. Stackiq SHALL NOT hold the service desk credentials. + +#### Scenario: A new application in use reaches TOPdesk +@e2e tests/e2e/workflows/itsm-exchange.spec.ts + +- **GIVEN** a Nextcloud admin set up the service desk exchange with the integriq source for the municipality's TOPdesk and the TOPdesk preset +- **WHEN** an information manager moves the usage of application X to In production +- **THEN** the flow run shows the usage sent to the source +- **AND** the usage carries the record id TOPdesk returned + +### Requirement: REQ-ITX-002 An application in use shows its service desk record + +A usage SHALL keep its service desk references (system, record id, link, last synchronised), show the link on its page and in a Service desk column on Applications in use, and the Integrations page SHALL show the service desk exchange with the outcome of its last run. + +#### Scenario: A service desk employee finds the catalogue entry and back +@e2e tests/e2e/workflows/itsm-exchange.spec.ts + +- **GIVEN** a usage linked to service desk record A-123 +- **WHEN** the information manager opens Applications in use +- **THEN** the Service desk column shows A-123 and opens the record in the service desk diff --git a/openspec/changes/sharing-itsm-exchange/tasks.md b/openspec/changes/sharing-itsm-exchange/tasks.md new file mode 100644 index 000000000..7efaeb21c --- /dev/null +++ b/openspec/changes/sharing-itsm-exchange/tasks.md @@ -0,0 +1,43 @@ +# Tasks: sharing-itsm-exchange + +## Implementation tasks + +### Task 1: External references and the connection entry +- **spec_ref**: openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-002-an-application-in-use-shows-its-service-desk-record +- **files**: `lib/Settings/register.d/itsm-exchange.json`, `lib/Settings/connections.json`, `tests/Unit/Settings/ConnectionsDeclarationTest.php` +- **acceptance_criteria**: + - GIVEN the merged register WHEN it is imported THEN usage carries externalReferences + - GIVEN integriq installed WHEN the Integrations page opens THEN it lists Service desk +- [ ] Implement +- [ ] Test (PHPUnit `tests/Unit/Settings/ItsmExchangeFragmentTest.php`; the existing `ConnectionsDeclarationTest.php` extended for the itsm entry) + +### Task 2: Flow templates and the set-up action +- **spec_ref**: openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-001-the-organisations-applications-in-use-reach-its-service-desk +- **files**: `lib/Settings/flows/itsm-outbound.json`, `lib/Settings/flows/itsm-inbound.json`, `lib/Service/ItsmExchangeService.php`, `lib/Controller/ItsmExchangeController.php`, `appinfo/routes.php`, `src/views/settings/sections/ItsmExchange.vue`, `lib/Service/ConnectionReportService.php` (report for the itsm key) +- **acceptance_criteria**: + - GIVEN the administrator set up the exchange with a TOPdesk source WHEN a usage goes live THEN the flow sends it to the source and writes the returned record id back + - GIVEN a nightly run WHEN a service desk record matches a usage by name and supplier THEN the usage gets its link +- [ ] Implement +- [ ] Test (PHPUnit `tests/Unit/Service/ItsmExchangeServiceTest.php` fills and validates the templates; a flow test run against a mock source in `tests/e2e/workflows/itsm-exchange.spec.ts`) + +### Task 3: Service desk link on the pages +- **spec_ref**: openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-002-an-application-in-use-shows-its-service-desk-record +- **files**: `src/manifest.d/usages.json`, `l10n/en.json`, `l10n/nl.json` +- **acceptance_criteria**: + - GIVEN a usage with a service desk reference WHEN Applications in use opens THEN the Service desk column links to the record +- [ ] Implement +- [ ] Test (Playwright case in `tests/e2e/workflows/itsm-exchange.spec.ts`) + +### Task 4: Documentation +- **spec_ref**: openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-001-the-organisations-applications-in-use-reach-its-service-desk +- **files**: `docs/features/service-desk-exchange.md`, `docs/images/service-desk-exchange.png` +- **acceptance_criteria**: + - GIVEN the docs site WHEN a reader opens Service desk exchange THEN setting up the integriq source, installing the set and reading the results are explained with a screenshot +- [ ] Implement +- [ ] Test (docs build, screenshot with Playwright) + +## Verification + +- `openspec validate sharing-itsm-exchange --type change --strict` passes. +- `composer check:strict` and `npm run lint` pass; the PHPUnit and Playwright cases above pass. +- English and Dutch strings for every new label (ADR-005); docs with a screenshot (ADR-010). From 7e5efacd334174d83ba71b8fb6dfa4802343c026 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 27 Sep 2026 20:13:35 +0200 Subject: [PATCH 14/20] docs(openspec): sharing-itsm-exchange, seed data line follows the set-up action --- openspec/changes/sharing-itsm-exchange/design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openspec/changes/sharing-itsm-exchange/design.md b/openspec/changes/sharing-itsm-exchange/design.md index 1bd4daf3c..fd7cfb6fd 100644 --- a/openspec/changes/sharing-itsm-exchange/design.md +++ b/openspec/changes/sharing-itsm-exchange/design.md @@ -37,7 +37,7 @@ Declarative: fields, the connection entry, and the flows and mappings as data ru ## Seed data -None; the configuration set is installed on purpose. +None; the flows are created on purpose by the set-up action. ## Risks From 2355fcc52e29830a131d6c8018874b703b82653b Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 27 Sep 2026 20:16:45 +0200 Subject: [PATCH 15/20] docs(openspec): operations-record-reconciliation, OpenRegister dedup and merge for applications and services --- .../.openspec.yaml | 2 + .../design.md | 72 +++++++++++++++ .../proposal.md | 54 +++++++++++ .../specs/record-reconciliation/spec.md | 90 +++++++++++++++++++ .../operations-record-reconciliation/tasks.md | 60 +++++++++++++ 5 files changed, 278 insertions(+) create mode 100644 openspec/changes/operations-record-reconciliation/.openspec.yaml create mode 100644 openspec/changes/operations-record-reconciliation/design.md create mode 100644 openspec/changes/operations-record-reconciliation/proposal.md create mode 100644 openspec/changes/operations-record-reconciliation/specs/record-reconciliation/spec.md create mode 100644 openspec/changes/operations-record-reconciliation/tasks.md diff --git a/openspec/changes/operations-record-reconciliation/.openspec.yaml b/openspec/changes/operations-record-reconciliation/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/operations-record-reconciliation/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/operations-record-reconciliation/design.md b/openspec/changes/operations-record-reconciliation/design.md new file mode 100644 index 000000000..f1761ad6d --- /dev/null +++ b/openspec/changes/operations-record-reconciliation/design.md @@ -0,0 +1,72 @@ +# Design: operations-record-reconciliation + +Read at development 49e65cb4, with OpenRegister development 4fee776 and `@conduction/nextcloud-vue` 2.57.1. + +## Where it fits + +| Part | File and line | What changes | +|---|---|---| +| Fragment | new `lib/Settings/register.d/operations-record-reconciliation.json` (ADR-037) | `recordStatus` and `mergedInto` on `module` (`lib/Settings/softwarecatalogus_register.json:6777`) and `catalogService` (`:1324`); `configuration.x-openregister-merge` on both; `configuration.x-openregister-dedup` on `catalogService`; higher schema versions | +| Register | `lib/Settings/softwarecatalogus_register.json:7` | register version and changelog entry in the monolith, since a fragment cannot set the register's own version text | +| Listener | new `lib/EventListener/CatalogueMergeRelinker.php`, registered in `lib/AppInfo/Application.php:777` `registerEventListeners()` | on OpenRegister's `ObjectsMergedEvent` for `module` or `catalogService`: re-point references, set `mergedInto` | +| Map | new `lib/Service/CatalogueReferenceMap.php` | the references per target schema, one list for the listener and the organisation merge | +| Service | `lib/Service/MergeOrganisatieService.php:111` `FIELD_RELATION_TYPES` | reads the organisation entries from the map, adding the six missing references | +| Service | `lib/Service/FacetService.php:400` `fetchBaseObjects()` | leaves out objects whose `recordStatus` is Merged | +| Pages | `src/views/FacetedCatalogIndexView.vue:68` toolbar, `src/manifest.json:386` `Organisaties` `headerActions` | a Find duplicates action; handler `openDuplicateCandidates` in `src/customComponents.js` | +| Pages | `src/manifest.json:491` `ModuleDetail` | a body widget `MergedRecordBanner` | +| Component | new `src/components/merge/MergedRecordBanner.vue` | shows Merged into with a link to the survivor when `recordStatus` is Merged | +| Cleanup | `src/modals/object/MergeObject.vue`, `src/modals/Modals.vue:11`, `src/store/plugins/stackiqPlugin.js:1408` `mergeObjects` | removed | + +## Decisions + +### D1. OpenRegister finds and merges; stackiq declares (ADR-045) + +ADR-045 gives OpenRegister the duplicate candidates surface and the reversible merge, and lists an app-local merge wizard or dedup scanner as review-blocking. OpenRegister already reads `configuration.x-openregister-dedup` (`DuplicateDetectionService.php:587`), lists candidate pairs, lets a steward dismiss a pair, and merges with preview, execute and reverse (`lib/Service/Merge/MergeService.php`). Both annotations are in `Schema::ANNOTATION_VOCABULARY` (`lib/Db/Schema.php:3154` and `:3163` in OpenRegister), so they survive import. Stackiq declares: + +- `catalogService`: `x-openregister-dedup` with `matchRules` name normalized 0.4, name levenshtein 0.2, provider exact 0.3, website normalized 0.1, threshold 0.7, the same shape as `module`'s. +- `module` and `catalogService`: `x-openregister-merge` with `statusField` `recordStatus`, `survivorStatus` Active, `mergedStatus` Merged, `reversalWindowDays` 30. + +Rejected: mounting the dead `MergeObject.vue` for applications. It is exactly the app-local merge tool ADR-045 forbids, and it calls OpenRegister's older per-object merge (`stackiqPlugin.js:1418`) without preview or reversal. + +### D2. The catalogue links to OpenRegister's page instead of hosting one + +The steward opens Find duplicates on `/modules`, `/diensten` or `/organisaties` and lands on OpenRegister's `/duplicates` page (`src/manifest.json:415` in OpenRegister), which lists pairs and runs the merge wizard. The action shows for Nextcloud admins and functional administrators. On the two faceted pages it is an entry in the toolbar's `NcActions` next to Saved views (`src/views/FacetedCatalogIndexView.vue:68`); on `Organisaties` it is a `headerActions` entry with a handler, the pattern the Integrations page uses (`src/manifest.d/connection-registry.json:37` to `:43`), because a manifest `navigate` only pushes a route inside stackiq. + +### D3. Stackiq re-points catalogue references after OpenRegister merges + +OpenRegister's merge relinks one reverse reference per schema (`relinkReverseFk()`, `MergeService.php:684`), meant for source records. A module is referenced from eleven places in the register: `suite.applications`, `catalogService.modules`, `vulnerability.modules`, `usage.module`, `usage.plannedReplacement`, `connection.moduleA`, `connection.moduleB`, `connection.realisedWithIntermediaryModule`, `software-review.modules`, `compliancy.module` and `moduleVersion.module`. A service from five: `usage.diensten`, `catalogContract.service`, `connection.service`, `software-review.diensten` and `module.diensten`. `CatalogueMergeRelinker` listens to `ObjectsMergedEvent` (`getSurvivorUuid()`, `getMergedFromUuids()`, `getMergeOperationId()`), walks `CatalogueReferenceMap` for the merged schema, replaces each loser uuid by the survivor in scalar and array fields (dropping a duplicate that the replacement creates in an array), sets `mergedInto` on the losers, and writes one audit entry per moved reference with the merge operation id. + +This is the domain half ADR-045 leaves to the app: which fields point at an application. The generic half, relinking every `$ref` inside the merge unit so a reversal restores it, is OpenRegister's, named in the proposal's Out of scope. When it lands the listener's walk becomes a no-op and can go. + +Rejected: declaring `x-openregister-merge` `sourceLink` for one of the references. It would move only that one, and it is meant for source records feeding a golden record, not for catalogue relations. + +### D4. One reference map for both merges + +`MergeOrganisatieService` keeps its own engine for organisations, because it also moves Nextcloud group membership (`:570`), which OpenRegister's engine does not. Its relation list moves into `CatalogueReferenceMap` next to the module and service lists, and gains `module.provider`, `catalogService.provider`, `usage.provider`, `organization.deelnames`, `organization.participants` and `model.organizations`. `tests/Unit/Service/CatalogueReferenceMapTest.php` reads the merged register and fails when a `$ref` to `module`, `catalogService` or `organization` exists that the map does not list, so a new reference cannot be forgotten again. + +### D5. Merged records leave the lists but stay reachable + +`recordStatus` defaults to Active; a repair step sets it on existing rows so no row is left empty. `FacetService::fetchBaseObjects()` drops Merged objects, so `/modules`, `/diensten` and their facet counts leave them out; the manifest pages that filter by a value list are not used, because a value filter that matches no stored value hides every row (the lesson in the `Organisaties` page note, `src/manifest.json:396`). A merged record keeps its detail page, and `MergedRecordBanner` says Merged into and links to the survivor. + +## Declarative versus imperative + +- Duplicate detection and the merge are declarative: `x-openregister-dedup` and `x-openregister-merge` on the schemas (ADR-031, ADR-045). +- The reference walk after a merge is imperative, in one listener, because no OpenRegister rule relinks arbitrary references yet (D3). +- The Merged into banner is a relation shown from a field, which a built-in widget cannot condition on the status, so it is a small body widget. + +## Seed data + +`module` and `catalogService` gain `recordStatus` and `mergedInto`. The demo descriptor `lib/Settings/stackiq_mock_register.json` sets `recordStatus: Active` on the existing modules and services, and adds one likely duplicate in the `stackiq` register so the candidates page has a pair on a fresh instance: + +| `@self.slug` | `name` | `provider` | `website` | `recordStatus` | +|---|---|---|---|---| +| module-voorbeeld-name-1-1 (existing) | Voorbeeld Name 1 | `@ref:organization-voorbeeld-name-2-2` (set here if `insight-supplier-facet` has not) | https://example.invalid/resource/0 | Active | +| module-duplicate-1 | Voorbeeld name 1 | `@ref:organization-voorbeeld-name-2-2` | https://example.invalid/resource/0 | Active | + +Name normalized, provider and website match, so the pair scores 1.0 against the 0.7 threshold. + +## Risks + +- **Reversal leaves references on the survivor** until OpenRegister relinks inside the merge unit. The audit entries list every move with the merge operation id, so a steward can put them back by hand. +- **Federation mirrors.** A merged-away mirror returns on the next pull (`FederationMerger` plans by peer id). The docs tell the steward to keep the mirror as survivor or dismiss the pair. +- **Rights.** OpenRegister's merge checks the caller's rights on both objects; the listener writes with the same rights the organisation merge uses today and only touches references to the two merged objects. diff --git a/openspec/changes/operations-record-reconciliation/proposal.md b/openspec/changes/operations-record-reconciliation/proposal.md new file mode 100644 index 000000000..9296030e4 --- /dev/null +++ b/openspec/changes/operations-record-reconciliation/proposal.md @@ -0,0 +1,54 @@ +--- +kind: code +depends_on: [] +--- + +# Find and merge duplicate applications and services through OpenRegister + +## Summary + +The same application arrives twice: once typed by a supplier, once from a federation peer or an import, spelled a little differently. Today a Nextcloud admin can merge two organisations in stackiq, and nobody can merge two applications or two services. OpenRegister already finds duplicate candidates from the rules stackiq declares, and already merges two objects reversibly. This change declares those rules and the merge settings on applications and services, links the catalogue pages to OpenRegister's duplicate candidates page, re-points the catalogue's references when OpenRegister merges, hides merged records, and fixes the organisation merge, which leaves a merged supplier's applications and services behind. + +## Why + +This change covers two matrix rows. + +- `stackiq:ops-reconciliation`, "Deduplicate and reconcile records that arrive from several sources." Stackiq rates itself partial: an admin can merge duplicate organisations with a dry run, and nothing deduplicates applications or reconciles records from several sources. SAP LeanIX rates yes: "import software records from ServiceNow as aggregated software fact sheets" (https://help.sap.com/docs/leanix/ea/aggregation-and-linkage-of-software-records), with custom matching to avoid "duplicate fact sheets" (https://help.sap.com/docs/leanix/ea/matching-rules). BlueDolphin rates yes: "for each record an object will be created or merged with an already existing object" (https://help.bluedolphin.io/en/articles/11967635-four-things-you-need-to-know-before-you-start-importing-sources). GLPI rates yes from its source at 11.0.9: `src/RuleImportAsset.php:46` import and link rules match incoming records to existing assets, `src/RuleDictionnarySoftware.php:44` normalises software names from different sources, and duplicates can be merged afterwards (`src/Software.php:1011`). +- `stackiq:land-duplicate-merge`, "Merge two catalogue entries that turn out to describe the same thing." Stackiq rates itself partial: only organisations can be merged, and only by a Nextcloud admin. GLPI rates yes from its source: `src/Software.php:926` lists same-named software as merge candidates and `src/Software.php:1011` moves versions and licences into the kept entry. This row is below the bar on its own and rides with `stackiq:ops-reconciliation`: its missing half is merging applications and services, which is the merge this change extends. + +`ops-reconciliation` is partial and built: this change builds deduplication of applications and reconciliation of records from several sources. + +## What stackiq has today + +Read at development 49e65cb4, with OpenRegister development 4fee776. + +- `lib/Controller/MergeController.php:106` `execute()` and `:82` `dryRun()` merge one organisation into another, admin only (`:142`), through `lib/Service/MergeOrganisatieService.php`. The panel `src/components/organisations/OrganisationMergePanel.vue:46` shows its controls to admins only, mounted on `OrganisatieDetail` as `org-merge` (`src/manifest.json:430`). +- That merge re-points the relations listed in `FIELD_RELATION_TYPES` (`lib/Service/MergeOrganisatieService.php:111`: `usage.consumer`, `usage.participants`, `contactPerson.organization`, `connection.provider`) and `@self.organisation` on `catalogContract` and `compliancy` (`:122`). It misses `module.provider`, `catalogService.provider`, `usage.provider`, `organization.deelnames`, `organization.participants` and `model.organizations`, all of which reference an organisation in the register. A merged supplier's applications and services keep pointing at the tombstone. +- `src/modals/object/MergeObject.vue` is a generic merge modal, mounted only for the modal id `mergeOrganisatie` (`src/modals/Modals.vue:11`), which nothing sets. +- `lib/Service/Federation/FederationMerger.php` reconciles a peer's entries with that peer's own mirrors, by peer id. It never compares a mirror with a local record, so a peer's copy of a local application stays a second record. +- `module` (`lib/Settings/softwarecatalogus_register.json:7346`) and `organization` (`:2585`) declare `x-openregister-dedup` in their `configuration`. OpenRegister reads exactly that key (`lib/Service/Quality/DuplicateDetectionService.php:587` and `:588` in OpenRegister) and serves candidate pairs at `GET /api/objects/duplicates/{register}/{schema}` and on its page `/duplicates` (`src/views/quality/DuplicatesIndex.vue`). So the rules are wired in OpenRegister and dormant in stackiq: no stackiq page links to them. `catalogService` declares none. No schema declares `x-openregister-merge`. +- OpenRegister's merge engine (`lib/Service/Merge/MergeService.php`, routes `/api/objects/merge/preview`, `/execute` and `/{id}/reverse`) snapshots, flips the loser's status, records a `mergeOperation`, allows reversal within a window, and dispatches `ObjectsMergedEvent`. It relinks one reverse reference per schema (`relinkReverseFk()`, `:684`), meant for source records, not every catalogue reference to a module. + +## What this change builds + +- `x-openregister-dedup` on `catalogService`, next to the existing rules on `module` and `organization`. +- `x-openregister-merge` on `module` and `catalogService`, with a new `recordStatus` (Active, Merged) and `mergedInto` on both. +- A Find duplicates action on the Applications, Services and Organisations pages, for admins and functional administrators, that opens OpenRegister's Duplicate candidates page. +- A listener on `ObjectsMergedEvent` that re-points every catalogue reference to a merged application or service onto the survivor and sets `mergedInto`. +- Merged applications and services left out of the Applications and Services lists and facets, and a Merged into banner on their detail page. +- The organisation merge re-points the six missing references. +- The unused `MergeObject.vue` modal and its `mergeOrganisatie` branch go. + +## Out of scope + +- Relinking every reference inside OpenRegister's merge unit, so that a reversal restores them too. That is OpenRegister's half (ADR-045: relink and reverse on any schema). Until it lands, stackiq's listener re-points references after the merge (D3). +- Opening OpenRegister's Duplicate candidates page on a given register and schema from a link. The page has no query parameters today; the steward picks the register and schema there. That is OpenRegister's half. +- Moving the organisation merge onto OpenRegister's engine. The stackiq merge also moves Nextcloud group membership (`migrateGroupMembership()`, `:570`), which OpenRegister's engine does not do. +- Matching rules for imports and federation pulls at the moment a record arrives (`x-openregister-dedup` `onCreate`). A record is created and then surfaces as a candidate. +- Bulk merges of more than two records at once. + +## Risks + +- Until OpenRegister relinks inside the merge unit, a reversal restores the two records but leaves the references stackiq re-pointed on the survivor. The listener writes each move to the audit log, and the docs say so. +- A federation mirror merged away comes back on the next pull, because `FederationMerger` plans updates by peer id. The docs tell the steward to keep the mirror as survivor or dismiss the pair. +- New properties on `module` and `catalogService`: both schema versions and the register version must go up, or the import skips the change. diff --git a/openspec/changes/operations-record-reconciliation/specs/record-reconciliation/spec.md b/openspec/changes/operations-record-reconciliation/specs/record-reconciliation/spec.md new file mode 100644 index 000000000..b8990daed --- /dev/null +++ b/openspec/changes/operations-record-reconciliation/specs/record-reconciliation/spec.md @@ -0,0 +1,90 @@ +# record-reconciliation specification + +**Status**: proposed +**Scope**: stackiq +**OpenSpec changes**: +- operations-record-reconciliation + +## Purpose + +A functional administrator finds applications, services and organisations that were recorded twice, from different sources or by different people, and merges them through OpenRegister's duplicate candidates page. Stackiq declares how to recognise a duplicate and how to merge it, keeps every catalogue reference pointing at the record that survives, and stops showing the merged one in its lists. + +## ADDED Requirements + +### Requirement: REQ-RRC-001 Applications, services and organisations SHALL declare duplicate rules, and applications and services SHALL declare how they merge + +`module`, `catalogService` and `organization` SHALL carry `x-openregister-dedup` in their configuration. `module` and `catalogService` SHALL carry `x-openregister-merge` with `statusField` `recordStatus`, survivor status Active, merged status Merged and a reversal window of 30 days, and SHALL have the properties `recordStatus` and `mergedInto`. + +#### Scenario: A duplicate application appears as a candidate +@e2e tests/e2e/spec-coverage/record-reconciliation.spec.ts + +- **GIVEN** the demo applications Voorbeeld Name 1 and Voorbeeld name 1 from the same supplier with the same website +- **WHEN** a functional administrator opens OpenRegister's Duplicate candidates page for the catalogue register and the application schema +- **THEN** the two applications SHALL be listed as a pair above the threshold + +#### Scenario: The declarations survive import +@e2e exclude A configuration shape; tests/Unit/Settings/ReconciliationDeclarationTest.php asserts the merged register holds both annotations with the values above and a higher version on both schemas. + +- **GIVEN** the merged register +- **WHEN** `module` and `catalogService` are read +- **THEN** both SHALL carry `x-openregister-dedup` and `x-openregister-merge` in their configuration + +### Requirement: REQ-RRC-002 The catalogue pages SHALL lead an administrator to OpenRegister's duplicate candidates + +`/modules`, `/diensten` and `/organisaties` SHALL offer Find duplicates to Nextcloud admins and functional administrators, opening OpenRegister's Duplicate candidates page. Other users SHALL NOT see the action. + +#### Scenario: A functional administrator goes to the candidates +@e2e tests/e2e/spec-coverage/record-reconciliation.spec.ts + +- **GIVEN** a functional administrator on `/modules` +- **WHEN** they choose Find duplicates +- **THEN** OpenRegister's Duplicate candidates page SHALL open + +#### Scenario: A regular user does not see the action +@e2e tests/e2e/spec-coverage/record-reconciliation.spec.ts + +- **GIVEN** a municipal information manager who is not an admin and not a functional administrator +- **WHEN** they open `/modules` +- **THEN** the page SHALL NOT offer Find duplicates + +### Requirement: REQ-RRC-003 After OpenRegister merges two applications or services, every catalogue reference SHALL point at the survivor + +On `ObjectsMergedEvent` for `module` or `catalogService`, stackiq SHALL replace the merged uuid by the survivor's uuid in every reference the catalogue reference map lists for that schema, in scalar and array fields, without leaving the survivor twice in one array. It SHALL set `mergedInto` on the merged record and SHALL write one audit entry per moved reference with the merge operation id. + +#### Scenario: Usages and connections follow the survivor +@e2e tests/e2e/spec-coverage/record-reconciliation.spec.ts + +- **GIVEN** an application in use by Gemeente Voorbeeld and a connection, both on the duplicate application +- **WHEN** a functional administrator merges the duplicate into the original on OpenRegister's page +- **THEN** the usage and the connection SHALL point at the original +- **AND** the original's detail page SHALL list them + +#### Scenario: An array does not get the survivor twice +@e2e exclude A data edge; tests/Unit/EventListener/CatalogueMergeRelinkerTest.php asserts a service that listed both applications lists the survivor once after the merge. + +- **GIVEN** a service whose `modules` lists both the original and the duplicate +- **WHEN** the duplicate is merged into the original +- **THEN** the service's `modules` SHALL list the original once + +### Requirement: REQ-RRC-004 Merged applications and services SHALL leave the lists and point readers to the survivor + +`/modules` and `/diensten`, and their facet counts, SHALL leave out records whose `recordStatus` is Merged. The detail page of a merged record SHALL stay reachable and SHALL show Merged into with a link to the survivor. + +#### Scenario: A reader opens an old link to a merged application +@e2e tests/e2e/spec-coverage/record-reconciliation.spec.ts + +- **GIVEN** a merged duplicate application +- **WHEN** a municipal information manager opens its old `/modules/:id` link +- **THEN** the page SHALL show Merged into with a link to the original +- **AND** `/modules` SHALL NOT list the duplicate + +### Requirement: REQ-RRC-005 The organisation merge MUST re-point every reference to the merged organisation + +The organisation merge SHALL re-point every register reference to an organisation, including `module.provider`, `catalogService.provider`, `usage.provider`, `organization.deelnames`, `organization.participants` and `model.organizations`. The reference map SHALL be checked against the register so a reference the map misses fails a test. + +#### Scenario: A merged supplier's applications follow it +@e2e exclude The merge walks every organisation reference; tests/Unit/Service/MergeOrganisatieServiceTest.php asserts module.provider and catalogService.provider are re-pointed in dry run and execute, and tests/Unit/Service/CatalogueReferenceMapTest.php asserts the map covers every $ref in the register. + +- **GIVEN** two supplier organisations that are the same company, each with one application +- **WHEN** a Nextcloud admin merges one into the other +- **THEN** both applications SHALL have the survivor as `provider` diff --git a/openspec/changes/operations-record-reconciliation/tasks.md b/openspec/changes/operations-record-reconciliation/tasks.md new file mode 100644 index 000000000..e6bc54e55 --- /dev/null +++ b/openspec/changes/operations-record-reconciliation/tasks.md @@ -0,0 +1,60 @@ +# Tasks: operations-record-reconciliation + +## Implementation tasks + +### Task 1: Declare the duplicate and merge rules and the record status +- **spec_ref**: openspec/changes/operations-record-reconciliation/specs/record-reconciliation/spec.md#requirement-req-rrc-001-applications-services-and-organisations-shall-declare-duplicate-rules-and-applications-and-services-shall-declare-how-they-merge +- **files**: `lib/Settings/register.d/operations-record-reconciliation.json`, `lib/Settings/softwarecatalogus_register.json`, `lib/Repair/` (a step that sets recordStatus Active on existing rows), `tests/Unit/Settings/ReconciliationDeclarationTest.php` +- **acceptance_criteria**: + - GIVEN the merged register WHEN module and catalogService are read THEN both carry x-openregister-dedup and x-openregister-merge in configuration, recordStatus and mergedInto in properties, and higher versions + - GIVEN existing rows WHEN the repair step runs THEN every module and service has recordStatus Active +- [ ] Implement +- [ ] Test (PHPUnit tests/Unit/Settings/ReconciliationDeclarationTest.php) + +### Task 2: Share one reference map and complete the organisation merge +- **spec_ref**: openspec/changes/operations-record-reconciliation/specs/record-reconciliation/spec.md#requirement-req-rrc-005-the-organisation-merge-must-re-point-every-reference-to-the-merged-organisation +- **files**: `lib/Service/CatalogueReferenceMap.php`, `lib/Service/MergeOrganisatieService.php`, `tests/Unit/Service/CatalogueReferenceMapTest.php`, `tests/Unit/Service/MergeOrganisatieServiceTest.php` +- **acceptance_criteria**: + - GIVEN the merged register WHEN every $ref to module, catalogService or organization is collected THEN the map lists each one, and the test fails for a missing one + - GIVEN an organisation merge WHEN it runs in dry run or execute THEN module.provider, catalogService.provider, usage.provider, organization.deelnames, organization.participants and model.organizations are counted and re-pointed +- [ ] Implement +- [ ] Test (PHPUnit tests/Unit/Service/CatalogueReferenceMapTest.php and tests/Unit/Service/MergeOrganisatieServiceTest.php) + +### Task 3: Re-point references after OpenRegister merges applications or services +- **spec_ref**: openspec/changes/operations-record-reconciliation/specs/record-reconciliation/spec.md#requirement-req-rrc-003-after-openregister-merges-two-applications-or-services-every-catalogue-reference-shall-point-at-the-survivor +- **files**: `lib/EventListener/CatalogueMergeRelinker.php`, `lib/AppInfo/Application.php`, `tests/Unit/EventListener/CatalogueMergeRelinkerTest.php` +- **acceptance_criteria**: + - GIVEN a real ObjectsMergedEvent for module WHEN the listener runs THEN every mapped reference to the loser points at the survivor and the loser has mergedInto + - GIVEN an array holding both uuids WHEN the listener runs THEN it holds the survivor once + - GIVEN an event for another schema WHEN the listener runs THEN it changes nothing + - GIVEN a moved reference WHEN the listener finishes THEN one audit entry names it with the merge operation id +- [ ] Implement +- [ ] Test (PHPUnit tests/Unit/EventListener/CatalogueMergeRelinkerTest.php, constructing the real OpenRegister event class) + +### Task 4: Hide merged records and add the banner and the Find duplicates action +- **spec_ref**: openspec/changes/operations-record-reconciliation/specs/record-reconciliation/spec.md#requirement-req-rrc-004-merged-applications-and-services-shall-leave-the-lists-and-point-readers-to-the-survivor +- **files**: `lib/Service/FacetService.php`, `src/components/merge/MergedRecordBanner.vue`, `src/views/FacetedCatalogIndexView.vue`, `src/manifest.json`, `src/customComponents.js`, `tests/Unit/Service/FacetServiceTest.php`, `l10n/en.json`, `l10n/nl.json` +- **acceptance_criteria**: + - GIVEN a Merged module WHEN /modules and its facets load THEN it is not counted or listed + - GIVEN a Merged module WHEN its detail page opens THEN the banner links to the survivor + - GIVEN an admin or functional administrator WHEN /modules, /diensten or /organisaties opens THEN Find duplicates opens OpenRegister's Duplicate candidates page, and other users do not see it +- [ ] Implement +- [ ] Test (PHPUnit tests/Unit/Service/FacetServiceTest.php and Playwright tests/e2e/spec-coverage/record-reconciliation.spec.ts) + +### Task 5: Remove the unused merge modal, seed a duplicate and document the flow +- **spec_ref**: openspec/changes/operations-record-reconciliation/specs/record-reconciliation/spec.md#requirement-req-rrc-002-the-catalogue-pages-shall-lead-an-administrator-to-openregisters-duplicate-candidates +- **files**: `src/modals/object/MergeObject.vue`, `src/modals/Modals.vue`, `src/store/plugins/stackiqPlugin.js`, `lib/Settings/stackiq_mock_register.json`, `docs/features/record-reconciliation.md` +- **acceptance_criteria**: + - GIVEN the source tree WHEN it is searched THEN MergeObject.vue, the mergeOrganisatie modal branch and mergeObjects are gone and the build passes + - GIVEN a fresh demo import WHEN OpenRegister's candidates page is opened for applications THEN the seeded pair is listed + - GIVEN the docs WHEN a reader opens the feature page THEN it shows the steps with screenshots and says what a reversal does not restore yet +- [ ] Implement +- [ ] Test (Playwright tests/e2e/spec-coverage/record-reconciliation.spec.ts against the demo data) + +## Verification + +- `openspec validate operations-record-reconciliation --type change --strict` +- PHPUnit: tests/Unit/Settings/ReconciliationDeclarationTest.php, tests/Unit/Service/CatalogueReferenceMapTest.php, tests/Unit/Service/MergeOrganisatieServiceTest.php, tests/Unit/EventListener/CatalogueMergeRelinkerTest.php, tests/Unit/Service/FacetServiceTest.php +- Playwright: tests/e2e/spec-coverage/record-reconciliation.spec.ts +- Docs in docs/features/record-reconciliation.md with screenshots (ADR-010) +- English and Dutch strings for the action, the banner and the record status values (ADR-005) From 024fda5c867a2035ba501b0ca3d63c7734f19cb3 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 27 Sep 2026 20:21:23 +0200 Subject: [PATCH 16/20] docs(openspec): organisations-role-mapping-and-access-review, role groups from chosen groups and an access review page --- .../.openspec.yaml | 2 + .../design.md | 67 ++++++++++++ .../proposal.md | 50 +++++++++ .../role-mapping-and-access-review/spec.md | 100 ++++++++++++++++++ .../tasks.md | 74 +++++++++++++ 5 files changed, 293 insertions(+) create mode 100644 openspec/changes/organisations-role-mapping-and-access-review/.openspec.yaml create mode 100644 openspec/changes/organisations-role-mapping-and-access-review/design.md create mode 100644 openspec/changes/organisations-role-mapping-and-access-review/proposal.md create mode 100644 openspec/changes/organisations-role-mapping-and-access-review/specs/role-mapping-and-access-review/spec.md create mode 100644 openspec/changes/organisations-role-mapping-and-access-review/tasks.md diff --git a/openspec/changes/organisations-role-mapping-and-access-review/.openspec.yaml b/openspec/changes/organisations-role-mapping-and-access-review/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/organisations-role-mapping-and-access-review/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/organisations-role-mapping-and-access-review/design.md b/openspec/changes/organisations-role-mapping-and-access-review/design.md new file mode 100644 index 000000000..1c43e2bb2 --- /dev/null +++ b/openspec/changes/organisations-role-mapping-and-access-review/design.md @@ -0,0 +1,67 @@ +# Design: organisations-role-mapping-and-access-review + +Read at development 49e65cb4, with OpenRegister development 4fee776. + +## Where it fits + +| Part | File and line | What changes | +|---|---|---| +| Service | new `lib/Service/RoleGroupMap.php` | the six catalogue roles, their role group (the lower-case name the register's `authorization` uses), the admin-chosen source groups per role, and the default role per organisation type | +| Service | new `lib/Service/RoleGrantSync.php` | computes a person's roles and sets their role group memberships to exactly match | +| Service | `lib/Service/Stackiq/GroupHandler.php:286` `updateRoleBasedGroups()`, `:457` `updateGemeenteGroups()` | the first delegates to `RoleGrantSync`; the second, which does nothing, goes | +| Service | `lib/Service/Stackiq/ContactPersonHandler.php:1615` `getRoleGroupByOrganizationType()` | reads the organisation type default from `RoleGroupMap`, on the English enum | +| Settings | `lib/Service/SettingsService.php:6375` `getUserGroupsConfig()` and its update; `lib/Controller/SettingsController.php:3379` | `roleMapping`, `organisationTypeRoles` and `accessReviewIntervalDays` in the same admin-only config | +| Admin view | `src/views/settings/sections/UserGroupsConfiguration.vue`, `src/store/modules/settings.js:826` | a Role mapping table and the review interval | +| Listener | new `lib/EventListener/RoleGrantLoginListener.php`, registered next to `UserLoggedInEvent` in `lib/AppInfo/Application.php:779` | syncs the signing-in user | +| Job | new `lib/BackgroundJob/RoleGrantSyncJob.php` | nightly sync of every catalogue user | +| Repair | new step in `lib/Repair/` | writes the role onto the contact person of each current role group member who lacks it | +| Fragment | new `lib/Settings/register.d/organisations-role-mapping-and-access-review.json` | `accessReviewedAt` and `accessReviewedBy` on `contactPerson`, higher version | +| Controller and routes | new `lib/Controller/AccessReviewController.php`; `GET /api/access-review/{organisationUuid}` and `POST /api/access-review/{contactPersonId}/confirm` in `appinfo/routes.php` | the review list and the Keep access action | +| Page and menu | new `src/manifest.d/organisations-role-mapping-and-access-review.json`: page `AccessReview` at `/organisaties/access-review`, `type: custom`, component `AccessReviewView`; menu entry with `permission: access.review`; `src/menu-layout.json` relocation under `Organisaties` | | +| View | new `src/views/access/AccessReviewView.vue`, registered in `src/customComponents.js` | `CnDataTable` from the library (`src/components/CnDataTable`), organisation picker for admins | +| Shell | `lib/Controller/DashboardController.php:54` (from `operations-sync-status-and-progress`) | adds `access.review` to the permission list for admins and functional administrators | + +## Decisions + +### D1. Role groups stay the access vocabulary; the mapping says who belongs in them + +The register's `authorization` rules name the role groups (for example `usage.authorization.read` names `gebruik-beheerder`), and a register import replaces those lists as written. Renaming the group a role uses would mean rewriting every schema's rules and losing the change on the next import. So a role keeps its group, and the admin chooses which of their own groups grant the role: Inkoop grants Gebruik-beheerder. `RoleGrantSync` puts the members of Inkoop into `gebruik-beheerder`. Nextcloud has no nested groups, which is why stackiq keeps the membership in step itself. + +Rejected: rewriting `authorization` lists in OpenRegister from the mapping. It fights the import, and a missed schema would silently lock a role out. + +### D2. A person's roles have three sources, and the sync sets exactly those + +`RoleGrantSync::rolesFor(user)` is the union of the contact person's `roles`, the roles whose source groups the user is in, and the default role of their organisation's type. The sync adds the user to each matching role group and removes them from role groups they no longer derive. It touches only the role groups in `RoleGroupMap`, never another group. Role and group names are compared case-insensitively, which ends the mismatch between Aanbod-beheerder and `aanbod-beheerder` in `updateRoleBasedGroups()`. + +The organisation type defaults are keyed on the stored enum: Municipality and Collaboration to Gebruik-beheerder, Supplier and Community to Aanbod-beheerder, the same intent as the comment at `ContactPersonHandler.php:1620` to `:1623`, and editable in the mapping. + +### D3. When the sync runs + +At sign-in (a listener on `UserLoggedInEvent`, the event `TestEventListener` already receives at `lib/AppInfo/Application.php:779`), after the admin saves the mapping (for the members of every changed source group), when a contact person's `roles` change (the existing `updateUserGroups()` path), and nightly for everyone, so a change in a directory group reaches people who do not sign in. + +### D4. The review lives on the contact person + +`contactPerson` gains `accessReviewedAt` and `accessReviewedBy`. Keep access sets both through OpenRegister, so the audit trail shows who confirmed whom and when. A person is due when `accessReviewedAt` is empty or older than the interval (default 365 days). Remove role edits the contact person's `roles`, and the sync follows. Disable account calls the existing admin-only `POST /api/contactpersonen/{contactpersoonId}/disable` (`appinfo/routes.php:177`). + +Rejected: a separate review campaign schema. The row asks whether every user still needs access; one date per person answers it, and a campaign can build on it later. + +### D5. Who reviews whom + +`AccessReviewController` answers a Nextcloud admin for any organisation, and a member of `functioneel-beheerder` or `organisatie-beheerder` for their own active organisation (user value `core`/`organisation`, the rule `PortfolioReportController::isAuthorisedForOrganisation()` applies at `lib/Controller/PortfolioReportController.php:139`). It reads through `ContactpersoonService::getContactPersonsWithUserDetailsForOrganization()` (`lib/Service/ContactpersoonService.php:832`) and adds each role's source from `RoleGroupMap`. A reviewer cannot confirm their own access; the page disables the button on their own row and the endpoint refuses it. + +## Declarative versus imperative + +- The new contact person fields are declarative schema properties. +- Keeping group membership in step is imperative: it acts on Nextcloud groups, which no `x-openregister-*` rule manages. +- A reminder when reviews fall due fits an `x-openregister-notifications` scheduled rule on `contactPerson`; it is named as a follow-up. + +## Seed data + +`contactPerson` gains two properties. The demo descriptor `lib/Settings/stackiq_mock_register.json` sets `accessReviewedAt` on the demo contact persons: one a week ago, one fourteen months ago and one empty, so the Access review page shows one current and two due rows on a fresh instance. The seed writer computes the dates from the import date. + +## Risks + +- **Managed groups.** D2 removes people from role groups they do not derive. The repair step runs first and writes the role onto every current member's contact person, and logs each one, so the upgrade changes nobody's access. +- **A role from a group cannot be removed on the page.** The page names the source group instead of offering Remove role for it. +- **Load of the nightly sync.** It walks catalogue users in batches with an explicit limit, the same bound the organisation sync uses. +- **`SyncAccessPolicy`** from `operations-sync-status-and-progress` checks membership of `functioneel-beheerder`. That group keeps its name under D1 and the sync keeps it filled, so the policy needs no change. diff --git a/openspec/changes/organisations-role-mapping-and-access-review/proposal.md b/openspec/changes/organisations-role-mapping-and-access-review/proposal.md new file mode 100644 index 000000000..3cd8f160d --- /dev/null +++ b/openspec/changes/organisations-role-mapping-and-access-review/proposal.md @@ -0,0 +1,50 @@ +--- +kind: code +depends_on: + - operations-sync-status-and-progress +--- + +# Map catalogue roles onto your own groups, and review who still needs access + +## Summary + +Stackiq's access rules name fixed groups such as `gebruik-beheerder` and `aanbod-beheerder`. A municipality that already keeps its buyers in a group called Inkoop cannot tell stackiq that those people are buyers; someone has to copy them into the fixed group by hand and keep it up to date. Nor can anyone check whether the people who hold a role still need it: stackiq fetches each user's last login and never shows it. This change lets a Nextcloud admin choose, per catalogue role, which groups grant it, keeps the role groups in step with that choice, fixes two paths that stopped assigning roles, and adds an Access review page where a functional administrator sees each person's roles, where they come from and when they last signed in, and confirms or withdraws them. + +## Why + +This change covers two matrix rows. + +- `stackiq:org-roles`, "Map catalogue roles such as administrator, buyer and civil servant onto user groups." Stackiq rates itself partial: an admin configures which groups count as generic users, organisation admins and super users, while the catalogue roles map to fixed group names. SAP LeanIX rates yes: "The role to be assigned to the user. Required values: ADMIN, MEMBER, or VIEWER" and "customer_roles ... The custom role to be assigned", mapped from identity provider groups (https://help.sap.com/docs/leanix/ea/sso-attribute-overview). BlueDolphin rates yes: "BlueDolphin uses Role-Based Access Control (RBAC). Every user is linked to one or more roles ... Add and delete custom roles" (https://help.bluedolphin.io/en/articles/11967624-manage-roles-and-permissions). GLPI rates yes from its source at 11.0.9: roles are profiles (`src/Profile.php:55`) mapped onto groups by authorisation rules (`src/RuleRight.php:236` group criterion, `:297` profile action). TOPdesk rates yes: "Assign these permissions via Supporting Files > Permission Groups > [Permission Group]" (https://docs.topdesk.com/en/automated-actions.html). +- `stackiq:org-access-review`, "Review periodically whether every user still needs their access, and withdraw what is no longer needed." Stackiq rates itself no. The demand is a tender: Helmond REQ78 asks administrators to check periodically that every user still needs access (https://www.tenderned.nl/aankondigingen/overzicht/398728). + +`org-roles` is partial and built: this change builds the mapping of each catalogue role onto a group the administrator chooses. `org-access-review` is built new. + +## What stackiq has today + +Read at development 49e65cb4. + +- `src/views/settings/StackiqSettings.vue:80` mounts `UserGroupsConfiguration`, which reads and writes `/api/user-groups/config` (`src/store/modules/settings.js:830`, `appinfo/routes.php:153` and `:154`, `lib/Controller/SettingsController.php:3379`, admin only). It holds generic groups, organisation admin groups and super user groups (`lib/Service/SettingsService.php:6375`). No catalogue role appears in it. +- `lib/Service/Stackiq/GroupHandler.php:167` creates the fixed role groups `aanbod-beheerder`, `gebruik-beheerder`, `gebruik-raadpleger`, `functioneel-beheerder`, `vng-raadpleger`, `organisatie-beheerder`, `organisaties-beheerder` and `ambtenaar`. The register's `authorization` rules name these groups. +- `GroupHandler::updateRoleBasedGroups()` (`:286`) walks only the configured generic groups (default `software-catalog-users`, `:103`) and adds a user when a group name equals one of the contact person's `roles` exactly. The roles are written with a capital (Aanbod-beheerder, `contactPerson.roles` enum) and the groups in lower case, so a contact person's role never puts them in its role group this way. +- `lib/Service/Stackiq/ContactPersonHandler.php:1615` `getRoleGroupByOrganizationType()` gives a new contact person a role group by organisation type, keyed on `gemeente`, `leverancier`, `samenwerking` and `community` (`:1624`). `organization.type` now holds Municipality, Supplier, Collaboration and Community (`lib/Repair/RenameDutchCatalogValues.php:66` to `:68`), so only Community still matches. `GroupHandler::updateGemeenteGroups()` (`:457`) does nothing any more. +- `lib/Service/ContactpersoonService.php:888` returns each account's `lastLogin` through `GET /api/contactpersonen/organisation/{organizationUuid}/with-user-details` (`appinfo/routes.php:170`), and `src/components/ContactpersonenList.vue:482` stores it. Nothing renders it. No review, recertification or expiry of access exists in `lib/` or `src/`. + +## What this change builds + +- A role mapping in the admin User groups section: for each catalogue role, the Nextcloud groups whose members hold it, and the default role per organisation type. +- One role service that every path uses: a person's roles come from their contact person's `roles`, from the mapped groups they are in, and from their organisation's type; stackiq keeps them in exactly the matching role groups, at sign-in, when the mapping changes, and nightly. +- The two broken paths fixed through that service: roles to role groups, and organisation type to role. +- An Access review page under Organisations, for functional administrators of an organisation and Nextcloud admins: each person with an account, their roles and where each comes from, last sign-in, account status, when their access was last reviewed and whether a review is due. Keep access records the review; Remove role withdraws a role; a Nextcloud admin can also disable the account. +- A review interval in admin settings. + +## Out of scope + +- Mapping identity provider claims to roles. OpenRegister derives groups from sign-in claims (`lib/Service/Rbac/DerivedGrantResolver.php` in OpenRegister); an admin who signs in through SAML or OpenID Connect maps claims to the role groups there. +- New catalogue roles or changing what a role may do. The register's `authorization` rules stay as they are and keep naming the role groups. +- Reminders when a review is due. The page shows what is due; a notification can follow as a declarative rule. +- Reviewing Nextcloud admins or accounts outside the catalogue. + +## Risks + +- Role groups become managed. A person an admin put in `gebruik-beheerder` by hand, without the role, would be taken out at the next sync. A migration step writes the role onto the contact person of every current member first, so nobody loses access at upgrade. +- Withdrawing a role that came from a mapped group does not stick while the person stays in that group. The page says where each role comes from and offers Remove role only for roles on the contact person; for a group role it names the group to change. diff --git a/openspec/changes/organisations-role-mapping-and-access-review/specs/role-mapping-and-access-review/spec.md b/openspec/changes/organisations-role-mapping-and-access-review/specs/role-mapping-and-access-review/spec.md new file mode 100644 index 000000000..12489768a --- /dev/null +++ b/openspec/changes/organisations-role-mapping-and-access-review/specs/role-mapping-and-access-review/spec.md @@ -0,0 +1,100 @@ +# role-mapping-and-access-review specification + +**Status**: proposed +**Scope**: stackiq +**OpenSpec changes**: +- organisations-role-mapping-and-access-review + +## Purpose + +A Nextcloud admin tells stackiq which of the organisation's own groups grant each catalogue role, and stackiq keeps the role groups its access rules use in step. A functional administrator reviews, per organisation, who holds which role, where it comes from and when they last signed in, and confirms or withdraws it. + +## ADDED Requirements + +### Requirement: REQ-RMA-001 A Nextcloud admin SHALL map each catalogue role onto groups of their choice + +The admin User groups settings SHALL list the catalogue roles Aanbod-beheerder, Gebruik-beheerder, Gebruik-raadpleger, Functioneel-beheerder, Organisatie-beheerder and VNG-raadpleger, each with the Nextcloud groups whose members hold that role, and the default role for each organisation type. Only a Nextcloud admin SHALL read or change the mapping. + +#### Scenario: An admin makes the Inkoop group buyers +@e2e tests/e2e/spec-coverage/role-mapping.spec.ts + +- **GIVEN** a Nextcloud admin in stackiq's admin settings and a group Inkoop with one member +- **WHEN** they map Gebruik-beheerder onto Inkoop and save +- **THEN** that member SHALL be in `gebruik-beheerder` after the save +- **AND** the member SHALL see the usages of their own organisation + +#### Scenario: A non-admin cannot change the mapping +@e2e exclude An admin guard; tests/Unit/Controller/SettingsControllerUserGroupsTest.php asserts 403 on read and write for a non-admin. + +- **GIVEN** a functional administrator who is not a Nextcloud admin +- **WHEN** they post a role mapping to `/api/user-groups/config` +- **THEN** stackiq SHALL answer 403 and keep the mapping + +### Requirement: REQ-RMA-002 A person SHALL be in exactly the role groups of the roles they derive + +A person's roles SHALL be the union of their contact person's `roles`, the roles whose mapped groups they are in, and the default role of their organisation's type. Stackiq SHALL add them to each matching role group and remove them from role groups they no longer derive, comparing names without regard to case, and SHALL NOT touch any other group. It SHALL do so at sign-in, after the mapping changes, after the contact person's roles change, and nightly. + +#### Scenario: A new supplier contact gets the supplier role +@e2e exclude A group side effect; tests/Unit/Service/RoleGrantSyncTest.php asserts a contact person of an organisation of type Supplier is added to aanbod-beheerder and one of type Municipality to gebruik-beheerder. + +- **GIVEN** a new contact person of an organisation of type Supplier, with no roles +- **WHEN** their account is created +- **THEN** they SHALL be in `aanbod-beheerder` + +#### Scenario: Leaving the mapped group ends the role +@e2e exclude A directory change between sign-ins; tests/Unit/Service/RoleGrantSyncTest.php asserts removal from gebruik-beheerder when the person leaves Inkoop and has no other source for the role. + +- **GIVEN** a member of Inkoop holding Gebruik-beheerder only through Inkoop +- **WHEN** they leave Inkoop and the nightly sync runs +- **THEN** they SHALL no longer be in `gebruik-beheerder` + +#### Scenario: The upgrade takes nobody's access +@e2e exclude A repair step; tests/Unit/Repair/SeedRolesFromGroupsTest.php asserts every current member of a role group gets the role on their contact person before the first sync. + +- **GIVEN** a person an admin had added to `gebruik-beheerder` by hand, without the role on their contact person +- **WHEN** stackiq is upgraded and the sync runs +- **THEN** they SHALL still be in `gebruik-beheerder` + +### Requirement: REQ-RMA-003 A functional administrator SHALL review the access of their organisation's people + +A page `AccessReview` at `/organisaties/access-review`, under Organisations, SHALL list each contact person of an organisation who has an account, with their roles and the source of each role, their last sign-in, whether the account is enabled, when their access was last reviewed and by whom, and whether a review is due under the configured interval. It SHALL answer Nextcloud admins for any organisation and members of `functioneel-beheerder` or `organisatie-beheerder` for their own active organisation, and refuse others. + +#### Scenario: An information manager finds who has not signed in for a year +@e2e tests/e2e/spec-coverage/access-review.spec.ts + +- **GIVEN** a functional administrator of Gemeente Voorbeeld, and a colleague who last signed in fourteen months ago and was never reviewed +- **WHEN** they open `/organisaties/access-review` +- **THEN** the colleague SHALL be listed with their last sign-in and marked due + +#### Scenario: Another organisation's list is refused +@e2e exclude An authorisation rule; tests/Unit/Controller/AccessReviewControllerTest.php asserts 403 for a functional administrator asking for another organisation and 200 for a Nextcloud admin. + +- **GIVEN** a functional administrator of Gemeente Voorbeeld +- **WHEN** they call `GET /api/access-review/{organisationUuid}` for Gemeente Anders +- **THEN** stackiq SHALL answer 403 + +### Requirement: REQ-RMA-004 A reviewer SHALL confirm or withdraw a person's access, and the decision SHALL be recorded + +Keep access SHALL set `accessReviewedAt` and `accessReviewedBy` on the contact person through OpenRegister. Remove role SHALL remove a role from the contact person's `roles`, and the role group SHALL follow. For a role that comes from a mapped group the page SHALL name that group instead of offering Remove role. A reviewer SHALL NOT confirm their own access. A Nextcloud admin SHALL also be able to disable the account. + +#### Scenario: A reviewer confirms a colleague +@e2e tests/e2e/spec-coverage/access-review.spec.ts + +- **GIVEN** a due colleague on the Access review page +- **WHEN** the functional administrator chooses Keep access +- **THEN** the row SHALL show today as reviewed, by that administrator, and no longer due +- **AND** the contact person's history SHALL record the change + +#### Scenario: A reviewer withdraws a role +@e2e tests/e2e/spec-coverage/access-review.spec.ts + +- **GIVEN** a colleague who holds Gebruik-beheerder on their contact person and no longer buys software +- **WHEN** the functional administrator chooses Remove role for Gebruik-beheerder +- **THEN** the colleague SHALL no longer hold the role or be in `gebruik-beheerder` + +#### Scenario: A reviewer cannot confirm themselves +@e2e exclude A guard; tests/Unit/Controller/AccessReviewControllerTest.php asserts the confirm endpoint refuses a reviewer's own contact person. + +- **GIVEN** a functional administrator on the Access review page +- **WHEN** they try to confirm their own row +- **THEN** stackiq SHALL refuse it diff --git a/openspec/changes/organisations-role-mapping-and-access-review/tasks.md b/openspec/changes/organisations-role-mapping-and-access-review/tasks.md new file mode 100644 index 000000000..f1753702b --- /dev/null +++ b/openspec/changes/organisations-role-mapping-and-access-review/tasks.md @@ -0,0 +1,74 @@ +# Tasks: organisations-role-mapping-and-access-review + +## Implementation tasks + +### Task 1: Add the role map and its admin settings +- **spec_ref**: openspec/changes/organisations-role-mapping-and-access-review/specs/role-mapping-and-access-review/spec.md#requirement-req-rma-001-a-nextcloud-admin-shall-map-each-catalogue-role-onto-groups-of-their-choice +- **files**: `lib/Service/RoleGroupMap.php`, `lib/Service/SettingsService.php`, `lib/Controller/SettingsController.php`, `src/views/settings/sections/UserGroupsConfiguration.vue`, `src/store/modules/settings.js`, `tests/Unit/Service/RoleGroupMapTest.php`, `tests/Unit/Controller/SettingsControllerUserGroupsTest.php`, `l10n/en.json`, `l10n/nl.json` +- **acceptance_criteria**: + - GIVEN no saved mapping WHEN the map is read THEN each of the six roles has its lower-case role group and no source groups, and the four organisation types have their default roles on the English enum + - GIVEN an admin WHEN they save Gebruik-beheerder onto Inkoop and an interval of 180 days THEN the config returns both + - GIVEN a non-admin WHEN they read or write the config THEN they get 403 +- [ ] Implement +- [ ] Test (PHPUnit tests/Unit/Service/RoleGroupMapTest.php and tests/Unit/Controller/SettingsControllerUserGroupsTest.php) + +### Task 2: Keep role groups in step and fix the two broken paths +- **spec_ref**: openspec/changes/organisations-role-mapping-and-access-review/specs/role-mapping-and-access-review/spec.md#requirement-req-rma-002-a-person-shall-be-in-exactly-the-role-groups-of-the-roles-they-derive +- **files**: `lib/Service/RoleGrantSync.php`, `lib/Service/Stackiq/GroupHandler.php`, `lib/Service/Stackiq/ContactPersonHandler.php`, `tests/Unit/Service/RoleGrantSyncTest.php` +- **acceptance_criteria**: + - GIVEN roles from the contact person, a mapped group and the organisation type WHEN rolesFor() runs THEN it returns their union, compared without regard to case + - GIVEN a person who no longer derives a role WHEN the sync runs THEN they leave that role group and keep every non-role group + - GIVEN a Supplier contact without roles WHEN their account is created THEN they are in aanbod-beheerder + - GIVEN the old updateGemeenteGroups() WHEN the code is searched THEN it is gone +- [ ] Implement +- [ ] Test (PHPUnit tests/Unit/Service/RoleGrantSyncTest.php) + +### Task 3: Run the sync at sign-in, on changes and nightly, after a safe upgrade +- **spec_ref**: openspec/changes/organisations-role-mapping-and-access-review/specs/role-mapping-and-access-review/spec.md#requirement-req-rma-002-a-person-shall-be-in-exactly-the-role-groups-of-the-roles-they-derive +- **files**: `lib/EventListener/RoleGrantLoginListener.php`, `lib/BackgroundJob/RoleGrantSyncJob.php`, `lib/Repair/SeedRolesFromGroups.php`, `lib/AppInfo/Application.php`, `appinfo/info.xml`, `tests/Unit/Repair/SeedRolesFromGroupsTest.php`, `tests/Unit/BackgroundJob/RoleGrantSyncJobTest.php` +- **acceptance_criteria**: + - GIVEN a real UserLoggedInEvent WHEN the listener runs THEN the user is synced + - GIVEN a current role group member without the role WHEN the repair step runs THEN the role is written onto their contact person and logged + - GIVEN many users WHEN the nightly job runs THEN it works in bounded batches +- [ ] Implement +- [ ] Test (PHPUnit tests/Unit/Repair/SeedRolesFromGroupsTest.php and tests/Unit/BackgroundJob/RoleGrantSyncJobTest.php) + +### Task 4: Add the review fields and the review endpoints +- **spec_ref**: openspec/changes/organisations-role-mapping-and-access-review/specs/role-mapping-and-access-review/spec.md#requirement-req-rma-004-a-reviewer-shall-confirm-or-withdraw-a-persons-access-and-the-decision-shall-be-recorded +- **files**: `lib/Settings/register.d/organisations-role-mapping-and-access-review.json`, `lib/Controller/AccessReviewController.php`, `appinfo/routes.php`, `tests/Unit/Controller/AccessReviewControllerTest.php`, `tests/Unit/Settings/AccessReviewDeclarationTest.php` +- **acceptance_criteria**: + - GIVEN the merged register WHEN contactPerson is read THEN it has accessReviewedAt and accessReviewedBy and a higher version + - GIVEN a functional administrator WHEN they list their own organisation THEN each row carries roles with sources, last sign-in, enabled, reviewed at and by, and due + - GIVEN another organisation WHEN they list it THEN they get 403, and a Nextcloud admin gets 200 + - GIVEN their own contact person WHEN they confirm it THEN the endpoint refuses +- [ ] Implement +- [ ] Test (PHPUnit tests/Unit/Controller/AccessReviewControllerTest.php and tests/Unit/Settings/AccessReviewDeclarationTest.php) + +### Task 5: Add the Access review page under Organisations +- **spec_ref**: openspec/changes/organisations-role-mapping-and-access-review/specs/role-mapping-and-access-review/spec.md#requirement-req-rma-003-a-functional-administrator-shall-review-the-access-of-their-organisations-people +- **files**: `src/manifest.d/organisations-role-mapping-and-access-review.json`, `src/menu-layout.json`, `src/views/access/AccessReviewView.vue`, `src/customComponents.js`, `lib/Controller/DashboardController.php`, `tests/vitest/accessReviewView.spec.js`, `l10n/en.json`, `l10n/nl.json` +- **acceptance_criteria**: + - GIVEN a functional administrator WHEN they open Organisations THEN Access review is a child entry, and a regular user does not see it + - GIVEN a due row WHEN Keep access is chosen THEN the row shows today and the reviewer, and is no longer due + - GIVEN a role from a mapped group WHEN the row renders THEN it names the group and offers no Remove role for it + - GIVEN a Nextcloud admin WHEN the page renders THEN it offers an organisation picker and Disable account +- [ ] Implement +- [ ] Test (vitest tests/vitest/accessReviewView.spec.js and Playwright tests/e2e/spec-coverage/access-review.spec.ts) + +### Task 6: Seed review dates and document both features +- **spec_ref**: openspec/changes/organisations-role-mapping-and-access-review/specs/role-mapping-and-access-review/spec.md#requirement-req-rma-003-a-functional-administrator-shall-review-the-access-of-their-organisations-people +- **files**: `lib/Settings/stackiq_mock_register.json`, `docs/features/role-mapping-and-access-review.md` +- **acceptance_criteria**: + - GIVEN a fresh demo import WHEN the Access review page opens THEN it shows one current and two due rows + - GIVEN the docs WHEN a reader opens the feature page THEN it explains role groups, mapped groups and the review with screenshots, and points SAML and OpenID Connect users to OpenRegister's derived grants +- [ ] Implement +- [ ] Test (Playwright tests/e2e/spec-coverage/role-mapping.spec.ts and tests/e2e/spec-coverage/access-review.spec.ts against the demo data) + +## Verification + +- `openspec validate organisations-role-mapping-and-access-review --type change --strict` +- PHPUnit: tests/Unit/Service/RoleGroupMapTest.php, tests/Unit/Controller/SettingsControllerUserGroupsTest.php, tests/Unit/Service/RoleGrantSyncTest.php, tests/Unit/Repair/SeedRolesFromGroupsTest.php, tests/Unit/BackgroundJob/RoleGrantSyncJobTest.php, tests/Unit/Controller/AccessReviewControllerTest.php, tests/Unit/Settings/AccessReviewDeclarationTest.php +- vitest: tests/vitest/accessReviewView.spec.js +- Playwright: tests/e2e/spec-coverage/role-mapping.spec.ts and tests/e2e/spec-coverage/access-review.spec.ts +- Docs in docs/features/role-mapping-and-access-review.md with screenshots (ADR-010) +- English and Dutch strings for the roles, the mapping table, the page, its states and actions (ADR-005) From e22da0504fcb767c4d2e961bcea2b1e14179cc27 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 27 Sep 2026 20:21:34 +0200 Subject: [PATCH 17/20] docs(openspec): operations-sync-status-and-progress, align the role group note with the role mapping change --- openspec/changes/operations-sync-status-and-progress/design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openspec/changes/operations-sync-status-and-progress/design.md b/openspec/changes/operations-sync-status-and-progress/design.md index 49d37c998..08267d844 100644 --- a/openspec/changes/operations-sync-status-and-progress/design.md +++ b/openspec/changes/operations-sync-status-and-progress/design.md @@ -45,7 +45,7 @@ Next to `last_sync_time`, `recordSyncTime()` also writes `last_sync_result`: sta `SyncAccessPolicy` allows a Nextcloud admin or a member of `functioneel-beheerder`, the group `GroupHandler` keeps in step with the contact person role Functioneel-beheerder (`lib/Service/Stackiq/GroupHandler.php:170`). `SyncStatusController` uses it with `#[NoAdminRequired]` and returns 403 otherwise. `getProgress()` keeps its owner check and adds: an admin may read any operation, and the policy may read `organisation_sync` operations. An operation id alone never grants access. -When `organisations-role-mapping-and-access-review` makes the role's group configurable, the policy reads the mapped group; it is the only place that names the group. +`organisations-role-mapping-and-access-review` keeps that group's name and keeps its members in step with the role, so the policy stays as it is; it is the only place in this change that names the group. ### D5. The menu shows the entry only to those users From e0e235b5aaa267023fa6653c524691c30ec2134e Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 27 Sep 2026 20:25:24 +0200 Subject: [PATCH 18/20] docs(openspec): security-baseline-classification, availability, integrity and confidentiality per application in use --- .../.openspec.yaml | 2 + .../design.md | 76 ++++++++++++++++++ .../proposal.md | 49 ++++++++++++ .../specs/baseline-classification/spec.md | 79 +++++++++++++++++++ .../security-baseline-classification/tasks.md | 62 +++++++++++++++ 5 files changed, 268 insertions(+) create mode 100644 openspec/changes/security-baseline-classification/.openspec.yaml create mode 100644 openspec/changes/security-baseline-classification/design.md create mode 100644 openspec/changes/security-baseline-classification/proposal.md create mode 100644 openspec/changes/security-baseline-classification/specs/baseline-classification/spec.md create mode 100644 openspec/changes/security-baseline-classification/tasks.md diff --git a/openspec/changes/security-baseline-classification/.openspec.yaml b/openspec/changes/security-baseline-classification/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/security-baseline-classification/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/security-baseline-classification/design.md b/openspec/changes/security-baseline-classification/design.md new file mode 100644 index 000000000..1533be9fc --- /dev/null +++ b/openspec/changes/security-baseline-classification/design.md @@ -0,0 +1,76 @@ +# Design: security-baseline-classification + +Read at development 49e65cb4, with OpenRegister development 4fee776 and the lead's merged changes on development 9a5ece6a. + +## Where it fits + +| Part | File and line | What changes | +|---|---|---| +| Fragment | new `lib/Settings/register.d/security-baseline-classification.json` (ADR-037) | nine properties on `usage` (`lib/Settings/softwarecatalogus_register.json:2654`), each with the property read and update rule `usage.interneAnnotation` uses; a higher `usage` version | +| Subscriber | new `lib/EventListener/UsageClassificationSubscriber.php`, registered in `lib/AppInfo/Application.php` next to `ModuleComplianceSubscriber` (`:802` and `:803`) | on create and update of a `usage`, sets `bbnLevel` to the highest aspect and stamps `classifiedAt` and `classifiedBy` when an aspect changed | +| Service | new `lib/Service/UsageClassificationService.php` | the GEMMA suggestion and the cross-organisation summary | +| Controller and routes | new `lib/Controller/UsageClassificationController.php`; `GET /api/gebruik/{usageId}/classification-suggestion` and `GET /api/modules/{moduleId}/classification-summary` in `appinfo/routes.php` | | +| Page | `GebruikDetail` in `src/manifest.d/usages.json` (from `landscape-usage-registration`) | a body widget `UsageClassificationPanel` | +| Page | `Gebruik` in `src/manifest.d/usages.json` | a `bbnLevel` column and quick filters BBN1, BBN2, BBN3 and Not classified | +| Page | `src/manifest.json:491` `ModuleDetail` | a body widget `ModuleClassificationSummary` | +| Components | new `src/components/usages/UsageClassificationPanel.vue`, `src/components/modules/ModuleClassificationSummary.vue`, registered in `src/customComponents.js` | `CnWidgetWrapper` and `CnProgressBar` from the library | + +## The properties + +| Property | Type | Notes | +|---|---|---| +| `availabilityLevel`, `integrityLevel`, `confidentialityLevel` | enum BBN1, BBN2, BBN3 | the BIO baseline level per aspect | +| `availabilityReason`, `integrityReason`, `confidentialityReason` | string | the main reason, as GEMMA records one per aspect | +| `bbnLevel` | enum BBN1, BBN2, BBN3 | derived, the highest of the three; read-only in the form | +| `classifiedAt` | date-time | stamped when an aspect changes | +| `classifiedBy` | string | the user id that changed it | + +Each property carries `authorization` `read` and `update` of `{"group": "public", "match": {"_organisation": "$organisation"}}`, the rule `usage.interneAnnotation` already carries (`lib/Settings/softwarecatalogus_register.json:2901`). A supplier who may read the usage of its product (the `aanbod-beheerder` read rule on `provider`) does not see the classification. + +## Decisions + +### D1. The classification belongs to the organisation that uses the application + +The request is a CISO classifying "de pakketten in mijn pakketoverzicht": the applications their organisation uses. BIO classification depends on the data an organisation puts in an application, so two municipalities can rightly classify one product differently. `usage` is that organisation's record of the application. `module.bbnLevel` stays as the catalogue's level for the product. + +Rejected: splitting `module.bbnLevel` into three. It would stay one answer for every municipality, which is the half the row says is missing. + +### D2. Three aspects, and the overall level derived from them + +GEMMA records Beschikbaarheid, Integriteit and Vertrouwelijkheid per reference component, each on a scale of 1 to 3 with a main reason, next to its BBN (`lib/Settings/GEMMA_release.xml` property definitions `propid-20` to `propid-27`). The usage takes the same shape. `bbnLevel` is the highest of the three, computed by `UsageClassificationSubscriber` on every create and update, so an API write keeps it right too. The subscriber writes only when the derived value or the stamp differs, so its own save does not trigger it again. + +Rejected: an `x-openregister-*` rule for the derived field. ADR-031's declarative dialects cover lifecycle, aggregation, notifications and relations; none sets one property from others on the same object. + +### D3. The suggestion comes from GEMMA + +Suggest from GEMMA reads the reference components the usage names in `usedForReferenceComponents` (`:2982`), or the application's `referenceComponents` (`:6992`) when the usage names none, and takes the highest `element.availability`, `element.integrity` and `element.confidentiality` per aspect, mapping 1 to 3 onto BBN1 to BBN3. It fills the form and does not save; the CISO decides. Components without a score are skipped, and the panel says how many had one. + +### D4. Sharing is a count, withheld below three + +Other municipalities cannot read another organisation's usages, and OpenRegister's aggregation filters rows by the reader's rights before counting (`AggregateVisibility`), so a shared view needs a server-side summary. `GET /api/modules/{moduleId}/classification-summary` reads the classified usages of that application across organisations with a bounded query, counts distinct consuming organisations, and returns per aspect the number of organisations per level. Below three organisations it returns only the count of organisations and no levels. It answers Nextcloud admins, members of `ambtenaar`, and users whose active organisation has type Municipality or Collaboration; others get 403. It never returns an organisation name or id. + +Rejected: showing each organisation's classification by name. The issue asks to share knowledge, and a named security level of one municipality is information an attacker can use. + +## Declarative versus imperative + +- The properties and their property-level read rules are declarative. +- The derived `bbnLevel` is imperative (D2), in one subscriber. +- The summary is a cross-organisation aggregate with a minimum group size, which OpenRegister's aggregation does not offer (D4), so it is one stackiq endpoint. The proposal names the OpenRegister rule that would replace it. + +## Seed data + +`usage` gains nine properties. The demo descriptor `lib/Settings/stackiq_mock_register.json` classifies one of its usages in the `stackiq` register, and adds one municipality and two usages of the same application so the summary has three consuming organisations to show: + +| `@self.slug` | `consumer` | `module` | availability | integrity | confidentiality | derived | +|---|---|---|---|---|---|---| +| usage-usage-1-1 | `@ref:organization-voorbeeld-name-1-1` | `@ref:module-voorbeeld-name-1-1` | BBN1 | BBN2 | BBN2 | BBN2 | +| usage-classified-2 | `@ref:organization-voorbeeld-name-3-3` | `@ref:module-voorbeeld-name-1-1` | BBN2 | BBN2 | BBN3 | BBN3 | +| usage-classified-3 | `@ref:organization-classification-4` (new, Gemeente Voorbeeldstad, type Municipality) | `@ref:module-voorbeeld-name-1-1` | BBN1 | BBN2 | BBN2 | BBN2 | +| usage-usage-2-2 and usage-usage-3-3 | as seeded | as seeded | empty | empty | empty | empty | + +`ModuleDetail` for Voorbeeld Name 1 then shows three organisations: BBN2 twice and BBN3 once overall. + +## Risks + +- **Disclosure through counts.** Withheld below three organisations, counts only, municipal readers only (D4). The threshold is a constant with a unit test, not a setting, so nobody lowers it by accident. +- **Schema version.** The fragment raises the `usage` version; without it the import skips the new properties. diff --git a/openspec/changes/security-baseline-classification/proposal.md b/openspec/changes/security-baseline-classification/proposal.md new file mode 100644 index 000000000..0278d550c --- /dev/null +++ b/openspec/changes/security-baseline-classification/proposal.md @@ -0,0 +1,49 @@ +--- +kind: code +depends_on: + - landscape-usage-registration +--- + +# Classify the applications you use for availability, integrity and confidentiality + +## Summary + +A CISO wants to classify the applications in their own organisation's overview with a BIO baseline level, and share that knowledge with other municipalities. Stackiq has one BBN level per product, set in the catalogue, the same for every municipality and not split into availability, integrity and confidentiality. This change lets an organisation classify each application it uses on those three aspects, with a reason each, suggests the levels from GEMMA's reference components, derives the overall BBN level, and shows other municipalities how organisations classify an application, as counts that name no one. + +## Why + +This change covers one matrix row. + +- `stackiq:sec-baseline-classification`, "Classify each application with a baseline security level for availability, integrity and confidentiality." Stackiq rates itself partial: `module.bbnLevel` holds BBN1 to BBN3, one level per application, not per organisation and not split into availability, integrity and confidentiality. The demand is a feature request on the GEMMA Softwarecatalogus: "Als CISO wil ik de pakketten in mijn pakketoverzicht van een BBN classificatie voorzien, opdat deze kennis met andere gemeenten gedeeld wordt en wij passende beveiligingsmaatregelen kunnen nemen" (https://github.com/VNG-Realisatie/Softwarecatalogus/issues/46, labels IBD and PvE wens). No competitor cell is rated yes for this row. + +The row is partial and built: this change builds the classification split into availability, integrity and confidentiality, made by the organisation that uses the application. + +## What stackiq has today + +Read at development 49e65cb4, with the lead's merged changes on development 9a5ece6a. + +- `lib/Settings/softwarecatalogus_register.json:7225` `module.bbnLevel`, enum BBN1, BBN2, BBN3, facetable. It is part of the product record, which every organisation reads. +- `ModuleDetail` shows it in `md-data` (`src/manifest.json:500`), and the Modules page lists it as a column (`:608`) with quick filters BBN1, BBN2, BBN3 and Without DPIA (from `:611`). +- `usage` (`:2654`, version 1.5.0) is the organisation's own use of an application. It has no security classification. `landscape-usage-registration` gives it the pages `Gebruik` and `GebruikDetail`. +- The GEMMA reference components in the `vng-gemma` register carry the split already: `element.availability`, `element.integrity` and `element.confidentiality`, each with a main reason, and `element.bivScoreBbn` (GEMMA properties Beschikbaarheid, Integriteit, Vertrouwelijkheid and BIV score BBN, `lib/Settings/GEMMA_release.xml`, for example a score of 122 on a component of BBN 2). `usage.usedForReferenceComponents` (`:2982`) and `module.referenceComponents` (`:6992`) point at them. +- `bioMeasure.bbnLevel` names the BBN levels a BIO measure applies to. + +## What this change builds + +- On `usage`: `availabilityLevel`, `integrityLevel` and `confidentialityLevel` (BBN1 to BBN3), a reason for each, `bbnLevel` derived as the highest of the three, `classifiedAt` and `classifiedBy`. +- On `GebruikDetail`: a Security classification panel where the organisation's CISO or functional administrator sets the three levels, with a Suggest from GEMMA action that takes the highest level per aspect from the application's reference components. +- On `Gebruik`: the BBN level as a column and a quick filter. +- On `ModuleDetail`: a panel How organisations classify this application, with the number of organisations per level for each aspect, shown to municipal users once at least three organisations have classified it, and never naming an organisation. + +## Out of scope + +- Changing `module.bbnLevel`. It stays the catalogue's level for the product, and the Modules filters keep working. +- Listing the BIO measures that apply at the derived level (`bioMeasure.bbnLevel`). A follow-up can add that list to the usage page. +- A cross-organisation aggregate in OpenRegister. OpenRegister's aggregation filters rows by the reader's rights first (`lib/Service/Rbac/AggregateVisibility.php` in OpenRegister) and has no rule for a minimum group size across organisations; if it gains one, the stackiq endpoint can go. +- A DPIA or risk assessment workflow. The DPIA fields on `module` stay as they are. + +## Risks + +- Aggregates can disclose. With two organisations, a count tells each what the other chose. The summary is withheld below three classifying organisations, shows counts only, and is shown only to users of municipalities and collaborations and to admins. +- A derived `bbnLevel` goes stale if a level is changed outside stackiq. It is recomputed on every save of the usage, so a direct API write also updates it. +- New properties on `usage`: its version and the register version must go up, or the import skips the change. diff --git a/openspec/changes/security-baseline-classification/specs/baseline-classification/spec.md b/openspec/changes/security-baseline-classification/specs/baseline-classification/spec.md new file mode 100644 index 000000000..6c765a3df --- /dev/null +++ b/openspec/changes/security-baseline-classification/specs/baseline-classification/spec.md @@ -0,0 +1,79 @@ +# baseline-classification specification + +**Status**: proposed +**Scope**: stackiq +**OpenSpec changes**: +- security-baseline-classification + +## Purpose + +A CISO or functional administrator of a municipality classifies each application their organisation uses on availability, integrity and confidentiality, on the BIO baseline levels, with a reason for each. Stackiq suggests the levels from GEMMA, derives the overall BBN level, and shows other municipalities how organisations classify an application without naming any of them. + +## ADDED Requirements + +### Requirement: REQ-BCL-001 An organisation SHALL classify each application it uses on availability, integrity and confidentiality + +`usage` SHALL carry `availabilityLevel`, `integrityLevel` and `confidentialityLevel` (BBN1, BBN2, BBN3), a reason for each, a derived `bbnLevel`, `classifiedAt` and `classifiedBy`. Only users of the consuming organisation SHALL read or change these properties; a supplier that may read the usage SHALL NOT see them. + +#### Scenario: A CISO classifies an application in use +@e2e tests/e2e/spec-coverage/baseline-classification.spec.ts + +- **GIVEN** a functional administrator of Gemeente Voorbeeld on `/gebruik/:id` +- **WHEN** they set availability BBN1, integrity BBN2 and confidentiality BBN2 with a reason each, and save +- **THEN** the Security classification panel SHALL show the three levels and BBN2 overall +- **AND** it SHALL show today and their name as classified at and by + +#### Scenario: The supplier does not see the classification +@e2e exclude A property rule held by OpenRegister; tests/Unit/Settings/BaselineClassificationDeclarationTest.php asserts every new property carries the owning-organisation read and update rule of usage.interneAnnotation. + +- **GIVEN** a classified usage of a product +- **WHEN** a user of the product's supplier reads that usage +- **THEN** the classification properties SHALL NOT be in the response + +### Requirement: REQ-BCL-002 The overall BBN level SHALL be the highest of the three aspects, on every save + +On every create and update of a `usage`, stackiq SHALL set `bbnLevel` to the highest of the three aspect levels, empty when none is set, and SHALL stamp `classifiedAt` and `classifiedBy` when an aspect changed. It SHALL NOT write when nothing it derives has changed. + +#### Scenario: An API write keeps the overall level right +@e2e exclude A subscriber; tests/Unit/EventListener/UsageClassificationSubscriberTest.php asserts BBN1, BBN2, BBN3 gives BBN3, that an unchanged save writes nothing, and that the stamp follows an aspect change. + +- **GIVEN** a usage with BBN2 overall +- **WHEN** an integration raises confidentiality to BBN3 through the OpenRegister API +- **THEN** the usage's `bbnLevel` SHALL be BBN3 + +### Requirement: REQ-BCL-003 Stackiq SHALL suggest the levels from GEMMA's reference components + +Suggest from GEMMA on the usage page SHALL fill the three levels with the highest GEMMA score per aspect among the usage's reference components, or the application's when the usage names none, mapping 1 to 3 onto BBN1 to BBN3. It SHALL NOT save, and SHALL say how many components had a score. + +#### Scenario: The suggestion follows the reference components +@e2e tests/e2e/spec-coverage/baseline-classification.spec.ts + +- **GIVEN** a usage of an application whose reference component has GEMMA availability 1, integrity 2 and confidentiality 2 +- **WHEN** the functional administrator chooses Suggest from GEMMA +- **THEN** the form SHALL show BBN1, BBN2 and BBN2, unsaved + +### Requirement: REQ-BCL-004 Municipalities SHALL see how organisations classify an application, without names and not below three + +`ModuleDetail` SHALL show How organisations classify this application: per aspect and overall, the number of organisations per level. The data SHALL come from `GET /api/modules/{moduleId}/classification-summary`, which SHALL answer Nextcloud admins, members of `ambtenaar` and users whose active organisation is a municipality or a collaboration, SHALL refuse others with 403, SHALL return levels only when at least three organisations classified the application, and SHALL never return an organisation name or id. + +#### Scenario: An information manager learns how others classify an application +@e2e tests/e2e/spec-coverage/baseline-classification.spec.ts + +- **GIVEN** three organisations that classified Voorbeeld Name 1, two at BBN2 and one at BBN3 overall +- **WHEN** a municipal information manager of another municipality opens its `/modules/:id` +- **THEN** the panel SHALL show 3 organisations, BBN2 two times and BBN3 once +- **AND** it SHALL name none of them + +#### Scenario: Two organisations are not enough +@e2e exclude A threshold; tests/Unit/Service/UsageClassificationServiceTest.php asserts the summary returns only the organisation count for fewer than three classifying organisations. + +- **GIVEN** an application classified by two organisations +- **WHEN** the summary is requested +- **THEN** it SHALL return that two organisations classified it and no levels + +#### Scenario: A supplier cannot read the summary +@e2e exclude An authorisation rule; tests/Unit/Controller/UsageClassificationControllerTest.php asserts 403 for a user whose active organisation is a supplier. + +- **GIVEN** a user whose active organisation has type Supplier +- **WHEN** they call `GET /api/modules/{moduleId}/classification-summary` +- **THEN** stackiq SHALL answer 403 diff --git a/openspec/changes/security-baseline-classification/tasks.md b/openspec/changes/security-baseline-classification/tasks.md new file mode 100644 index 000000000..8db848f3a --- /dev/null +++ b/openspec/changes/security-baseline-classification/tasks.md @@ -0,0 +1,62 @@ +# Tasks: security-baseline-classification + +## Implementation tasks + +### Task 1: Add the classification properties to usage +- **spec_ref**: openspec/changes/security-baseline-classification/specs/baseline-classification/spec.md#requirement-req-bcl-001-an-organisation-shall-classify-each-application-it-uses-on-availability-integrity-and-confidentiality +- **files**: `lib/Settings/register.d/security-baseline-classification.json`, `tests/Unit/Settings/BaselineClassificationDeclarationTest.php`, `l10n/en.json`, `l10n/nl.json` +- **acceptance_criteria**: + - GIVEN the merged register WHEN usage is read THEN it has the nine properties, each with the owning-organisation read and update rule, and a higher version + - GIVEN a Dutch instance WHEN the form renders THEN Beschikbaarheid, Integriteit and Vertrouwelijkheid and their reasons are Dutch +- [ ] Implement +- [ ] Test (PHPUnit tests/Unit/Settings/BaselineClassificationDeclarationTest.php) + +### Task 2: Derive the overall level on every save +- **spec_ref**: openspec/changes/security-baseline-classification/specs/baseline-classification/spec.md#requirement-req-bcl-002-the-overall-bbn-level-shall-be-the-highest-of-the-three-aspects-on-every-save +- **files**: `lib/EventListener/UsageClassificationSubscriber.php`, `lib/AppInfo/Application.php`, `tests/Unit/EventListener/UsageClassificationSubscriberTest.php` +- **acceptance_criteria**: + - GIVEN aspects BBN1, BBN2, BBN3 WHEN a real object event for a usage arrives THEN bbnLevel is BBN3 + - GIVEN no aspect WHEN the usage is saved THEN bbnLevel is empty + - GIVEN an unchanged save WHEN the subscriber runs THEN it writes nothing + - GIVEN an aspect change WHEN the subscriber runs THEN classifiedAt and classifiedBy are stamped +- [ ] Implement +- [ ] Test (PHPUnit tests/Unit/EventListener/UsageClassificationSubscriberTest.php, constructing the real OpenRegister event classes) + +### Task 3: Add the GEMMA suggestion and the summary endpoint +- **spec_ref**: openspec/changes/security-baseline-classification/specs/baseline-classification/spec.md#requirement-req-bcl-004-municipalities-shall-see-how-organisations-classify-an-application-without-names-and-not-below-three +- **files**: `lib/Service/UsageClassificationService.php`, `lib/Controller/UsageClassificationController.php`, `appinfo/routes.php`, `tests/Unit/Service/UsageClassificationServiceTest.php`, `tests/Unit/Controller/UsageClassificationControllerTest.php` +- **acceptance_criteria**: + - GIVEN reference components with GEMMA scores WHEN the suggestion runs THEN it returns the highest per aspect mapped onto BBN1 to BBN3 and the number of scored components + - GIVEN three classifying organisations WHEN the summary runs THEN it returns counts per level per aspect and overall, and no organisation name or id + - GIVEN two WHEN the summary runs THEN it returns only the organisation count + - GIVEN a user of a supplier WHEN they call the summary THEN they get 403, and a municipal user gets 200 +- [ ] Implement +- [ ] Test (PHPUnit tests/Unit/Service/UsageClassificationServiceTest.php and tests/Unit/Controller/UsageClassificationControllerTest.php) + +### Task 4: Add the panels and the usage list column +- **spec_ref**: openspec/changes/security-baseline-classification/specs/baseline-classification/spec.md#requirement-req-bcl-003-stackiq-shall-suggest-the-levels-from-gemmas-reference-components +- **files**: `src/components/usages/UsageClassificationPanel.vue`, `src/components/modules/ModuleClassificationSummary.vue`, `src/customComponents.js`, `src/manifest.d/usages.json`, `src/manifest.json`, `tests/vitest/usageClassificationPanel.spec.js`, `l10n/en.json`, `l10n/nl.json` +- **acceptance_criteria**: + - GIVEN /gebruik/:id WHEN Suggest from GEMMA is chosen THEN the three levels fill and nothing is saved until Save + - GIVEN /gebruik WHEN it opens THEN BBN level is a column with quick filters BBN1, BBN2, BBN3 and Not classified + - GIVEN /modules/:id with three classifying organisations WHEN it opens for a municipal user THEN the summary shows counts and no names +- [ ] Implement +- [ ] Test (vitest tests/vitest/usageClassificationPanel.spec.js and Playwright tests/e2e/spec-coverage/baseline-classification.spec.ts) + +### Task 5: Seed classifications and document the feature +- **spec_ref**: openspec/changes/security-baseline-classification/specs/baseline-classification/spec.md#requirement-req-bcl-004-municipalities-shall-see-how-organisations-classify-an-application-without-names-and-not-below-three +- **files**: `lib/Settings/stackiq_mock_register.json`, `docs/features/baseline-classification.md` +- **acceptance_criteria**: + - GIVEN a fresh demo import WHEN Voorbeeld Name 1 opens THEN the summary shows three organisations, BBN2 twice and BBN3 once + - GIVEN the docs WHEN a reader opens the feature page THEN it shows the panel, the suggestion and the summary in screenshots and explains who sees what +- [ ] Implement +- [ ] Test (Playwright tests/e2e/spec-coverage/baseline-classification.spec.ts against the demo data) + +## Verification + +- `openspec validate security-baseline-classification --type change --strict` +- PHPUnit: tests/Unit/Settings/BaselineClassificationDeclarationTest.php, tests/Unit/EventListener/UsageClassificationSubscriberTest.php, tests/Unit/Service/UsageClassificationServiceTest.php, tests/Unit/Controller/UsageClassificationControllerTest.php +- vitest: tests/vitest/usageClassificationPanel.spec.js +- Playwright: tests/e2e/spec-coverage/baseline-classification.spec.ts +- Docs in docs/features/baseline-classification.md with screenshots (ADR-010) +- English and Dutch strings for the aspects, the levels, the panels and the quick filters (ADR-005) From 996d12fbbb91d583cde64a96855ae31dea8c2cb5 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 27 Sep 2026 20:26:21 +0200 Subject: [PATCH 19/20] chore(parity): OpenSpec-pass batch 3 matrix states and decisions --- openspec/parity/capabilities.json | 88 +++++++-------- openspec/parity/gap-decisions.json | 176 +++++++++++++++++++++++++++++ 2 files changed, 220 insertions(+), 44 deletions(-) diff --git a/openspec/parity/capabilities.json b/openspec/parity/capabilities.json index 629ae4e8a..a5c9f0898 100644 --- a/openspec/parity/capabilities.json +++ b/openspec/parity/capabilities.json @@ -702,14 +702,14 @@ "topdesk": "no", "stackiq": "partial", "built": { - "state": "built", + "state": "specified", "evidence": "src/components/organisations/OrganisationMergePanel.vue:46 (admin-only controls) mounted as OrganisatieDetail bodyWidget org-merge (src/manifest.json:430); lib/Controller/MergeController.php:106 execute with isAdmin check at :142; generic src/modals/object/MergeObject.vue only mounted for modal 'mergeOrganisatie' which nothing sets", "owner": "ConductionNL/stackiq" }, "reachedOn": "OrganisatieDetail /organisaties/:id, Merge panel (admins only)", "provider": "stackiq", "providerHow": "read-from-code", - "note": "Only organisations can be merged, with a dry run first, and only by a Nextcloud admin. Applications, services and other entries have no merge.", + "note": "Only organisations can be merged, with a dry run first, and only by a Nextcloud admin. Applications, services and other entries have no merge. Specified in openspec/changes/operations-record-reconciliation (OpenSpec pass 2026-09-27).", "evidence": { "stackiq": "src/components/organisations/OrganisationMergePanel.vue:46 (admin-only controls) mounted as OrganisatieDetail bodyWidget org-merge (src/manifest.json:430); lib/Controller/MergeController.php:106 execute with isAdmin check at :142; generic src/modals/object/MergeObject.vue only mounted for modal 'mergeOrganisatie' which nothing sets", "topdesk": "https://docs.topdesk.com/en/migration-status.html: \"You cannot merge the two cards into one card\" (read 2026-09-26); https://tip.topdesk.com/c/239-ai-cmdb-monitoring-: roadmap card in column \"Under consideration\", \"AI can continuously scan your configuration database for duplicate records ... and surfaces them for review\" (read 2026-09-26)", @@ -2328,14 +2328,14 @@ "topdesk": "unknown", "stackiq": "no", "built": { - "state": "none", + "state": "specified", "evidence": "lib/Settings/softwarecatalogus_register.json sbomComponent has name/version/purl/licenses/type/hashes/bomRef/vexCveIds, no lifecycle or EOL field; the EOL feed (lib/Service/EolSyncService.php:290) only stamps versions of modules with eolProductSlug; no relation from an application to the platform it runs on", "owner": "ConductionNL/stackiq" }, "reachedOn": "nothing reaches it", "provider": "stackiq", "providerHow": "read-from-code", - "note": "You could register a database as its own 'System software' module and feed its EOL, but nothing ties it to the applications that depend on it; SBOM components carry no lifecycle.", + "note": "You could register a database as its own 'System software' module and feed its EOL, but nothing ties it to the applications that depend on it; SBOM components carry no lifecycle. Specified in openspec/changes/operations-technology-components (OpenSpec pass 2026-09-27).", "evidence": { "sap-leanix": "https://help.sap.com/docs/leanix/ea/obsolescence-risk-management: 'SAP LeanIX helps you gain an overview of your application landscape's obsolescence risk exposure' over the technology layer; lifecycle dates of IT components from the catalog in Technology Risk and Compliance (read 2026-09-26). Reached on: Use case Obsolescence Risk Management; IT Component lifecycle.", "stackiq": "lib/Settings/softwarecatalogus_register.json sbomComponent has name/version/purl/licenses/type/hashes/bomRef/vexCveIds, no lifecycle or EOL field; the EOL feed (lib/Service/EolSyncService.php:290) only stamps versions of modules with eolProductSlug; no relation from an application to the platform it runs on", @@ -2417,7 +2417,7 @@ "topdesk": "yes", "stackiq": "partial", "built": { - "state": "built", + "state": "specified", "evidence": "lib/Service/ContractStatusService.php:77 shouldExpire and :114 expirePastContracts set Active -> Expired when endDate < now; lib/BackgroundJob/ContractStatusJob.php:57 daily, registered in appinfo/info.xml:99", "owner": "ConductionNL/stackiq" }, @@ -2426,7 +2426,7 @@ "providerHow": "read-from-code", "feature": "contract-administration", "featureConfidence": "high", - "note": "A daily job moves Active contracts past their end date to Expired on its own. There is no 'expiring' state in the enum, so the middle step of the row does not exist.", + "note": "A daily job moves Active contracts past their end date to Expired on its own. There is no 'expiring' state in the enum, so the middle step of the row does not exist. Specified in openspec/changes/contracts-expiry-and-owner (OpenSpec pass 2026-09-27).", "evidence": { "stackiq": "lib/Service/ContractStatusService.php:77 shouldExpire and :114 expirePastContracts set Active -> Expired when endDate < now; lib/BackgroundJob/ContractStatusJob.php:57 daily, registered in appinfo/info.xml:99", "topdesk": "https://docs.topdesk.com/en/creating-a-contract.html: \"Status: Configurable drop-down showing the contract's lifecycle status, e.g. draft, active ... Reminder date\" (read 2026-09-26); https://docs.topdesk.com/en/terminating-a-contract.html: \"The contract will terminate once the end date passes\" (read 2026-09-26). Reached on: Modules > Contract Management and SLM.", @@ -2542,7 +2542,7 @@ "topdesk": "unknown", "stackiq": "partial", "built": { - "state": "built", + "state": "specified", "evidence": "register :6937 module.licentietype enum Closed source/Open source and module.licence (five open-source licence names); catalogContract.contractType enum SLA/Licence/Maintenance (:3341)", "owner": "ConductionNL/stackiq" }, @@ -2551,7 +2551,7 @@ "providerHow": "read-from-code", "feature": "license-and-seat-tracking", "featureConfidence": "medium", - "note": "An application records open versus closed source and which open-source licence. There is no licence metric such as per user, per organisation or per seat.", + "note": "An application records open versus closed source and which open-source licence. There is no licence metric such as per user, per organisation or per seat. Specified in openspec/changes/contracts-licence-seats (OpenSpec pass 2026-09-27).", "evidence": { "glpi": "source read at 11.0.9: each licence has a type, install/mysql/glpi-empty.sql:6775 glpi_softwarelicenses.softwarelicensetypes_id, an admin editable dropdown seeded with types such as OEM (install/empty_data.php:9294). Reached on: Management > Licenses (front/softwarelicense.php), Type field.", "stackiq": "register :6937 module.licentietype enum Closed source/Open source and module.licence (five open-source licence names); catalogContract.contractType enum SLA/Licence/Maintenance (:3341)", @@ -2602,7 +2602,7 @@ "topdesk": "yes", "stackiq": "no", "built": { - "state": "none", + "state": "specified", "evidence": "grep for seat/licence count across lib/, src/ and the register finds no seat or quantity field on catalogContract (:3252), module or usage", "owner": "ConductionNL/stackiq" }, @@ -2611,7 +2611,7 @@ "providerHow": "read-from-code", "feature": "license-and-seat-tracking", "featureConfidence": "high", - "note": "No field records licences bought or in use. The overlay lists license-and-seat-tracking as 'soon'.", + "note": "No field records licences bought or in use. The overlay lists license-and-seat-tracking as 'soon'. Specified in openspec/changes/contracts-licence-seats (OpenSpec pass 2026-09-27).", "evidence": { "glpi": "source read at 11.0.9: install/mysql/glpi-empty.sql:6774 glpi_softwarelicenses.number is the bought quantity; src/SoftwareLicense.php:158 computeValidityIndicator compares it with assigned items (Item_SoftwareLicense::countForLicense) and flags over use in red (src/SoftwareLicense.php:1030), with allow_overquota at install/mysql/glpi-empty.sql:6797. Reached on: Assets > Software > Licenses tab.", "stackiq": "grep for seat/licence count across lib/, src/ and the register finds no seat or quantity field on catalogContract (:3252), module or usage", @@ -3220,14 +3220,14 @@ "topdesk": "yes", "stackiq": "partial", "built": { - "state": "built", + "state": "specified", "evidence": "src/views/settings/StackiqSettings.vue:80 UserGroupsConfiguration -> GET/POST /api/user-groups/config (src/store/modules/settings.js:830); lib/Controller/SettingsController.php:3379; lib/Service/Stackiq/GroupHandler.php:103 generic groups, :167 fixed role groups (aanbod-beheerder, gebruik-beheerder, ...), group choice by organisation type (:471)", "owner": "ConductionNL/stackiq" }, "reachedOn": "admin settings section 'User groups'", "provider": "stackiq", "providerHow": "read-from-code", - "note": "An admin configures which groups count as generic users, organisation admins and super users. The catalogue roles themselves map to fixed, hard-coded group names and by organisation type, so an admin cannot map e.g. 'buyer' onto a group of their choice.", + "note": "An admin configures which groups count as generic users, organisation admins and super users. The catalogue roles themselves map to fixed, hard-coded group names and by organisation type, so an admin cannot map e.g. 'buyer' onto a group of their choice. Specified in openspec/changes/organisations-role-mapping-and-access-review (OpenSpec pass 2026-09-27).", "evidence": { "glpi": "source read at 11.0.9: roles are profiles with per right settings (src/Profile.php:55), mapped onto groups and directory attributes by authorisation rules, src/RuleRight.php:236 group criterion and src/RuleRight.php:297 profile action. Reached on: Administration > Profiles; Administration > Rules > Authorizations assignment rules.", "stackiq": "src/views/settings/StackiqSettings.vue:80 UserGroupsConfiguration -> GET/POST /api/user-groups/config (src/store/modules/settings.js:830); lib/Controller/SettingsController.php:3379; lib/Service/Stackiq/GroupHandler.php:103 generic groups, :167 fixed role groups (aanbod-beheerder, gebruik-beheerder, ...), group choice by organisation type (:471)", @@ -3549,7 +3549,7 @@ "topdesk": "yes", "stackiq": "partial", "built": { - "state": "built", + "state": "specified", "evidence": "openapi.json at repo root has an info block and 0 paths. Hand-written JSON docs: lib/Controller/ViewController.php:373 (GET /api/views/docs, routes.php:186) and lib/Controller/AangebodenGebruikController.php:866 (GET /api/aangeboden-gebruik/docs, routes.php:261). The views docs endpoint is login-only; the aangeboden-gebruik docs endpoint is @PublicPage (AangebodenGebruikController.php:860), so anyone can read it (corrected 2026-09-26). Hand-written markdown in docs/API_REFERENCE.md and docs/View_API.md on the docs site. No src/ caller of either docs endpoint.", "owner": "ConductionNL/stackiq", "readOn": "2026-09-26" @@ -3557,7 +3557,7 @@ "reachedOn": "API only: GET /api/views/docs and /api/aangeboden-gebruik/docs; docs site https://stackiq.conduction.nl (Documentation footer link)", "provider": "stackiq", "providerHow": "read-from-code", - "note": "The generated OpenAPI file is empty. What exists is hand-written: two JSON doc endpoints and markdown pages. OpenRegister may generate an OAS per register, but stackiq does not surface it.", + "note": "The generated OpenAPI file is empty. What exists is hand-written: two JSON doc endpoints and markdown pages. OpenRegister may generate an OAS per register, but stackiq does not surface it. Specified in openspec/changes/sharing-generated-api-docs (OpenSpec pass 2026-09-27).", "evidence": { "glpi": "source read at 11.0.9: src/Glpi/Api/HL/Controller/CoreController.php:322 route /doc serves a Swagger UI 'GLPI API Documentation' (:329 to :331) over the spec built by src/Glpi/Api/HL/OpenAPIGenerator.php. Reached on: /api.php/doc. Driven on the lab at 11.0.9 (2026-09-26): on a fresh install /api.php/v2/doc answers 403 \"The High-Level API is disabled\"; after switching on enable_hlapi in Setup > General > API it serves the Swagger UI \"GLPI API Documentation\".", "stackiq": "openapi.json at repo root has an info block and 0 paths. Hand-written JSON docs: lib/Controller/ViewController.php:373 (GET /api/views/docs, routes.php:186) and lib/Controller/AangebodenGebruikController.php:866 (GET /api/aangeboden-gebruik/docs, routes.php:261). The views docs endpoint is login-only; the aangeboden-gebruik docs endpoint is @PublicPage (AangebodenGebruikController.php:860), so anyone can read it (corrected 2026-09-26). Hand-written markdown in docs/API_REFERENCE.md and docs/View_API.md on the docs site. No src/ caller of either docs endpoint.", @@ -3579,7 +3579,7 @@ "topdesk": "yes", "stackiq": "partial", "built": { - "state": "built", + "state": "specified", "evidence": "ArchiMate export lib/Controller/SettingsController.php:1615 (POST /api/archimate/export) and :1685 per-organisation export (GET /api/archimate/export/organization/{uuid}), called only from src/views/settings/sections/ArchiMateImportExport.vue (admin settings, StackiqSettings.vue:86). CSV export of the portfolio report: lib/Controller/PortfolioReportController.php:105, button src/views/organisaties/PortfolioReport.vue:567. No index page opts into the library's CSV/Excel export (no allowExport in src/manifest.json).", "owner": "ConductionNL/stackiq" }, @@ -3588,7 +3588,7 @@ "providerHow": "read-from-code", "feature": "archimate-import-and-export", "featureConfidence": "medium", - "note": "A full ArchiMate export exists but is only reached from admin settings. The one export on a user page is the portfolio report CSV, and the catalogue list pages offer no export.", + "note": "A full ArchiMate export exists but is only reached from admin settings. The one export on a user page is the portfolio report CSV, and the catalogue list pages offer no export. Specified in openspec/changes/insight-exports-and-custom-reports (OpenSpec pass 2026-09-27).", "evidence": { "vng-softwarecatalogus": "https://www.softwarecatalogus.nl/Beschikbare%20downloads: \"De publieke informatie is ook beschikbaar als download van exportbestanden ... Mijn pakketten, Mijn koppelingen: Knop [Exporteren]\" (read 2026-09-26). Reached on: Mijn softwarecatalogus > Exporteren; Beschikbare downloads.", "glpi": "source read at 11.0.9: every search list exports to CSV, PDF, ODS and XLSX through src/Glpi/Search/Output/Csv.php, Pdf.php, Ods.php and Xlsx.php, plus impact CSV (front/impactcsv.php) and the APIs (src/Glpi/Api/HL/Controller/AssetController.php:2825). Reached on: any list, Export menu. Driven on the lab at 11.0.9 (2026-09-26): the Appliances list exported to CSV (/front/report.dynamic.php display_type 3) with the created record.", @@ -3610,14 +3610,14 @@ "topdesk": "yes", "stackiq": "no", "built": { - "state": "none", + "state": "specified", "evidence": "No ITSM connector: lib/Settings/connections.json lists only email, federation and eol-feed; grep for topdesk/servicenow/itsm in lib/ and src/ finds nothing.", "owner": "ConductionNL/stackiq" }, "reachedOn": "nothing reaches it", "provider": "stackiq", "providerHow": "read-from-code", - "note": "No integration with a service management tool exists.", + "note": "No integration with a service management tool exists. Specified in openspec/changes/sharing-itsm-exchange (OpenSpec pass 2026-09-27).", "evidence": { "sap-leanix": "https://www.leanix.net/hubfs/Legal/Metrics-and-Feature-List-EAM-SAP-LeanIX-v3.1.pdf: 'ServiceNow integration An integration that connects the subscription services to the customer's ServiceNow subscription to synchronize infrastructure and software asset information'; Jira Service Management integration at https://help.sap.com/docs/leanix/ea/jira-service-management-integration-faqs (read 2026-09-26). Reached on: Administration > Integrations > ServiceNow.", "bluedolphin": "https://help.bluedolphin.io/en/articles/11967779-add-an-integration-in-bluedolphin: 'out-of-the-box integrations with ITSM platforms like TOPdesk, ServiceNow, and JIRA'; https://help.bluedolphin.io/en/articles/11967782-bluedolphin-to-topdesk-integration and https://help.bluedolphin.io/en/articles/12148500-bluedolphin-to-servicenow-integration (read 2026-09-26). Reached on: System settings > Marketplace (paid add on).", @@ -3668,7 +3668,7 @@ "topdesk": "partial", "stackiq": "partial", "built": { - "state": "built", + "state": "specified", "evidence": "lib/Service/FacetService.php:109 DIMENSIONS = referenceComponent, standard, applicationService, domain (GET /api/facets/{schema}, routes.php:195); src/views/FacetedCatalogIndexView.vue renders CnFacetSidebar with these plus search, on the Applications and Services pages. Supplier is only a column (src/manifest.json Modules config columns 'provider'), not a facet, although the module schema marks provider facetable.", "owner": "ConductionNL/stackiq" }, @@ -3677,7 +3677,7 @@ "providerHow": "read-from-code", "feature": "gemma-alignment", "featureConfidence": "low", - "note": "Search plus reference-component, standard, application-service and domain facets work. Supplier is not offered as a facet, which is half of the row's example.", + "note": "Search plus reference-component, standard, application-service and domain facets work. Supplier is not offered as a facet, which is half of the row's example. Specified in openspec/changes/insight-supplier-facet (OpenSpec pass 2026-09-27).", "evidence": { "vng-softwarecatalogus": "https://www.softwarecatalogus.nl/node/13683: \"Aan de linkerkant staan zogenaamde filter mogelijkheden. Deze werken ook in combinatie ... Achter de te zetten filters staat een getal\" (read 2026-09-26); https://www.softwarecatalogus.nl/pakketversies: facets Leverancier, Standaard, Referentiecomponent, Status planning, Domein, Doelgroep, Bedrijfsfunctie (read 2026-09-26). Reached on: Alle pakketten / Alle pakketversies.", "glpi": "source read at 11.0.9: every list has a criteria builder, src/Glpi/Search/Input/QueryBuilder.php:72 showGenericSearch, over all search options, for example manufacturer on appliances (src/Appliance.php:229 region, glpi_manufacturers) and status (src/Appliance.php:350); there are no counted facets and no reference component to filter on. Reached on: Management > Appliances, search criteria.", @@ -3815,7 +3815,7 @@ "topdesk": "partial", "stackiq": "no", "built": { - "state": "none", + "state": "specified", "evidence": "No report builder in src/ or lib/. The only report is the fixed Gartner TIME portfolio report (lib/Controller/PortfolioReportController.php, CSV at :105). The overlay lists portfolio-reporting as status 'soon'.", "owner": "ConductionNL/stackiq" }, @@ -3824,7 +3824,7 @@ "providerHow": "read-from-code", "feature": "portfolio-reporting", "featureConfidence": "high", - "note": "Users cannot build their own report. The one fixed portfolio report can be exported as CSV, but it is not configurable.", + "note": "Users cannot build their own report. The one fixed portfolio report can be exported as CSV, but it is not configurable. Specified in openspec/changes/insight-exports-and-custom-reports (OpenSpec pass 2026-09-27).", "evidence": { "sap-leanix": "https://help.sap.com/docs/leanix/ea/sap-leanix-apis: 'GraphQL is used to create custom reports'; https://help.sap.com/docs/leanix/ea/reporting-framework-and-cli: reporting library with 'Export to PDF and PNG files' (read 2026-09-26). Reached on: Reports > custom reports (reporting framework, Extension Hub).", "glpi": "source read at 11.0.9: any itemtype list takes arbitrary criteria (src/Glpi/Search/Input/QueryBuilder.php:72), selectable columns, and exports to CSV, PDF, ODS or XLSX (src/Glpi/Search/Output/Xlsx.php); the result can be saved (src/SavedSearch.php:52) and charted on a dashboard (src/Glpi/Dashboard/Grid.php:67). Reached on: any list with criteria, column selection and export.", @@ -3846,7 +3846,7 @@ "topdesk": "yes", "stackiq": "partial", "built": { - "state": "built", + "state": "specified", "evidence": "Portfolio report CSV: lib/Controller/PortfolioReportController.php:105 (DataDownloadResponse text/csv), button src/views/organisaties/PortfolioReport.vue:567. No index page sets the library's allowExport (grep allowExport/exportable in src/manifest.json and the register finds nothing), so Applications, Contracts and the other lists have no export.", "owner": "ConductionNL/stackiq" }, @@ -3855,7 +3855,7 @@ "providerHow": "read-from-code", "feature": "portfolio-reporting", "featureConfidence": "low", - "note": "One fixed report exports to CSV for a selected organisation. The filtered catalogue lists cannot be exported.", + "note": "One fixed report exports to CSV for a selected organisation. The filtered catalogue lists cannot be exported. Specified in openspec/changes/insight-exports-and-custom-reports (OpenSpec pass 2026-09-27).", "evidence": { "glpi": "source read at 11.0.9: src/Glpi/Search/Output/Csv.php, Ods.php, Xlsx.php and Pdf.php export the filtered list; the output format selector is rendered by src/Html.php:4219 Dropdown::showOutputFormat. Reached on: any filtered list, Export. Driven on the lab at 11.0.9 (2026-09-26): the filtered Appliances list exported to CSV with the created record.", "stackiq": "Portfolio report CSV: lib/Controller/PortfolioReportController.php:105 (DataDownloadResponse text/csv), button src/views/organisaties/PortfolioReport.vue:567. No index page sets the library's allowExport (grep allowExport/exportable in src/manifest.json and the register finds nothing), so Applications, Contracts and the other lists have no export.", @@ -3966,14 +3966,14 @@ "topdesk": "unknown", "stackiq": "partial", "built": { - "state": "built", + "state": "specified", "evidence": "lib/Controller/SettingsController.php:1289 getProgress and :1360 streamProgress (routes.php:119-120) serve lib/Service/ProgressTracker.php, used only by lib/Service/MergeOrganisatieService.php; no src/ caller of /api/progress. The admin ArchiMate import shows a spinner then the final objects-processed count (src/views/settings/sections/ArchiMateImportExport.vue:103). Organisation sync shows a status block with last sync time and organisations to process (src/views/settings/sections/OrganizationSynchronization.vue:211).", "owner": "ConductionNL/stackiq" }, "reachedOn": "admin settings sections Organization Synchronization and ArchiMate Import/Export (status and result only); progress endpoints API only", "provider": "stackiq", "providerHow": "read-from-code", - "note": "A progress API exists but no page reads it. Admins see a sync status and final import results, not the progress of a running job.", + "note": "A progress API exists but no page reads it. Admins see a sync status and final import results, not the progress of a running job. Specified in openspec/changes/operations-sync-status-and-progress (OpenSpec pass 2026-09-27).", "evidence": { "glpi": "source read at 11.0.9: massive actions over many records show a progress bar, src/MassiveAction.php:1294 displayProgressBar; the LDAP synchronisation command shows one per user batch, src/Glpi/Console/Ldap/SynchronizeUsersCommand.php:382; long web operations report through src/Glpi/Controller/ProgressController.php:50 /progress/check/{key}. Inventory imports run per agent request without a progress view. Reached on: massive action screen; CLI ldap:sync.", "stackiq": "lib/Controller/SettingsController.php:1289 getProgress and :1360 streamProgress (routes.php:119-120) serve lib/Service/ProgressTracker.php, used only by lib/Service/MergeOrganisatieService.php; no src/ caller of /api/progress. The admin ArchiMate import shows a spinner then the final objects-processed count (src/views/settings/sections/ArchiMateImportExport.vue:103). Organisation sync shows a status block with last sync time and organisations to process (src/views/settings/sections/OrganizationSynchronization.vue:211).", @@ -3995,14 +3995,14 @@ "topdesk": "yes", "stackiq": "no", "built": { - "state": "none", + "state": "specified", "evidence": "No knowledge-article schema among the register's schemas (sector, suite, catalogService, vulnerability, contactPerson, organization, usage, catalogContract, connection, software-review, element, view, model, property-definition, relation, module, compliancy, bioMeasure, moduleVersion, sbomComponent in lib/Settings/softwarecatalogus_register.json); ModuleDetail only has a Documentation files panel.", "owner": "ConductionNL/stackiq" }, "reachedOn": "nothing reaches it", "provider": "stackiq", "providerHow": "read-from-code", - "note": "There is no knowledge base. Files can be attached to an application, but that is not searchable articles.", + "note": "There is no knowledge base. Files can be attached to an application, but that is not searchable articles. Specified in openspec/changes/insight-knowledge-base (OpenSpec pass 2026-09-27).", "evidence": { "glpi": "source read at 11.0.9: src/KnowbaseItem.php:57 knowledge base articles with categories and visibility, linked to items through src/KnowbaseItem_Item.php:45 and shown on the appliance Knowledge base tab (src/Appliance.php:104); menu src/Html.php:1307. Reached on: Tools > Knowledge base (front/knowbaseitem.php). Driven on the lab at 11.0.9 (2026-09-26): /front/knowbaseitem.php opens the knowledge base with Search and Browse.", "topdesk": "https://docs.topdesk.com/en/knowledge-management.html: \"The Knowledge Base is set up and managed by your organization's knowledge managers. Every operator is able to use information from the Knowledge Base\" (read 2026-09-26). Reached on: Modules > Knowledge Management.", @@ -4111,14 +4111,14 @@ "topdesk": "yes", "stackiq": "no", "built": { - "state": "none", + "state": "specified", "evidence": "No hardware schema in lib/Settings/softwarecatalogus_register.json (schemas are software, organisation, contract and GEMMA model types only).", "owner": "ConductionNL/stackiq" }, "reachedOn": "nothing reaches it", "provider": "stackiq", "providerHow": "read-from-code", - "note": "Only software is registered, not hardware.", + "note": "Only software is registered, not hardware. Specified in openspec/changes/operations-technology-components (OpenSpec pass 2026-09-27).", "evidence": { "glpi": "source read at 11.0.9: src/autoload/CFG_GLPI.php:208 asset_types lists Computer, Monitor, NetworkEquipment and the other hardware types managed next to software, under the Assets menu. Reached on: Assets > Computers, Monitors, Network devices.", "topdesk": "https://docs.topdesk.com/en/linking-assets-to-other-assets.html: \"Think of a router that provides a computer with access to your network, or a printer\" (read 2026-09-26); any asset type via templates. Reached on: Asset Management.", @@ -4140,14 +4140,14 @@ "topdesk": "yes", "stackiq": "partial", "built": { - "state": "built", + "state": "specified", "evidence": "Applications (module lib/Settings/softwarecatalogus_register.json:6777), versions (moduleVersion :7649), suites (suite :1135) and application-to-application connections (connection :3563) with relations; ModuleDetail and SuiteDetail show a Related panel. No manifest page has register+schema 'connection' or 'usage', so connections are not listed or created on their own page.", "owner": "ConductionNL/stackiq" }, "reachedOn": "Applications /modules/:id and Suites /suites/:id Related panels; no page for connections", "provider": "stackiq", "providerHow": "read-from-code", - "note": "The landscape and its relations are recorded as catalogue objects, not as a CMDB with CI classes. Connections (koppelingen) have no index or detail page of their own.", + "note": "The landscape and its relations are recorded as catalogue objects, not as a CMDB with CI classes. Connections (koppelingen) have no index or detail page of their own. Specified in openspec/changes/operations-technology-components (OpenSpec pass 2026-09-27).", "evidence": { "vng-softwarecatalogus": "https://www.softwarecatalogus.nl/node/16564: C19: \"Dubbel beheer (én in de Softwarecatalogus én in de CMDB) ... Een CMDB die separaat wordt bijgehouden\" (read 2026-09-26); https://www.softwarecatalogus.nl/node/19703: \"Zolang de CMDB niet gekoppeld is aan de Softwarecatalogus\" (read 2026-09-26). The docs treat the CMDB as a separate tool.", "glpi": "source read at 11.0.9: configuration items are the asset types (src/autoload/CFG_GLPI.php:208) plus appliances, with relations recorded as appliance membership (src/Appliance_Item.php:45), impact relations (install/mysql/glpi-empty.sql:1247 glpi_impactrelations) and network port links. Reached on: Assets menu; item > Impact analysis tab.", @@ -4169,14 +4169,14 @@ "topdesk": "unknown", "stackiq": "partial", "built": { - "state": "built", + "state": "specified", "evidence": "Organisation merge: src/manifest.json:430 OrganisationMergePanel on OrganisatieDetail, calling /api/organisaties/{uuid}/merge/dry-run and /merge (src/store/modules/organisatie.js:486/524, lib/Controller/MergeController.php:106, admin-only body guard). Federation mirrors are reconciled per peer by lib/Service/Federation/FederationMerger.php.", "owner": "ConductionNL/stackiq" }, "reachedOn": "Organisation /organisaties/:id, Merge organisation panel (admin only)", "provider": "stackiq", "providerHow": "read-from-code", - "note": "An admin can merge duplicate organisations with a dry run. Nothing deduplicates applications or reconciles records arriving from several sources.", + "note": "An admin can merge duplicate organisations with a dry run. Nothing deduplicates applications or reconciles records arriving from several sources. Specified in openspec/changes/operations-record-reconciliation (OpenSpec pass 2026-09-27).", "evidence": { "stackiq": "Organisation merge: src/manifest.json:430 OrganisationMergePanel on OrganisatieDetail, calling /api/organisaties/{uuid}/merge/dry-run and /merge (src/store/modules/organisatie.js:486/524, lib/Controller/MergeController.php:106, admin-only body guard). Federation mirrors are reconciled per peer by lib/Service/Federation/FederationMerger.php.", "topdesk": "unknown: deduplication across sources is not described; https://tip.topdesk.com/c/239-ai-cmdb-monitoring- (duplicates) is under consideration; searched the full-text search index of docs.topdesk.com (https://docs.topdesk.com/en/js/fuzzydata.js, 987 pages) (read 2026-09-26)", @@ -4343,7 +4343,7 @@ "topdesk": "partial", "stackiq": "partial", "built": { - "state": "built", + "state": "specified", "evidence": "lib/BackgroundJob/OrganizationContactSyncJob.php:75 TimedJob every 300 s calling performScheduledSync; admin section src/views/settings/sections/CronjobConfiguration.vue:56 shows each job's interval and an enable switch; src/views/settings/sections/OrganizationSynchronization.vue:211 shows Last Sync from app config last_sync_time (lib/Service/OrganizationSyncService.php:1609, written by recordSyncTime :1674).", "owner": "ConductionNL/stackiq" }, @@ -4352,7 +4352,7 @@ "providerHow": "read-from-code", "feature": "automatic-user-provisioning", "featureConfidence": "medium", - "note": "The sync runs on a schedule and its last run time is shown, but only an admin can see and configure it, which is the rule for partial.", + "note": "The sync runs on a schedule and its last run time is shown, but only an admin can see and configure it, which is the rule for partial. Specified in openspec/changes/operations-sync-status-and-progress (OpenSpec pass 2026-09-27).", "evidence": { "stackiq": "lib/BackgroundJob/OrganizationContactSyncJob.php:75 TimedJob every 300 s calling performScheduledSync; admin section src/views/settings/sections/CronjobConfiguration.vue:56 shows each job's interval and an enable switch; src/views/settings/sections/OrganizationSynchronization.vue:211 shows Last Sync from app config last_sync_time (lib/Service/OrganizationSyncService.php:1609, written by recordSyncTime :1674).", "topdesk": "https://docs.topdesk.com/en/events-that-trigger-actions.html: \"when tracking imports/Exchange exports via system events ... on a schedule\" (read 2026-09-26); https://tip.topdesk.com/c/116-support-for-importing-persons-and-operators-directly-from-local-active-directory: roadmap card in column \"Launched\", person import from AD (read 2026-09-26). Reached on: Settings > Import settings.", @@ -4462,7 +4462,7 @@ "originUrl": "https://www.tenderned.nl/aankondigingen/overzicht/398728", "stackiq": "no", "built": { - "state": "none", + "state": "specified", "evidence": "lib/Service/ContactpersoonService.php:889 returns a user's lastLogin and src/components/ContactpersonenList.vue:482 stores it, but nothing renders it; no review, recertification or expiry of access in lib/ or src/ (grep recertif, accessReview)", "owner": "ConductionNL/stackiq" }, @@ -4470,7 +4470,7 @@ "provider": "stackiq", "providerHow": "read-from-code", "featureConfidence": "medium", - "note": "Helmond REQ78 asks administrators to check periodically that every user still needs access. stackiq fetches the last login of each contact's account and never shows it.", + "note": "Helmond REQ78 asks administrators to check periodically that every user still needs access. stackiq fetches the last login of each contact's account and never shows it. Specified in openspec/changes/organisations-role-mapping-and-access-review (OpenSpec pass 2026-09-27).", "vng-softwarecatalogus": "partial", "evidence": { "vng-softwarecatalogus": "https://www.softwarecatalogus.nl/gebruikersbeheer: \"Er opent zich een overzicht met alle geregistreerde gebruikers van uw organisatie ... inclusief wanneer zij voor het laatst hebben ingelogd\" (read 2026-09-26); https://www.softwarecatalogus.nl/: tip \"Controleer of alle gebruikers nog werkzaam zijn bij de gemeente of samenwerking\" (read 2026-09-26). A manual check, no review cycle. Reached on: Menu > Gebruikersbeheer.", @@ -4675,7 +4675,7 @@ "originUrl": "https://github.com/VNG-Realisatie/Softwarecatalogus/issues/41", "stackiq": "partial", "built": { - "state": "built", + "state": "specified", "evidence": "compliance records carry bewijsReferentie and a Documentation files panel (src/manifest.json:885 KompliantieDetail), and module.dpiaDocumentRef (lib/Settings/softwarecatalogus_register.json:7280) links a DPIA; these are published per record under the register read rules, but nothing shares a processing agreement or pentest report between organisations as such", "owner": "ConductionNL/stackiq" }, @@ -4691,7 +4691,7 @@ "glpi": "source read at 11.0.9: documents (src/Document.php:67) are visible only inside the instance through entities and profiles; anonymous access is limited to FAQ attachments when use_public_faq is on (src/Document.php:717), and there is no sharing with other organisations.", "topdesk": "unknown: assets hold documents in a Documents widget, but sharing them with other organisations is not described; searched the full-text search index of docs.topdesk.com (https://docs.topdesk.com/en/js/fuzzydata.js, 987 pages) (read 2026-09-26)" }, - "note": "Mined from vng-softwarecatalogus (featureRequest) on 2026-09-26.", + "note": "Mined from vng-softwarecatalogus (featureRequest) on 2026-09-26. Specified in openspec/changes/sharing-compliance-documents (OpenSpec pass 2026-09-27).", "sap-leanix": "unknown", "bluedolphin": "unknown", "glpi": "no", @@ -4705,7 +4705,7 @@ "originUrl": "https://github.com/VNG-Realisatie/Softwarecatalogus/issues/46", "stackiq": "partial", "built": { - "state": "built", + "state": "specified", "evidence": "lib/Settings/softwarecatalogus_register.json:7225 module.bbnLevel (BBN1 to BBN3), shown in the ModuleDetail data widget (src/manifest.json:500) and filterable on Modules (src/manifest.json:608); one level per application, not per organisation and not split into availability, integrity and confidentiality", "owner": "ConductionNL/stackiq" }, @@ -4721,7 +4721,7 @@ "glpi": "source read at 11.0.9: grep -rli 'confidentialit' over src/ templates/ locales/glpi.pot returns nothing and appliances have no availability, integrity or confidentiality fields (install/mysql/glpi-empty.sql:8935 glpi_appliances); only a repurposed dropdown or a custom asset field could hold it.", "topdesk": "unknown: no availability, integrity and confidentiality classification is described; searched the full-text search index of docs.topdesk.com (https://docs.topdesk.com/en/js/fuzzydata.js, 987 pages) (read 2026-09-26)" }, - "note": "Mined from vng-softwarecatalogus (featureRequest) on 2026-09-26.", + "note": "Mined from vng-softwarecatalogus (featureRequest) on 2026-09-26. Specified in openspec/changes/security-baseline-classification (OpenSpec pass 2026-09-27).", "sap-leanix": "unknown", "bluedolphin": "unknown", "glpi": "no", @@ -5125,7 +5125,7 @@ "originUrl": "https://github.com/glpi-project/roadmap/discussions/290", "stackiq": "partial", "built": { - "state": "built", + "state": "specified", "evidence": "catalogContract.contactPersonUser (lib/Settings/softwarecatalogus_register.json:3398) names the responsible person on the user side, shown on ContractDetail (src/manifest.json:563); expiry warnings do not reach them because the only expiry notification never matches (see ctr-expiry-alert)", "owner": "ConductionNL/stackiq" }, @@ -5141,7 +5141,7 @@ "bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; a contract owner who receives expiry warnings is not documented (read 2026-09-26)", "topdesk": "https://docs.topdesk.com/en/creating-a-contract.html: \"Operator The TOPdesk operator responsible for managing the contract ... Reminder date Date on which an operator should be reminded about the contract, e.g. ahead of expiry\" (read 2026-09-26); https://docs.topdesk.com/en/events-that-trigger-actions.html: \"notify a manager that a contract will expire in a month\" (read 2026-09-26). Reached on: Contract card > Management > Operator." }, - "note": "Mined from glpi (featureRequest) on 2026-09-26.", + "note": "Mined from glpi (featureRequest) on 2026-09-26. Specified in openspec/changes/contracts-expiry-and-owner (OpenSpec pass 2026-09-27).", "vng-softwarecatalogus": "unknown", "sap-leanix": "yes", "bluedolphin": "unknown", diff --git a/openspec/parity/gap-decisions.json b/openspec/parity/gap-decisions.json index 65ba9a1a2..c3c95c50d 100644 --- a/openspec/parity/gap-decisions.json +++ b/openspec/parity/gap-decisions.json @@ -319,6 +319,14 @@ "change": null, "decidedOn": "2026-09-27" }, + { + "row": "ctr-contract-owner", + "matrix": "stackiq", + "decision": "build", + "reason": "Build: 2 competitors rate yes (SAP LeanIX, TOPdesk); featureRequest demand. Partial and built: the change builds the missing half, expiry warnings that reach the named contact person.", + "change": "contracts-expiry-and-owner", + "decidedOn": "2026-09-27" + }, { "row": "ctr-depreciation", "matrix": "stackiq", @@ -335,6 +343,14 @@ "change": null, "decidedOn": "2026-09-27" }, + { + "row": "ctr-licence-model", + "matrix": "stackiq", + "decision": "build", + "reason": "Below the bar on its own (1 competitor yes, no tender, feature request or roadmap demand), and it rides with ctr-seat-count: its missing half is a licence metric such as per user or per seat, which seat counting needs.", + "change": "contracts-licence-seats", + "decidedOn": "2026-09-27" + }, { "row": "ctr-linked-contracts", "matrix": "stackiq", @@ -359,6 +375,22 @@ "change": null, "decidedOn": "2026-09-27" }, + { + "row": "ctr-seat-count", + "matrix": "stackiq", + "decision": "build", + "reason": "Build: 2 competitors rate yes (GLPI, TOPdesk).", + "change": "contracts-licence-seats", + "decidedOn": "2026-09-27" + }, + { + "row": "ctr-status", + "matrix": "stackiq", + "decision": "build", + "reason": "Build: 2 competitors rate yes (SAP LeanIX, TOPdesk). Partial and built: the change builds the missing half, the expiring state between active and expired.", + "change": "contracts-expiry-and-owner", + "decidedOn": "2026-09-27" + }, { "row": "ins-ai-action-audit", "matrix": "stackiq", @@ -383,6 +415,38 @@ "change": null, "decidedOn": "2026-09-27" }, + { + "row": "ins-custom-report", + "matrix": "stackiq", + "decision": "build", + "reason": "Build: 2 competitors rate yes (SAP LeanIX, GLPI).", + "change": "insight-exports-and-custom-reports", + "decidedOn": "2026-09-27" + }, + { + "row": "ins-export-list", + "matrix": "stackiq", + "decision": "build", + "reason": "Build: 4 competitors rate yes (GEMMA Softwarecatalogus, SAP LeanIX, GLPI, TOPdesk). Partial and built: the change builds the missing half, export on the filtered catalogue list pages.", + "change": "insight-exports-and-custom-reports", + "decidedOn": "2026-09-27" + }, + { + "row": "ins-faceted-search", + "matrix": "stackiq", + "decision": "build", + "reason": "Build: 4 competitors rate yes (GEMMA Softwarecatalogus, SAP LeanIX, BlueDolphin, GLPI). Partial and built: the change builds the missing half, supplier as a facet.", + "change": "insight-supplier-facet", + "decidedOn": "2026-09-27" + }, + { + "row": "ins-kb", + "matrix": "stackiq", + "decision": "build", + "reason": "Build: 2 competitors rate yes (GLPI, TOPdesk).", + "change": "insight-knowledge-base", + "decidedOn": "2026-09-27" + }, { "row": "ins-natural-language-query", "matrix": "stackiq", @@ -391,6 +455,14 @@ "change": "stackiq-mcp-adoption", "decidedOn": "2026-09-27" }, + { + "row": "ins-progress", + "matrix": "stackiq", + "decision": "build", + "reason": "Build: 2 competitors rate yes (SAP LeanIX, GLPI). Partial and built: the change builds the missing half, a page that shows the progress of a running synchronisation or import.", + "change": "operations-sync-status-and-progress", + "decidedOn": "2026-09-27" + }, { "row": "ins-scheduled-report", "matrix": "stackiq", @@ -479,6 +551,14 @@ "change": "landscape-application-page", "decidedOn": "2026-09-27" }, + { + "row": "land-duplicate-merge", + "matrix": "stackiq", + "decision": "build", + "reason": "Below the bar on its own (1 competitor yes, no tender, feature request or roadmap demand), and it rides with ops-reconciliation: its missing half is merging applications and services, which is the merge that change extends.", + "change": "operations-record-reconciliation", + "decidedOn": "2026-09-27" + }, { "row": "land-guided-wizard", "matrix": "stackiq", @@ -559,6 +639,14 @@ "change": null, "decidedOn": "2026-09-27" }, + { + "row": "life-tech-obsolescence", + "matrix": "stackiq", + "decision": "build", + "reason": "Below the bar on its own (1 competitor yes, no tender, feature request or roadmap demand), and it rides with ops-hardware-assets: its missing half is the relation from an application to the platform it runs on with that platform's lifecycle, which the technology components change records.", + "change": "operations-technology-components", + "decidedOn": "2026-09-27" + }, { "row": "life-value-assessment", "matrix": "stackiq", @@ -647,6 +735,22 @@ "change": null, "decidedOn": "2026-09-27" }, + { + "row": "ops-ci-relations", + "matrix": "stackiq", + "decision": "build", + "reason": "Build: 2 competitors rate yes (GLPI, TOPdesk). Partial and built: the change builds the missing half, configuration item classes beyond applications, and their relations.", + "change": "operations-technology-components", + "decidedOn": "2026-09-27" + }, + { + "row": "ops-hardware-assets", + "matrix": "stackiq", + "decision": "build", + "reason": "Build: 2 competitors rate yes (GLPI, TOPdesk).", + "change": "operations-technology-components", + "decidedOn": "2026-09-27" + }, { "row": "ops-mobile", "matrix": "stackiq", @@ -663,6 +767,14 @@ "change": null, "decidedOn": "2026-09-27" }, + { + "row": "ops-reconciliation", + "matrix": "stackiq", + "decision": "build", + "reason": "Build: 3 competitors rate yes (SAP LeanIX, BlueDolphin, GLPI). Partial and built: the change builds the missing half, deduplicating applications and reconciling records from several sources.", + "change": "operations-record-reconciliation", + "decidedOn": "2026-09-27" + }, { "row": "ops-saas-discovery", "matrix": "stackiq", @@ -671,6 +783,14 @@ "change": null, "decidedOn": "2026-09-27" }, + { + "row": "ops-scheduled-sync", + "matrix": "stackiq", + "decision": "build", + "reason": "Build: 2 competitors rate yes (SAP LeanIX, BlueDolphin). Partial and built: the change builds the missing half, the sync schedule and last run for the organisation administrator, not only the Nextcloud admin.", + "change": "operations-sync-status-and-progress", + "decidedOn": "2026-09-27" + }, { "row": "ops-self-service", "matrix": "stackiq", @@ -695,6 +815,14 @@ "change": null, "decidedOn": "2026-09-27" }, + { + "row": "org-access-review", + "matrix": "stackiq", + "decision": "build", + "reason": "Build: tender demand (https://www.tenderned.nl/aankondigingen/overzicht/398728).", + "change": "organisations-role-mapping-and-access-review", + "decidedOn": "2026-09-27" + }, { "row": "org-act-as-user", "matrix": "stackiq", @@ -743,6 +871,14 @@ "change": null, "decidedOn": "2026-09-27" }, + { + "row": "org-roles", + "matrix": "stackiq", + "decision": "build", + "reason": "Build: 4 competitors rate yes (SAP LeanIX, BlueDolphin, GLPI, TOPdesk). Partial and built: the change builds the missing half, mapping each catalogue role onto a group the administrator chooses.", + "change": "organisations-role-mapping-and-access-review", + "decidedOn": "2026-09-27" + }, { "row": "org-sso", "matrix": "stackiq", @@ -759,6 +895,14 @@ "change": null, "decidedOn": "2026-09-27" }, + { + "row": "sec-baseline-classification", + "matrix": "stackiq", + "decision": "build", + "reason": "Build: featureRequest demand. Partial and built: the change builds the missing half, a classification split into availability, integrity and confidentiality.", + "change": "security-baseline-classification", + "decidedOn": "2026-09-27" + }, { "row": "sec-patch-status", "matrix": "stackiq", @@ -775,6 +919,30 @@ "change": null, "decidedOn": "2026-09-27" }, + { + "row": "share-api-docs", + "matrix": "stackiq", + "decision": "build", + "reason": "Build: 4 competitors rate yes (SAP LeanIX, BlueDolphin, GLPI, TOPdesk). Partial and built: the change builds the missing half, a generated OpenAPI description of the catalogue API.", + "change": "sharing-generated-api-docs", + "decidedOn": "2026-09-27" + }, + { + "row": "share-compliance-documents", + "matrix": "stackiq", + "decision": "build", + "reason": "Build: featureRequest demand. Partial and built: the change builds the missing half, sharing processing agreements and pentest reports with other organisations.", + "change": "sharing-compliance-documents", + "decidedOn": "2026-09-27" + }, + { + "row": "share-export", + "matrix": "stackiq", + "decision": "build", + "reason": "Build: 5 competitors rate yes (GEMMA Softwarecatalogus, SAP LeanIX, BlueDolphin, GLPI, TOPdesk). Partial and built: the change builds the missing half, an export of your own data from a user page instead of admin settings.", + "change": "insight-exports-and-custom-reports", + "decidedOn": "2026-09-27" + }, { "row": "share-federation-peers", "matrix": "stackiq", @@ -783,6 +951,14 @@ "change": null, "decidedOn": "2026-09-27" }, + { + "row": "share-itsm-integration", + "matrix": "stackiq", + "decision": "build", + "reason": "Build: 4 competitors rate yes (SAP LeanIX, BlueDolphin, GLPI, TOPdesk).", + "change": "sharing-itsm-exchange", + "decidedOn": "2026-09-27" + }, { "row": "share-lifecycle-conditional-sync", "matrix": "stackiq", From 1bc4552cad9594623d82db0cde7172e5a255f26c Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 27 Sep 2026 20:28:56 +0200 Subject: [PATCH 20/20] docs(openspec): guard the transfer and attestation endpoints on the maintainer rule, not the empty admin-group list --- openspec/changes/landscape-move-between-organisations/design.md | 2 +- openspec/changes/landscape-owner-attestation/design.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openspec/changes/landscape-move-between-organisations/design.md b/openspec/changes/landscape-move-between-organisations/design.md index d979a813d..ce78ed927 100644 --- a/openspec/changes/landscape-move-between-organisations/design.md +++ b/openspec/changes/landscape-move-between-organisations/design.md @@ -19,7 +19,7 @@ Rejected: calling the merge with a filter. The merge tombstones the source organ ## D2. Endpoints and authorisation -`POST /api/ownership-transfers/plan` and `POST /api/ownership-transfers/execute` in `appinfo/routes.php`, body `{ objects: [{ schema, id }], targetOrganisation }`, controller `lib/Controller/OwnershipTransferController.php`, `#[NoAdminRequired]` with an explicit guard: the caller is a Nextcloud admin, or is in an organisation admin group (`SettingsService::getOrganizationAdminGroups()`, as `SettingsController::verifyOrgExportPermission()` uses at `lib/Controller/SettingsController.php:1737`) AND is a member of both organisations (the multi-org membership from the archived change `multi-org-membership`). Anything else is a 403 before any read. +`POST /api/ownership-transfers/plan` and `POST /api/ownership-transfers/execute` in `appinfo/routes.php`, body `{ objects: [{ schema, id }], targetOrganisation }`, controller `lib/Controller/OwnershipTransferController.php`, `#[NoAdminRequired]` with an explicit guard: the caller is a Nextcloud admin, or passes the maintainer rule of `OrganisationMembersController::authorizeMaintainer()` (`lib/Controller/OrganisationMembersController.php:207`: in the `maintainer` group and a member of the organisation, from `OrganisationService::getUserOrganisations()`) for BOTH the source and the target organisation. Anything else is a 403 before any read. `SettingsService::getOrganizationAdminGroups()` is not used: it returns an empty list (`lib/Service/SettingsService.php:2105-2110`), so a guard built on it would admit Nextcloud admins only. ## D3. The action diff --git a/openspec/changes/landscape-owner-attestation/design.md b/openspec/changes/landscape-owner-attestation/design.md index 565a602c1..ff44c0e54 100644 --- a/openspec/changes/landscape-owner-attestation/design.md +++ b/openspec/changes/landscape-owner-attestation/design.md @@ -24,7 +24,7 @@ Authorization: an organisation reads its own rounds and requests (`_organisation 2. For each entry pick owners: `businessOwner` and `technicalOwner` for a usage, `contactPerson` for a module. Resolve each contact person to a Nextcloud uid through `ContactPersonHandler`; entries whose owner has no account are returned as `unassigned`. 3. Create the round and one request per entry and owner. -Route `POST /api/attestation-rounds` (`#[NoAdminRequired]`), guarded: the caller is an organisation admin of the scope organisation (`SettingsService::getOrganizationAdminGroups()`). The response lists the unassigned entries. +Route `POST /api/attestation-rounds` (`#[NoAdminRequired]`), guarded: the caller is a Nextcloud admin, or passes the maintainer rule of `OrganisationMembersController::authorizeMaintainer()` (`lib/Controller/OrganisationMembersController.php:207`: in the `maintainer` group and a member of the scope organisation). `SettingsService::getOrganizationAdminGroups()` is not used: it returns an empty list (`lib/Service/SettingsService.php:2105-2110`). The response lists the unassigned entries. ## D3. Answering