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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 19 additions & 6 deletions lib/Service/Connection/ConnectionObservations.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,17 +84,18 @@ public function breachLookup(?int $httpStatus): array {
* enabled the refresh stands alone, and the row waits for the next drain.
*
* @param int $enabledSinks How many sinks are enabled after the save.
* @param int $sinks How many sinks exist after the save, enabled or not.
*
* @return array{0: string, 1: string}|null The status and the message, or null.
*
* @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-keepiq-conn-002-a-save-asks-integriq-to-look-again-and-a-lookup-or-a-drain-reports-what-it-met
*/
public function siemSinksChanged(int $enabledSinks): ?array {
public function siemSinksChanged(int $enabledSinks, int $sinks): ?array {
if ($enabledSinks > 0) {
return null;
}

return $this->noSinkEnabled();
return $this->noSinkEnabled(sinks: $sinks);
}//end siemSinksChanged()

/**
Expand All @@ -106,14 +107,15 @@ public function siemSinksChanged(int $enabledSinks): ?array {
* @param int $enabledSinks How many sinks are enabled.
* @param array<int, array{host: string, ok: bool}> $delivered Per sink the drain delivered to: its host, and
* whether its last delivery went through.
* @param int $sinks How many sinks exist, enabled or not.
*
* @return array{0: string, 1: string}|null The status and the message, or null when the drain met nothing.
*
* @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-keepiq-conn-002-a-save-asks-integriq-to-look-again-and-a-lookup-or-a-drain-reports-what-it-met
*/
public function siemDrain(int $enabledSinks, array $delivered): ?array {
public function siemDrain(int $enabledSinks, array $delivered, int $sinks): ?array {
if ($enabledSinks === 0) {
return $this->noSinkEnabled();
return $this->noSinkEnabled(sinks: $sinks);
}

$total = count($delivered);
Expand Down Expand Up @@ -219,9 +221,20 @@ private function atHost(string $host): string {
/**
* The report for an instance where no sink is enabled.
*
* Sinks that exist and are all switched off are a choice an admin made, so
* they read `disabled` (hydra connection-registry D4, D12 item 9). No sink
* at all is a step nobody took yet, so it stays `unconfigured`. Neither
* message names a host: there is no delivery to name one from.
*
* @param int $sinks How many sinks exist, enabled or not.
*
* @return array{0: string, 1: string}
*/
private function noSinkEnabled(): array {
return ['unconfigured', 'No SIEM sink is switched on. Add one under SIEM audit export.'];
private function noSinkEnabled(int $sinks): array {
if ($sinks > 0) {
return ['disabled', 'Every SIEM sink is switched off, so no audit event is forwarded.'];
}

return ['unconfigured', 'No SIEM sink is added yet. Add one under SIEM audit export.'];
}//end noSinkEnabled()
}//end class
45 changes: 37 additions & 8 deletions lib/Service/Connection/ConnectionReporter.php
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,8 @@ public function __construct(
/**
* After an admin save wrote `breach_check_enabled`: ask integriq to look again.
*
* No report follows. Integriq reads the switch itself (rule 5), and a
* lookup reports once a user checks a password.
* No report follows. Integriq reads the `hibp` switch itself (rule 2b), and
* a lookup reports once a user checks a password.
*
* @return bool True when the refresh was sent.
*
Expand Down Expand Up @@ -173,23 +173,32 @@ public function reportBreachLookup(?int $httpStatus): bool {
/**
* After a sink create, change or delete: refresh, then report when no sink is left on.
*
* The count is only taken when integriq is installed, so without it the
* save costs no extra query.
* The counts are only taken when integriq is installed, so without it the
* save costs no extra query. All sinks are only counted when none is
* enabled, to tell switched off from never added.
*
* @param callable(): int $enabledSinkCount Counts the sinks that are enabled after the save.
* @param callable(): int $sinkCount Counts every sink after the save, enabled or not.
*
* @return bool True when a report was sent.
*
* @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-keepiq-conn-002-a-save-asks-integriq-to-look-again-and-a-lookup-or-a-drain-reports-what-it-met
*/
public function siemSinksChanged(callable $enabledSinkCount): bool {
public function siemSinksChanged(callable $enabledSinkCount, callable $sinkCount): bool {
if ($this->refresh(key: self::KEY_SIEM) === false) {
return false;
}

return $this->reportObserved(
key: self::KEY_SIEM,
observe: fn (): ?array => $this->observations->siemSinksChanged(enabledSinks: $enabledSinkCount())
observe: function () use ($enabledSinkCount, $sinkCount): ?array {
$enabled = $enabledSinkCount();

return $this->observations->siemSinksChanged(
enabledSinks: $enabled,
sinks: $this->countSinksWhenNoneEnabled(enabled: $enabled, sinkCount: $sinkCount)
);
}
);
}//end siemSinksChanged()

Expand All @@ -198,12 +207,13 @@ public function siemSinksChanged(callable $enabledSinkCount): bool {
*
* @param int $enabledSinks How many sinks are enabled.
* @param array<int, SiemSink> $attemptedSinks The sinks this drain tried to deliver to, after the attempt.
* @param callable(): int $sinkCount Counts every sink, enabled or not. Only called when none is enabled.
*
* @return bool True when a report was sent.
*
* @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-keepiq-conn-002-a-save-asks-integriq-to-look-again-and-a-lookup-or-a-drain-reports-what-it-met
*/
public function reportSiemDrain(int $enabledSinks, array $attemptedSinks): bool {
public function reportSiemDrain(int $enabledSinks, array $attemptedSinks, callable $sinkCount): bool {
return $this->reportObserved(
key: self::KEY_SIEM,
observe: fn (): ?array => $this->observations->siemDrain(
Expand All @@ -214,11 +224,30 @@ public function reportSiemDrain(int $enabledSinks, array $attemptedSinks): bool
'ok' => $sink->getLastDeliveryStatus() === 'ok',
],
array_values($attemptedSinks)
)
),
sinks: $this->countSinksWhenNoneEnabled(enabled: $enabledSinks, sinkCount: $sinkCount)
)
);
}//end reportSiemDrain()

/**
* Every sink, counted only when none is enabled; otherwise the enabled count stands in.
*
* With a sink enabled the total cannot change the report, so the query is skipped.
*
* @param int $enabled How many sinks are enabled.
* @param callable(): int $sinkCount Counts every sink.
*
* @return int
*/
private function countSinksWhenNoneEnabled(int $enabled, callable $sinkCount): int {
if ($enabled > 0) {
return $enabled;
}

return $sinkCount();
}//end countSinksWhenNoneEnabled()

/**
* The HTTP status a failed call still carries, for {@see reportBreachLookup()}.
*
Expand Down
3 changes: 2 additions & 1 deletion lib/Service/SiemService.php
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,8 @@ public function deliverDue(): int {

$this->connectionReporter?->reportSiemDrain(
enabledSinks: count($enabledSinks),
attemptedSinks: array_values($attempted)
attemptedSinks: array_values($attempted),
sinkCount: fn (): int => count($this->sinkMapper->findAll())
);

return $delivered;
Expand Down
3 changes: 2 additions & 1 deletion lib/Service/SiemSinkService.php
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,8 @@ private function applySecretAndFilter(SiemSink $sink, array $params): void {
*/
private function reportSinksChanged(): void {
$this->connectionReporter?->siemSinksChanged(
enabledSinkCount: fn (): int => count($this->sinkMapper->findEnabled())
enabledSinkCount: fn (): int => count($this->sinkMapper->findEnabled()),
sinkCount: fn (): int => count($this->sinkMapper->findAll())
);
}//end reportSinksChanged()
}//end class
7 changes: 5 additions & 2 deletions lib/Settings/connections.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@
"description": "Checks password hash prefixes against Have I Been Pwned, once an admin switches it on and a user opts in.",
"order": 10,
"settingsUrl": "/settings/admin/keepiq#section-breach-check",
"requiredConfig": ["breach_check_enabled"],
"unconfiguredMessage": "Breach checking is switched off. Switch it on under Breach checking in the Keepiq admin settings."
"switch": {
"configKey": "breach_check_enabled"
},
"disabledMessage": "Breach checking is switched off. Switch it on under Breach checking in the Keepiq admin settings.",
"unconfiguredMessage": "Not checked yet. Keepiq reports here after the next password check reaches Have I Been Pwned."
},
{
"key": "siem",
Expand Down
15 changes: 8 additions & 7 deletions openspec/changes/adopt-connection-registry/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ Each candidate was checked against the code on `development` on 2026-09-14.

| Key | Declared as | Why |
|---|---|---|
| `hibp` | `requiredConfig: ["breach_check_enabled"]` | `BreachProxyController::range()` refuses every lookup with 403 while the key is off. Nothing else gates the call. |
| `hibp` | `switch: {"configKey": "breach_check_enabled"}` | `BreachProxyController::range()` refuses every lookup with 403 while the key is off. Nothing else gates the call. |
| `siem` | `reportedOnly: true` | `SiemService::deliverDue()` drains every enabled sink in `keepiq_siem_sinks`. Sinks are records, not app config. |

**Why a boolean key is honest here.** `AdminSettingsService` stores `breach_check_enabled` with `setValueBool`. Integriq's `ConnectionConfigReader::readAnyType()` reads a typed key with `getValueBool`, and since hydra#676 a `false` counts as empty. A switched-off check therefore reads Not configured, and a switched-on one reads Configured with "Required settings are filled." until the first lookup reports. Verified against `integriq/lib/Service/ConnectionConfigReader.php` on `development`.
**Why a boolean key is honest here.** `AdminSettingsService` stores `breach_check_enabled` with `setValueBool`. Integriq's `ConnectionConfigReader::readAnyType()` reads a typed key with `getValueBool`, and since hydra#676 a `false` counts as empty. Since hydra#677 the key is the row's `switch`, not a required setting: a switched-off check reads `disabled` with the declared `disabledMessage`, and a switched-on one reads Not configured with "Not checked yet" until the first lookup reports. A filled switch says the check may run, not that Have I Been Pwned answered.

**Why no adapter on `hibp`.** The upstream is a fixed constant, `https://api.pwnedpasswords.com/range/`. There is no mock to select, so rule 3 has nothing to read.

Expand All @@ -36,13 +36,14 @@ Each candidate was checked against the code on `development` on 2026-09-14.
| answered anything else | `error` | "Have I Been Pwned answered HTTP {n} on the last range lookup." |
| did not answer | `error` | "The last range lookup got no answer from Have I Been Pwned." |

**SIEM, on a sink create, change or delete.** A refresh for `siem`. When no enabled sink is left, a report `unconfigured`: "No SIEM sink is switched on. Add one under SIEM audit export." Otherwise the refresh alone, so the row reads the declared "Not checked yet" until the next drain delivers.
**SIEM, on a sink create, change or delete.** A refresh for `siem`. When no enabled sink is left and sinks still exist, a report `disabled`: "Every SIEM sink is switched off, so no audit event is forwarded." When no sink exists at all, a report `unconfigured`: "No SIEM sink is added yet. Add one under SIEM audit export." The row has no `switch`, because sinks are records, so Keepiq reports `disabled` itself (hydra connection-registry D4). All sinks are counted only when none is enabled. Otherwise the refresh alone, so the row reads the declared "Not checked yet" until the next drain delivers.

**SIEM, after a drain** (`DeliverSiemEventsJob`, every 60 seconds). The report looks only at sinks the drain tried to deliver to in this run. A sink's older `lastDeliveryStatus` is not used: it would bring back an error from before a save, which is exactly what hydra#674 retires.

| Sinks the drain delivered to | Status |
|---|---|
| none, and no sink is enabled | `unconfigured` |
| none, no sink is enabled, and sinks exist | `disabled` |
| none, and no sink exists | `unconfigured` |
| none, while sinks are enabled | nothing |
| all took it | `configured` |
| some took it | `limited`, naming the first host that failed |
Expand All @@ -60,14 +61,14 @@ Each candidate was checked against the code on `development` on 2026-09-14.

- `src/manifest.d/80-connection-registry.json`: an `index` page `Integrations` at `/settings/integrations`, `requiresApp` integriq, `permission: admin`, `showAdd: false`, and the columns connection, status, status message, last checked and settings.
- Its menu entry `IntegrationsMenu` sits in the settings gear with `query: {app: keepiq}`, `permission: admin` and `visibleIf.appInstalled: integriq`.
- `src/services/connectionRegistry.js` holds the two formatters and `openIntegriqConnections`.
- `App.vue` passes the formatters through CnAppRoot's `formatters` prop, and merges the handler into the `customComponents` it passes, because CnIndexPage resolves a header action's handler against `customComponents`. It passed no formatters before this change.
- `src/services/connectionRegistry.js` holds `openIntegriqConnections`.
- `App.vue` passes no `formatters`, because CnAppRoot supplies the two built-ins, and merges the handler into the `customComponents` it passes, because CnIndexPage resolves a header action's handler against `customComponents`.

**Keepiq's own navigation rail.** Keepiq renders `KeepiqAppNav` in CnAppRoot's `#menu` slot, because CnAppNav cannot draw the vault folder tree. That rail read only `route`, `href` and `action`. It dropped `query`, so the menu would have opened the page with no preset and listed every app's rows. It also ignored `permission` and `visibleIf`, so the entry would have shown to every user and without integriq. `src/utils/navEntries.js` now holds both rules, taken from CnAppNav: `menuEntryTo()` passes `query` into the route, and `isMenuEntryVisible()` checks `visibleIf.appInstalled` against `OC.appswebroots` and `permission: admin` against the instance admin flag. No existing entry declares either field, so nothing else in the rail changes.

**Why `/settings/integrations` does not break ADR-004.** The rule forbids routing an admin settings component, such as `AdminRoot.vue`, inside the app. This route renders a CnIndexPage over integriq's `app_connection`, whose schema grants read access to admins only. The admin settings themselves stay in `AdminSettings.php`. The `hydra-gate-admin-router` check reads `src/router/index.js`, which Keepiq does not have: routes come from the manifest.

**Formatters.** The installed `@conduction/nextcloud-vue` 2.41.1 ships no `connectionStatus` built-in, so Keepiq carries a local copy with all six labels, `limited` included.
**Formatters.** `@conduction/nextcloud-vue` 3.2.0 ships `connectionStatus` and `connectionSettingsLabel` as built-ins, `disabled` included (nextcloud-vue#1173). Keepiq carried a local copy while it pinned 2.41.1, and dropped it on moving to 3.2.0.

## D4. Contract misfits

Expand Down
6 changes: 3 additions & 3 deletions openspec/changes/adopt-connection-registry/proposal.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,17 @@ Hydra change `connection-registry` (hydra#667, amended in hydra#673, hydra#674 a
## What changes

- New `lib/Settings/connections.json` with two connections: `hibp` and `siem`.
- `hibp` requires `breach_check_enabled`. The key is a boolean, and integriq reads a stored `false` as empty (amendment 6), so a switched-off check reads Not configured.
- `hibp` declares `breach_check_enabled` as its `switch`. The key is a boolean, and integriq reads a stored `false` as empty (amendment 6), so a switched-off check reads Switched off (amendment 9).
- `siem` is `reportedOnly`. The sinks are records, not settings, so a static file cannot list them. One row speaks for the whole family (D12, "Still out").
- The Breach checking and SIEM audit export sections get stable ids: `section-breach-check` and `section-siem`.
- Saving `breach_check_enabled` sends `ConnectionRefreshRequestedEvent` for `hibp`.
- Creating, changing or deleting a sink sends a refresh for `siem`, then reports Not configured when no sink is switched on.
- Creating, changing or deleting a sink sends a refresh for `siem`, then reports Switched off when sinks exist and none is on, or Not configured when there is no sink.
- A range lookup that reaches Have I Been Pwned reports its outcome. A SIEM drain that delivered to at least one sink reports the outcome over those sinks. Both are throttled: the same status at most once an hour, a new status at most once every five minutes.
- A report names a status code or a host. It never carries a hash prefix, a password, a sink URL path, a token or an exception message.
- An Integrations page under the settings gear, over integriq's `app_connection` schema, preset to `app=keepiq`, admin only, and only shown when integriq is installed.
- Keepiq's own navigation rail learns to honour a menu entry's `query`, `permission: admin` and `visibleIf.appInstalled`. It ignored all three before, so the preset would not have reached the page.
- Add integration opens `/apps/integriq/connections?app=keepiq&link=1`.
- Local `connectionStatus` and `connectionSettingsLabel` formatters with all six statuses, and the strings in English and Dutch.
- The `connectionStatus` and `connectionSettingsLabel` formatters come from `@conduction/nextcloud-vue` 3.2.0, which labels all seven statuses. The page strings are in English and Dutch.

## Depends on

Expand Down
Loading
Loading