From d7154621099808d52c933324342d35ad2907e5be Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 27 Sep 2026 20:08:42 +0200 Subject: [PATCH 1/3] docs(openspec): specify admin member overview, vault policies, scoped roles, public admin API and auto-confirm Five OpenSpec changes for the keepiq parity pass (rows admin-04, admin-12, admin-10, admin-22, admin-11, admin-13, admin-25). Specs only, no code. --- .../admin-auto-confirm-members/.openspec.yaml | 2 + .../admin-auto-confirm-members/design.md | 80 +++++++++++++ .../admin-auto-confirm-members/proposal.md | 52 ++++++++ .../specs/team-folder-auto-confirm/spec.md | 57 +++++++++ .../admin-auto-confirm-members/tasks.md | 27 +++++ .../.openspec.yaml | 2 + .../design.md | 94 +++++++++++++++ .../proposal.md | 64 ++++++++++ .../specs/admin-member-overview/spec.md | 50 ++++++++ .../specs/team-folder-sharing/spec.md | 31 +++++ .../tasks.md | 29 +++++ .../changes/admin-public-api/.openspec.yaml | 2 + openspec/changes/admin-public-api/design.md | 90 ++++++++++++++ openspec/changes/admin-public-api/proposal.md | 54 +++++++++ .../admin-public-api/specs/admin-api/spec.md | 65 ++++++++++ openspec/changes/admin-public-api/tasks.md | 27 +++++ .../changes/admin-scoped-roles/.openspec.yaml | 2 + openspec/changes/admin-scoped-roles/design.md | 87 ++++++++++++++ .../changes/admin-scoped-roles/proposal.md | 57 +++++++++ .../specs/admin-scoped-roles/spec.md | 50 ++++++++ openspec/changes/admin-scoped-roles/tasks.md | 32 +++++ .../admin-vault-policies/.openspec.yaml | 2 + .../changes/admin-vault-policies/design.md | 111 ++++++++++++++++++ .../changes/admin-vault-policies/proposal.md | 60 ++++++++++ .../specs/vault-policies/spec.md | 99 ++++++++++++++++ .../changes/admin-vault-policies/tasks.md | 34 ++++++ 26 files changed, 1260 insertions(+) create mode 100644 openspec/changes/admin-auto-confirm-members/.openspec.yaml create mode 100644 openspec/changes/admin-auto-confirm-members/design.md create mode 100644 openspec/changes/admin-auto-confirm-members/proposal.md create mode 100644 openspec/changes/admin-auto-confirm-members/specs/team-folder-auto-confirm/spec.md create mode 100644 openspec/changes/admin-auto-confirm-members/tasks.md create mode 100644 openspec/changes/admin-member-overview-and-offboarding/.openspec.yaml create mode 100644 openspec/changes/admin-member-overview-and-offboarding/design.md create mode 100644 openspec/changes/admin-member-overview-and-offboarding/proposal.md create mode 100644 openspec/changes/admin-member-overview-and-offboarding/specs/admin-member-overview/spec.md create mode 100644 openspec/changes/admin-member-overview-and-offboarding/specs/team-folder-sharing/spec.md create mode 100644 openspec/changes/admin-member-overview-and-offboarding/tasks.md create mode 100644 openspec/changes/admin-public-api/.openspec.yaml create mode 100644 openspec/changes/admin-public-api/design.md create mode 100644 openspec/changes/admin-public-api/proposal.md create mode 100644 openspec/changes/admin-public-api/specs/admin-api/spec.md create mode 100644 openspec/changes/admin-public-api/tasks.md create mode 100644 openspec/changes/admin-scoped-roles/.openspec.yaml create mode 100644 openspec/changes/admin-scoped-roles/design.md create mode 100644 openspec/changes/admin-scoped-roles/proposal.md create mode 100644 openspec/changes/admin-scoped-roles/specs/admin-scoped-roles/spec.md create mode 100644 openspec/changes/admin-scoped-roles/tasks.md create mode 100644 openspec/changes/admin-vault-policies/.openspec.yaml create mode 100644 openspec/changes/admin-vault-policies/design.md create mode 100644 openspec/changes/admin-vault-policies/proposal.md create mode 100644 openspec/changes/admin-vault-policies/specs/vault-policies/spec.md create mode 100644 openspec/changes/admin-vault-policies/tasks.md diff --git a/openspec/changes/admin-auto-confirm-members/.openspec.yaml b/openspec/changes/admin-auto-confirm-members/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/admin-auto-confirm-members/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/admin-auto-confirm-members/design.md b/openspec/changes/admin-auto-confirm-members/design.md new file mode 100644 index 000000000..5dee5dc64 --- /dev/null +++ b/openspec/changes/admin-auto-confirm-members/design.md @@ -0,0 +1,80 @@ +# Design: automatic confirmation of new team folder members + +## Context + +Read at development `4c214a9d`. + +- `lib/Service/TeamFolderService.php:451` `handleGroupMemberJoin()` notifies the folder owner with `team_folder_join_request` when a user joins a member group. Nothing else happens. +- `lib/Service/TeamFolderService.php:496` `approveJoin()` returns the new member's certificate and the subtree secrets, owner only. Its store action `src/store/modules/teamFolder.js:202` has no caller in `src`. +- `lib/Service/TeamFolderService.php:386` `reconcile()` computes the missing (secret, user) pairs; `:423` `registerFanOutShares()` stores browser-encrypted rows. Both call `loadOwnedTeamFolder()`, so only the owner can run them. +- `src/store/modules/teamFolder.js:278` `runFanOut()` reconciles, decrypts each source secret with the session key, encrypts per recipient certificate and posts in chunks. It runs when the owner opens the dialog (`src/modals/TeamFolderDialog.vue:469` shows the pending count). +- `lib/Service/TeamFolderShareService.php:93` stores rows inside one transaction and sends `team_folder_shared` once per new recipient; `:263` `createFanOutShare()` skips rows outside the subtree, for the owner, already existing, or for a user without an active suite. +- `lib/Service/TeamFolderService.php:620` `resolveGrade()` returns a member's effective grade. `lib/Service/ShareSyncService.php:230` `assertSyncSourceUnchanged()` refuses a write based on a stale view of the source. +- `src/store/modules/session.js:97` `unlockFromBlob()` is the single place every unlock path ends. + +## Goals / Non-Goals + +**Goals:** + +- A new member receives their copies without anyone clicking, as soon as one authorised member has an unlocked vault. +- The server never decrypts, never holds a key, and never picks the value it hands out. +- An administrator decides whether the behaviour is on. + +**Non-Goals:** + +- Server-side fan-out. The server holds no usable private key (ADR-003), so it cannot produce a recipient copy. +- A per-folder override. The switch is instance-wide; a per-folder opt-out needs a column and can follow. +- Confirming users who are not covered by a membership row. Coverage stays with the owner and Nextcloud groups. + +## Decisions + +### D1: An admin policy switch, off by default + +`team_folder_auto_confirm` is a boolean app config key in the Policies area, written through `PUT /api/settings/admin` and audited like other policy changes. `getPolicy()` exposes it to the browser. + +Alternative considered: always on. Rejected: some organisations want the owner to see each new member before access lands, which is today's behaviour. + +### D2: Authorised confirmers are the owner and write-grade members + +A `write` grade already lets a member push a new value to every member (`folder-permission-grades` spec). Handing the current value to one more covered member adds no power they lack. A `read` member could hand a new colleague a wrong value that no one else sees, so `read` members are not confirmers. + +Alternative considered: any member confirms. Rejected for that reason. + +### D3: A confirmer re-encrypts their own copy, if it is current + +The owner decrypts the source secret, as `runFanOut()` does today. A `write` member decrypts their own recipient copy. The server accepts a member's row only when the member holds a live derived copy of that source secret and that copy's `updated_at` is not older than the source's `key_updated_at`, the same staleness rule `ShareSyncService` applies to writes. A stale copy is skipped and the pair stays pending for the next confirmer. + +### D4: One pending-confirmations endpoint for the confirmer + +`GET /api/v1/team-folders/pending-confirmations` returns, for the session user, each team folder where they are a confirmer and pairs are missing: the folder id, the missing pairs, each recipient's certificate, and for a member the id of their own copy per source secret. It reuses the reconcile queries without the owner check, and it excludes disabled accounts and users without an active suite. It returns nothing when the switch is off. + +### D5: The browser confirms after unlock, without a click + +After `unlockFromBlob()` succeeds and the switch is on, the session store starts `teamFolder.autoConfirm()` in the background: fetch pending, decrypt each needed copy once, encrypt per recipient, post in chunks to `POST /api/v1/team-folders/{id}/shares`. It repeats every 15 minutes while unlocked and stops on lock. It shows one quiet notice, for example "Gave 2 new members access to Finance". A failure is logged and retried on the next run; it never blocks the vault. + +Alternative considered: a service worker that runs while the tab is closed. Rejected: the non-extractable key lives in the page's memory and is cleared on close (ADR-003); a worker would need the key outside that boundary. + +### D6: Everyone learns what happened + +The new member receives the existing `team_folder_shared` notification from `registerFanOutShares()`. The owner receives a new `team_folder_member_confirmed` notification naming the confirmer and the new member, routed through the existing `notify_group_shares` setting. The audit event for the fan-out records the confirmer as actor. + +## Security and zero-knowledge + +- The server never sees plaintext. A confirmer's browser decrypts with its own non-extractable key and posts only ciphertext encrypted for the recipient. +- Stored encrypted: the new recipient copies (RSA ciphertext, as today). Stored plain: the policy switch and the share rows' identifiers. +- The certificate trust is unchanged: the confirmer encrypts to the certificate the server returns for a covered user, which is exactly what the owner's manual fan-out does today. +- A confirmer cannot add a user: the server accepts a row only for a pair that is missing and covered by a membership row. + +## Risks / Trade-offs + +- If no confirmer unlocks, the new member keeps waiting. The team folder dialog shows the pending count with "Waiting for a member with write access to open Keepiq". +- A run in several confirmers' browsers at once can race. Row creation is idempotent (`createFanOutShare()` skips an existing share), so the loser creates nothing. +- Decrypting many secrets in the background costs CPU after unlock. Runs are chunked and yield between chunks, as `runFanOut()` already does. + +## Seed data + +None. PHPUnit tests build folders, grades and copies with mocks; the Playwright test adds a member to a folder where a `write` member unlocks. + +## Migration + +None. No table or column; one app config key. `` in `appinfo/info.xml` does not need a bump for schema reasons. diff --git a/openspec/changes/admin-auto-confirm-members/proposal.md b/openspec/changes/admin-auto-confirm-members/proposal.md new file mode 100644 index 000000000..0781e344e --- /dev/null +++ b/openspec/changes/admin-auto-confirm-members/proposal.md @@ -0,0 +1,52 @@ +--- +kind: code +--- + +# Automatic confirmation of new team folder members + +## Why + +A new team folder member gets no secrets until the folder owner opens the team folder dialog and runs the key fan-out. When the owner is on leave, the new colleague waits. This change confirms new members automatically, without breaking zero knowledge. + +| Row | Capability | What keepiq does today | +|---|---|---| +| admin-25 | New members are confirmed automatically, without an administrator handing over access by hand | A new team folder member gets access only when the owner's browser runs the key fan-out; there is no automatic confirmation. | + +Matrix: keepiq `openspec/parity/capabilities.json` + +### Demand + +- changelog: https://github.com/bitwarden/clients/releases/tag/web-v2026.3.1 + +### Competitors rated yes + +- Bitwarden: "bitwarden/server@v2026.9.1 src/Core/AdminConsole/Enums/PolicyType.cs:27 AutomaticUserConfirmation ('Automatically confirm invited users'); bitwarden/clients@web-v2026.9.0 apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.ts:33 AutoConfirmPolicy ..." +- HashiCorp Vault: "hashicorp/vault@v2.1.1 vault/identity_store_group_aliases.go:21 group-alias maps an external group (OIDC, LDAP) to a Vault group and its policies. Note: Vault encrypts server-side, so there is no key handover to confirm; a new member gets the policies of their mapped groups at first login. ..." +- Nextcloud Passwords: "marius-wieschollek/passwords@2026.9.0 src/lib/Controller/Api/ShareApiController.php:181 canShareWithUser() accepts any Nextcloud user; src/js/Actions/Share/CreateShareAction.js:90 #disableCse() moves shared items to server-side encryption, so no key handover is needed ..." + +## What Changes + +- An admin policy switch "Automatically confirm new team folder members" (`team_folder_auto_confirm`, off by default) in the Policies area of the Keepiq admin settings. +- With the switch on, the key fan-out for a new member runs in the unlocked browser of any authorised confirmer: the folder owner, or a member whose effective grade on the folder is `write`. It runs on unlock and every 15 minutes while the vault stays unlocked, with no click. +- A new endpoint `GET /api/v1/team-folders/pending-confirmations` tells a confirmer which folders have members waiting, with their certificates. +- `POST /api/v1/team-folders/{id}/shares` accepts rows from a `write`-grade confirmer who re-encrypts their own current copy, not only from the owner. +- The new member gets the existing "team folder shared" notification; the owner gets a notice naming who confirmed whom. +- With the switch off, nothing changes: the owner runs the fan-out as today. + +## Capabilities + +### New Capabilities + +- `team-folder-auto-confirm`: new team folder members receive their key copies automatically from any authorised member's unlocked browser, under an admin policy switch. + +### Modified Capabilities + +None. + +## Impact + +- **Backend**: a pending-confirmations query next to `TeamFolderService::reconcile()`; `registerFanOutShares()` accepts a `write`-grade confirmer with a copy freshness check; the policy key joins `AdminSettingsService` and `getPolicy()`; a new notification subject for the owner. +- **Frontend**: an `autoConfirm()` action in `src/store/modules/teamFolder.js` started after unlock in `src/store/modules/session.js`; a switch in the admin Policies area; the team folder dialog shows who confirmed and what still waits. +- **Database**: none. +- **Security**: the server still never decrypts. Only members already trusted to write a value for the whole team may hand a copy to a new member, and only from a copy as new as the source. +- **Cross-app**: none. diff --git a/openspec/changes/admin-auto-confirm-members/specs/team-folder-auto-confirm/spec.md b/openspec/changes/admin-auto-confirm-members/specs/team-folder-auto-confirm/spec.md new file mode 100644 index 000000000..0e1d8dcd8 --- /dev/null +++ b/openspec/changes/admin-auto-confirm-members/specs/team-folder-auto-confirm/spec.md @@ -0,0 +1,57 @@ +## ADDED Requirements + +### Requirement: Administrator switches automatic member confirmation on + +The system MUST offer an admin policy switch `team_folder_auto_confirm`, off by default, in the Policies area of the Keepiq admin settings. Only an administrator MUST be able to change it. Every change MUST be audited. `GET /api/settings/policy` MUST expose its value to the browser. With the switch off, new members MUST receive copies only through the owner's fan-out, as before. + +#### Scenario: Administrator turns on automatic confirmation + +- **GIVEN** an administrator on the Keepiq admin settings page +- **WHEN** they switch on "Automatically confirm new team folder members" and save +- **THEN** `GET /api/settings/policy` MUST return `team_folder_auto_confirm` as true +- **AND** one policy audit event MUST be recorded + +### Requirement: Pending confirmations are served to authorised confirmers only + +`GET /api/v1/team-folders/pending-confirmations` MUST return, for the session user, each team folder where that user is the owner or has an effective `write` grade and where covered members still miss copies. Each entry MUST carry the missing pairs, the recipients' certificates and, for a member, the ids of their own copies. It MUST exclude disabled accounts and users without an active suite. It MUST return an empty list when the switch is off or the user is a `read` member or a non-member. + +#### Scenario: Write member sees a waiting colleague + +- **GIVEN** the switch is on, `hank` has a `write` grade on team folder `Ops`, and `kim` just joined group `ops-team`, a member of `Ops` +- **WHEN** `hank`'s browser calls `GET /api/v1/team-folders/pending-confirmations` +- **THEN** the response MUST list `Ops` with the missing pairs for `kim` and `kim`'s certificate + +#### Scenario: Read member sees nothing + +- **GIVEN** the switch is on and `jack` has a `read` grade on team folder `Ops` with a waiting member +- **WHEN** `jack`'s browser calls `GET /api/v1/team-folders/pending-confirmations` +- **THEN** the response MUST be an empty list + +### Requirement: An unlocked confirmer's browser confirms without a click + +When the switch is on, the browser of an authorised confirmer MUST, after the vault unlocks and every 15 minutes while it stays unlocked, fetch pending confirmations, decrypt the needed secret (the owner's source, or the member's own copy) with the session key, encrypt it under each recipient's certificate, and post the rows to `POST /api/v1/team-folders/{id}/shares`. No request MUST carry plaintext. The run MUST stop when the vault locks. + +#### Scenario: New member gets access without the owner + +- **GIVEN** the switch is on, owner `iris` of team folder `Ops` is away, and `kim` joined a member group of `Ops` +- **WHEN** `write` member `hank` unlocks his vault on the lock screen at `/lock` +- **THEN** `kim` MUST receive a copy of every secret in `Ops` without any click +- **AND** `kim` MUST receive the "team folder shared" notification +- **AND** `iris` MUST receive a notification that `hank` confirmed `kim` + +### Requirement: The server accepts a confirmer's row only when it is safe + +`POST /api/v1/team-folders/{id}/shares` MUST accept a row from a non-owner only when the caller's effective grade on the folder is `write`, the target user is covered by a membership row and still misses that copy, the source secret is inside the folder subtree, and the caller's own copy of that source is not older than the source's last key change. Otherwise the row MUST be skipped and nothing MUST be stored for it. The server MUST NOT decrypt any submitted blob. + +#### Scenario: Stale copy is refused + +- **GIVEN** `hank`'s copy of secret `db-root` in `Ops` is older than the last key change of the source +- **WHEN** `hank`'s browser posts a row for `db-root` to new member `kim` +- **THEN** no copy for `kim` MUST be stored from that row +- **AND** the pair MUST stay pending for the next confirmer + +#### Scenario: Uncovered user is refused + +- **GIVEN** user `lee` is not covered by any membership row of `Ops` +- **WHEN** a `write` member of `Ops` posts a row targeting `lee` +- **THEN** no copy for `lee` MUST be stored diff --git a/openspec/changes/admin-auto-confirm-members/tasks.md b/openspec/changes/admin-auto-confirm-members/tasks.md new file mode 100644 index 000000000..dab77c006 --- /dev/null +++ b/openspec/changes/admin-auto-confirm-members/tasks.md @@ -0,0 +1,27 @@ +## 1. Policy switch + +- [ ] 1.1 Add `team_folder_auto_confirm` to the admin settings validation, the policy audit and `getPolicy()`. Verify with a PHPUnit test in `tests/Unit/Service/AdminSettingsServiceTest.php`. +- [ ] 1.2 Add the switch to the Policies area of the admin settings. Verify with a vitest in `tests/components/`. + +## 2. Server + +- [ ] 2.1 Add `TeamFolderService::pendingConfirmations($userId)` and `GET /api/v1/team-folders/pending-confirmations`, returning folders where the user is owner or `write` member with missing pairs, certificates and own-copy ids. Verify with PHPUnit tests for owner, `write` member, `read` member, switch off and a disabled recipient. +- [ ] 2.2 Let `registerFanOutShares()` accept a `write`-grade confirmer: check the grade with `resolveGrade()`, the pair is missing and covered, and the confirmer's copy is not older than the source's `key_updated_at`. Verify with PHPUnit tests for each refusal and for an accepted row. +- [ ] 2.3 Send `team_folder_member_confirmed` to the owner and record the confirmer as audit actor. Verify with a PHPUnit test on the notification and audit metadata. +- [ ] 2.4 Run the no-admin-idor and route-auth hydra gates on the new and changed endpoints. Verify by a green gate run. + +## 3. Browser + +- [ ] 3.1 Add `autoConfirm()` to `src/store/modules/teamFolder.js`: fetch pending, decrypt the owner source or the member's own copy once, encrypt per recipient, post in chunks. Verify with a vitest that mocks the crypto and asserts no plaintext leaves the store. +- [ ] 3.2 Start `autoConfirm()` after `unlockFromBlob()` when the switch is on, repeat every 15 minutes, stop on lock. Verify with a vitest using fake timers. +- [ ] 3.3 Show who confirmed and the waiting state in `TeamFolderDialog.vue`. Verify with a vitest. +- [ ] 3.4 Cover the flow end to end. Verify with a Playwright test in `tests/e2e/workflows/`: the switch is on, a user joins a member group, a `write` member unlocks, and the new member can open a folder secret without the owner acting. + +## Acceptance criteria + +- With the switch on, a new member of a team folder can read its secrets after any `write` member or the owner unlocks, with no click by anyone. +- With the switch off, behaviour is unchanged. +- A `read` member's browser never receives pending confirmations and cannot register a row for another user. +- A confirmer whose copy is older than the source cannot hand it out. +- No request from the auto-confirm flow carries plaintext; the server stores only recipient ciphertext. +- The owner is notified of every automatic confirmation. diff --git a/openspec/changes/admin-member-overview-and-offboarding/.openspec.yaml b/openspec/changes/admin-member-overview-and-offboarding/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/admin-member-overview-and-offboarding/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/admin-member-overview-and-offboarding/design.md b/openspec/changes/admin-member-overview-and-offboarding/design.md new file mode 100644 index 000000000..c939b8703 --- /dev/null +++ b/openspec/changes/admin-member-overview-and-offboarding/design.md @@ -0,0 +1,94 @@ +# Design: admin member overview and complete offboarding + +## Context + +Read at development `4c214a9d`. + +Offboarding today: + +- `lib/Controller/TeamFolderController.php:236` `offboard()` is `#[NoAdminRequired]` and hands the session user to the service, which asserts the caller is an instance admin or in `vault_admin` (`lib/Service/TeamFolderOffboardingService.php:139`). +- `lib/Service/TeamFolderOffboardingService.php:83` `offboard()` runs two steps: `revokeTeamSharesForUser()` at line 95, then the owned-secret transfer at line 98. It audits at line 112 and returns `revoked`, `transferred`, `skipped`. +- `lib/Service/TeamFolderShareService.php:232` revokes only share rows that carry a `team_folder_id`. The membership rows are untouched. +- `lib/Db/TeamFolderMemberMapper.php:132` `findUserMemberships()` finds the direct `user` rows of a user; `:115` `findGroupMemberships()` finds group rows. +- `lib/Service/TeamFolderMembershipResolver.php:149` `effectiveUsers()` expands a folder's rows to users; `:174` `eligibleRecipients()` keeps every user with an active suite and ignores whether the Nextcloud account is enabled; `:202` `membershipRowsForUser()` returns direct plus group rows covering a user. +- `lib/Service/TeamFolderService.php:386` `reconcile()` computes missing pairs for every effective user. So a leaver whose direct row survives is re-shared the next time the owner runs the fan-out. This is the defect the matrix records at `TeamFolderOffboardingService.php:95`. +- `lib/Event/Audit/AuditEventTypes.php:266` whitelists `leavingUserId`, `successorUserId`, `revokedCount`, `transferredCount` for `TEAM_FOLDER_OFFBOARDED`. +- `src/components/settings/OffboardingSection.vue:35` to `:49` asks for both user ids as free text. + +Vault visibility today: + +- `lib/Service/ComplianceReportService.php:107` counts distinct owners of active user suites. `src/components/settings/ComplianceSection.vue:44` prints that count. +- `src/components/settings/AdminSuiteSection.vue:24` asks for a suite id as free text; no screen lists suites. +- `lib/Controller/EncryptionSuiteController.php:89` `index()` lists only the caller's own suites. +- `lib/Db/EncryptionSuiteMapper.php:160` `findActiveByOwners()` already resolves the active suite for a batch of owners in one query. +- Issue #37 (open) asks for a list of users with an active vault for the share picker. + +## Goals / Non-Goals + +**Goals:** + +- Offboarding removes every direct team folder membership of the leaver in the same action. +- The administrator learns which groups still cover the leaver, and a disabled account is never re-shared. +- An administrator sees, per user, whether a vault is set up, and acts on a row without typing ids. + +**Non-Goals:** + +- Removing the leaver from a Nextcloud group. Group membership belongs to Nextcloud's user management. +- Transferring ownership of team folders the leaver owns. The existing transfer covers owned secrets; folder ownership is unchanged. +- A share picker list for non-admin users (issue #37). That list reveals who uses the vault to every user and needs its own privacy decision. +- Invitations or a "pending" state. Keepiq has no invitation flow; a user without a suite shows as `none`. + +## Decisions + +### D1: Remove direct membership rows as offboarding step three + +After the transfer, the service loads `findUserMemberships(leaver)` and deletes each row, across every team folder. The count is returned as `membershipsRemoved` and written to the audit event. + +Step three runs after the transfer so a transfer failure leaves the memberships intact for a re-run. The share revocation already ran in step one, so the order does not widen access in any window. + +Alternative considered: call `TeamFolderService::removeMember()` per row. Rejected: that method asserts the caller owns the folder (`TeamFolderService.php:313`) and would revoke shares a second time. The offboarding service already holds the admin authority and has revoked every derived share. + +### D2: Report group coverage instead of deleting group rows + +A group row covers every member of the group. Deleting it would cut access for colleagues. The service reads the leaver's group rows through `membershipRowsForUser()` and returns them as `stillCoveredByGroups` (team folder id plus group id). The UI shows them as a warning with the advice to remove the leaver from the group or disable the account. + +Alternative considered: a per-folder exclusion list for offboarded users. Rejected: a second access list next to Nextcloud groups drifts from them, and it needs a new table. + +### D3: The fan-out skips disabled accounts + +`eligibleRecipients()` skips a user whose Nextcloud account is disabled (`IUserManager::get()` then `IUser::isEnabled()`). Disabling the account is the standard Nextcloud offboarding step, so a leaver still in a member group gets no new copy from `reconcile()`. + +Alternative considered: skip users without an active suite only (today's rule). Rejected: offboarding does not revoke the suite, so the leaver still qualifies. + +### D4: Member overview as a paged admin endpoint + +`GET /api/v1/admin/members?status=&search=&limit=&offset=` is guarded by `#[AuthorizedAdminSetting(AdminSettings::class)]`. `MemberOverviewService` pages Nextcloud users through `IUserManager::search()`, then resolves per page: active suites through `findActiveByOwners()`, the newest non-active suite status, secret counts with one grouped count on `keepiq_secrets`, direct membership counts, and whether a row exists in `keepiq_emergency_contacts` for the user as grantor. Each row returns `userId`, `displayName`, `enabled`, `vaultStatus`, `activeSuiteId`, `suiteCreatedAt`, `secretCount`, `teamFolderMemberships`, `hasEmergencyContact`. + +The path sits under `/api/v1/admin/` so the `admin-public-api` change can document it in the public admin API without a rename. + +Alternative considered: extend the compliance metrics with a user list. Rejected: compliance snapshots are immutable evidence and aggregate only (`compliance-reporting` spec, "Org-level metadata-only compliance report"); a per-user list does not belong in a snapshot. + +### D5: One admin section drives the existing actions + +`MemberOverviewSection.vue` (`CnSettingsSection` plus `CnDataTable`) lists the rows with an `NcSelect` status filter (with `inputLabel`) and a search field. The row action "Offboard" writes the user id into a small shared Pinia store that `OffboardingSection.vue` reads; "Revoke suite" does the same for `AdminSuiteSection.vue` with `activeSuiteId`. The offboarding user fields become user pickers fed by the same endpoint. + +## Security and zero-knowledge + +- The server never holds plaintext here. The member endpoint returns identifiers, counts, statuses and dates. It never returns a certificate, a private key blob or any ciphertext. +- Stored encrypted versus plain: nothing new is stored. Deleting membership rows removes plain identifiers (`team_folder_id`, `member_type`, `member_id`, `grade`). +- Offboarding stays admin only (`TeamFolderOffboardingService.php:139`). The new endpoint is admin only through the Nextcloud middleware before the controller runs. +- Removing rows narrows access. A leaver who already read a secret still knows it; the offboarding summary keeps pointing at rotation, as `admin-suite-revocation` does for revoked suites. + +## Risks / Trade-offs + +- Listing every Nextcloud user on a large instance is slow. The endpoint pages (default 50, maximum 200) and resolves suites per page in one query. +- The list tells an administrator who uses the vault. That is the point of the row, and the data was already derivable from the database. It stays admin only. +- A leaver in a member group stays covered until an administrator acts on the warning or disables the account. The summary names the groups so this is never silent. + +## Seed data + +No new fixture. On the dev instance the seeded `admin` vault (`lib/Repair/SeedDevelopmentData.php:41`) shows as `active`, and every other Nextcloud user shows as `none`. PHPUnit tests build their own users and suites with mocks. + +## Migration + +None. No new table or column; the change deletes rows from the existing `keepiq_team_folder_members` table and adds a route. `` in `appinfo/info.xml` does not need a bump for schema reasons. diff --git a/openspec/changes/admin-member-overview-and-offboarding/proposal.md b/openspec/changes/admin-member-overview-and-offboarding/proposal.md new file mode 100644 index 000000000..caef3dd26 --- /dev/null +++ b/openspec/changes/admin-member-overview-and-offboarding/proposal.md @@ -0,0 +1,64 @@ +--- +kind: code +--- + +# Admin member overview and complete offboarding + +## Why + +An administrator cannot see which users have set up a vault, and offboarding a leaver leaves their own team folder member rows behind. This change specifies the missing half of two partial rows. + +| Row | Capability | What keepiq does today | +|---|---|---| +| admin-04 | Remove a leaving user from every team folder in one step | One action revokes every team-folder-derived share and hands owned team secrets to a successor. It does not delete the user's team-folder member rows, so a direct user membership survives. | +| admin-12 | See which users have set up a vault | Admins see how many users have an active vault, not which ones. The encryption-suite admin section needs a suite id typed in by hand. | + +Matrix: keepiq `openspec/parity/capabilities.json` + +### Demand + +No demand row. + +### Competitors rated yes + +- Bitwarden (admin-04): "bitwarden/server@v2026.9.1 src/Api/AdminConsole/Controllers/OrganizationUsersController.cs:579 DELETE organizations/{orgId}/users/{id}, :605 POST remove (bulk), :669 revoke. Note: Removing or revoking a member drops every collection and group access in one step." +- 1Password (admin-04): "https://support.1password.com/offboarding/ : suspend or remove an offboarded team member, removing all vault access" +- Passbolt (admin-04): "passbolt/passbolt_api@v5.16.0 src/Model/Table/UsersTable.php:458 softDelete: :522 GroupsUsers deleteAll for the user, :523 Permissions deleteAll for the user, folder relations removed; ... Note: Deleting a leaving user removes every permission, folder relation and group membership in one action, after sole-owned items are transferred ..." +- Keeper (admin-04): "https://docs.keeper.io/enterprise-guide/user-management-and-lifecycle : Delete User removes the user 'from all Roles, Nodes and Teams'; Lock Account or SCIM/AD Bridge suspension blocks access while Account Transfer keeps the records" +- HashiCorp Vault (admin-04): "hashicorp/vault@v2.1.1 ui/app/models/identity/entity.js:15 entity fields name, disabled, policies, metadata (disable or delete in Access > Entities); vault/identity_store_util.go:3164 external groups dropped on next login ..." +- Bitwarden (admin-12): "bitwarden/server@v2026.9.1 src/Core/AdminConsole/Enums/OrganizationUserStatusType.cs:15 Invited, :19 Accepted, :24 Confirmed, :30 Staged; bitwarden/clients@web-v2026.9.0 apps/web/src/app/admin-console/organizations/members/ members list with status filter ..." +- 1Password (admin-12): "https://support.1password.com/add-remove-team-members/ : People list shows invited, pending confirmation and active members" +- Passbolt (admin-12): "passbolt/passbolt_styleguide@v5.16.0 src/react-extension/components/User/DisplayUsers/DisplayUsers.js:470 isRowInactive greys out users who have not completed setup; ... Note: The users workspace shows which invited users have not activated their account yet ..." +- Keeper (admin-12): "https://docs.keeper.io/enterprise-guide/user-management-and-lifecycle : user status Invited ('has not completed their account setup yet'), Active, Locked" + +### Missing halves + +- admin-04 is partial. Built: `TeamFolderOffboardingService::offboard()` revokes every team-folder-derived share and transfers owned team secrets to a successor. Missing: offboarding also removes the leaver's own team folder member rows. +- admin-12 is partial. Built: the adoption count in the compliance section. Missing: a list of which users have set up a vault. + +## What Changes + +- Offboarding gains a third step: after revoking derived shares and transferring owned team secrets, it deletes every direct `user` membership row of the leaver (`keepiq_team_folder_members`, `member_type = user`). +- A group membership row is never deleted, because it covers other people. The offboarding result names every team folder group that still covers the leaver, so the administrator can remove them from that Nextcloud group. +- The team folder fan-out skips a recipient whose Nextcloud account is disabled. A disabled leaver who still sits in a member group is never re-shared by a later reconcile. +- The offboarding audit event records the number of removed membership rows and the covering groups. +- A new admin endpoint `GET /api/v1/admin/members` lists every Nextcloud user with their vault status (`none`, `active`, `revoked`, `compromised`), active suite id, secret count, team folder membership count and whether an emergency contact is set. Metadata only. +- A new admin settings section "Members" shows that list with a status filter and search. Each row offers "Offboard" and "Revoke suite", which prefill the existing offboarding and encryption suite sections. The suite id no longer has to be typed by hand. + +## Capabilities + +### New Capabilities + +- `admin-member-overview`: an administrator lists which users have set up a vault, filters by vault status, and starts offboarding or suite revocation from the list. + +### Modified Capabilities + +- `team-folder-sharing`: offboarding removes the leaver's direct team folder memberships, reports remaining group coverage, and the fan-out never re-shares to a disabled account. + +## Impact + +- **Backend**: `TeamFolderOffboardingService` gains the membership-removal step; `TeamFolderMembershipResolver::eligibleRecipients()` skips disabled accounts; a new `MemberOverviewService` and `MemberOverviewController` serve `GET /api/v1/admin/members`; the `TEAM_FOLDER_OFFBOARDED` audit whitelist gains two keys. +- **Frontend**: a new `MemberOverviewSection.vue` in the admin settings; `OffboardingSection.vue` and `AdminSuiteSection.vue` accept a prefilled user or suite from the list. +- **Database**: none. Rows are deleted from the existing `keepiq_team_folder_members` table; no new column or table. +- **Security**: the list endpoint is admin only and returns metadata only, never a certificate, private key blob or ciphertext. Removing membership rows narrows access; it never widens it. +- **Cross-app**: none. Issue #37 asks for a list of vault users for the share picker; this change serves the administrator list only, and the share picker variant stays with #37. diff --git a/openspec/changes/admin-member-overview-and-offboarding/specs/admin-member-overview/spec.md b/openspec/changes/admin-member-overview-and-offboarding/specs/admin-member-overview/spec.md new file mode 100644 index 000000000..8843f350f --- /dev/null +++ b/openspec/changes/admin-member-overview-and-offboarding/specs/admin-member-overview/spec.md @@ -0,0 +1,50 @@ +## ADDED Requirements + +### Requirement: Administrator lists vault status per user + +The system MUST let an administrator list every Nextcloud user with their vault status through `GET /api/v1/admin/members`. Each row MUST carry the user id, display name, whether the account is enabled, the vault status (`none`, `active`, `revoked` or `compromised`), the active suite id when there is one, the secret count, the direct team folder membership count and whether an emergency contact is set. The endpoint MUST be guarded by `#[AuthorizedAdminSetting(AdminSettings::class)]` and MUST support paging, a status filter and a search on user id or display name. + +#### Scenario: Administrator sees who has not set up a vault + +- **GIVEN** user `alice` has an active encryption suite and user `bob` has never unlocked keepiq +- **WHEN** an administrator calls `GET /api/v1/admin/members?status=none` +- **THEN** the response MUST list `bob` with `vaultStatus` `none` and no `activeSuiteId` +- **AND** the response MUST NOT list `alice` + +#### Scenario: Administrator reads the active suite id + +- **GIVEN** user `alice` has an active encryption suite +- **WHEN** an administrator calls `GET /api/v1/admin/members?search=alice` +- **THEN** the row for `alice` MUST carry `vaultStatus` `active` and her active suite id + +#### Scenario: A non-administrator is refused + +- **GIVEN** an authenticated user who is not an administrator and holds no delegation for the keepiq admin settings +- **WHEN** they call `GET /api/v1/admin/members` +- **THEN** Nextcloud MUST refuse the request before the controller runs + +### Requirement: Member overview returns metadata only + +The member overview MUST return identifiers, statuses, counts and dates only. It MUST NOT return a certificate, a private key blob, a secret name or any ciphertext. + +#### Scenario: No key material in the list + +- **GIVEN** a page of users with active suites and secrets +- **WHEN** an administrator calls `GET /api/v1/admin/members` +- **THEN** no row MUST contain a `certificate`, `privateKey`, `key`, `login` or `additionalFields` field + +### Requirement: Administrator acts on a member row + +The admin settings MUST show the member overview in a "Members" section with a status filter and a search field. Each row MUST offer "Offboard", which prefills the leaving user in the team offboarding section, and "Revoke suite", which prefills the suite id in the encryption suites section, when the user has an active suite. + +#### Scenario: Revoke a suite without typing its id + +- **GIVEN** an administrator on the Keepiq admin settings page and user `alice` with an active suite +- **WHEN** they choose "Revoke suite" on the `alice` row of the "Members" section +- **THEN** the "Encryption suites" section MUST show `alice`'s active suite id in its suite id field + +#### Scenario: Start offboarding from the list + +- **GIVEN** an administrator on the Keepiq admin settings page +- **WHEN** they choose "Offboard" on the `bob` row of the "Members" section +- **THEN** the "Team offboarding" section MUST show `bob` as the leaving user diff --git a/openspec/changes/admin-member-overview-and-offboarding/specs/team-folder-sharing/spec.md b/openspec/changes/admin-member-overview-and-offboarding/specs/team-folder-sharing/spec.md new file mode 100644 index 000000000..65145f007 --- /dev/null +++ b/openspec/changes/admin-member-overview-and-offboarding/specs/team-folder-sharing/spec.md @@ -0,0 +1,31 @@ +## ADDED Requirements + +### Requirement: Offboarding removes the leaver's direct team folder memberships + +The admin offboarding action (`POST /api/v1/team-folders/offboard`) MUST, after revoking derived shares and transferring owned team secrets, delete every direct `user` membership row of the leaving user in every team folder. It MUST NOT delete a group membership row. The response MUST report the number of removed rows as `membershipsRemoved` and MUST list each group row that still covers the leaver as `stillCoveredByGroups`. The `TEAM_FOLDER_OFFBOARDED` audit event MUST carry the removed row count and the covering group ids, and no key material. + +#### Scenario: Direct membership rows are removed + +- **GIVEN** leaving user `carol` is a direct member of team folders `Finance` and `Ops`, and successor `dave` +- **WHEN** an administrator runs the offboarding action for `carol` with successor `dave` +- **THEN** no `user` membership row for `carol` MUST remain in `keepiq_team_folder_members` +- **AND** the response MUST report `membershipsRemoved` as 2 + +#### Scenario: Group coverage is reported, not deleted + +- **GIVEN** leaving user `carol` is also covered by group `finance-team`, which is a member of team folder `Finance` +- **WHEN** an administrator runs the offboarding action for `carol` +- **THEN** the `finance-team` membership row MUST remain +- **AND** the response MUST list `Finance` with group `finance-team` under `stillCoveredByGroups` +- **AND** the "Team offboarding" section MUST show that group as a warning + +### Requirement: The fan-out never re-shares to a disabled account + +The team folder reconcile (`GET /api/v1/team-folders/{id}/reconcile`) MUST exclude every user whose Nextcloud account is disabled from the recipients and from the missing pairs, even when a membership row still covers that user. + +#### Scenario: Disabled leaver in a member group gets no new copy + +- **GIVEN** user `carol` is disabled in Nextcloud, still has an active suite, and is covered by group `finance-team` on team folder `Finance` +- **WHEN** the owner of `Finance` opens the team folder dialog and the reconcile runs +- **THEN** the missing pairs MUST NOT contain `carol` +- **AND** the fan-out MUST create no share for `carol` diff --git a/openspec/changes/admin-member-overview-and-offboarding/tasks.md b/openspec/changes/admin-member-overview-and-offboarding/tasks.md new file mode 100644 index 000000000..711aaf1e0 --- /dev/null +++ b/openspec/changes/admin-member-overview-and-offboarding/tasks.md @@ -0,0 +1,29 @@ +## 1. Offboarding removes memberships + +- [ ] 1.1 Add step three to `TeamFolderOffboardingService::offboard()`: delete every row from `findUserMemberships($leavingUserId)` after the transfer and return `membershipsRemoved`. Verify with a PHPUnit test in `tests/Unit/Service/TeamFolderOffboardingServiceTest.php` that asserts the rows are deleted and group rows are kept. +- [ ] 1.2 Return `stillCoveredByGroups` (team folder id and group id per remaining group row from `membershipRowsForUser()`). Verify with a PHPUnit test for a leaver covered by one direct row and one group row. +- [ ] 1.3 Widen the `TEAM_FOLDER_OFFBOARDED` whitelist in `lib/Event/Audit/AuditEventTypes.php` with `membershipsRemovedCount` and `coveringGroupIds`, and emit them from `TeamFolderAuditor::offboarded()`. Verify with a PHPUnit test on the dispatched metadata. +- [ ] 1.4 Make `TeamFolderMembershipResolver::eligibleRecipients()` skip disabled Nextcloud accounts. Verify with a PHPUnit test where a disabled user with an active suite is absent from `reconcile()` missing pairs. +- [ ] 1.5 Show `membershipsRemoved` and the covering groups in the `OffboardingSection.vue` summary. Verify with a vitest for the summary text in `tests/components/`. + +## 2. Member overview endpoint + +- [ ] 2.1 Add `MemberOverviewService` that pages users through `IUserManager::search()` and resolves suite status, secret count, membership count and emergency contact per page. Verify with a PHPUnit test that one page issues one suite query through `findActiveByOwners()`. +- [ ] 2.2 Add `MemberOverviewController::index()` with `#[AuthorizedAdminSetting(AdminSettings::class)]` and register `GET /api/v1/admin/members` in `appinfo/routes.php` before the SPA catch-all. Verify with a PHPUnit test for status and search filters and the route-auth and route-reachability hydra gates. +- [ ] 2.3 Assert that no row carries `certificate`, `privateKey` or any ciphertext field. Verify with a PHPUnit test over the serialized rows. + +## 3. Admin UI + +- [ ] 3.1 Add `MemberOverviewSection.vue` (`CnSettingsSection`, `CnDataTable`, `NcSelect` status filter with `inputLabel`, search) and mount it in `src/views/settings/Settings.vue`. Verify with a vitest in `tests/components/` for filter and paging. +- [ ] 3.2 Add the row actions "Offboard" and "Revoke suite" that prefill `OffboardingSection.vue` and `AdminSuiteSection.vue` through a shared store. Verify with a vitest that the prefilled values reach both sections. +- [ ] 3.3 Replace the two free-text user id fields in `OffboardingSection.vue` with user pickers fed by the member endpoint. Verify with a vitest and the nc-input-labels hydra gate. +- [ ] 3.4 Cover the flow end to end. Verify with a Playwright test in `tests/e2e/workflows/` where an administrator filters on `none`, then offboards a user from the list and sees the removed membership count. + +## Acceptance criteria + +- Offboarding a leaver with a direct team folder membership leaves no `user` row for them in `keepiq_team_folder_members`. +- Group rows that cover the leaver stay in place and are named in the offboarding result. +- A disabled Nextcloud account never appears in the missing pairs of a team folder reconcile. +- `GET /api/v1/admin/members` returns each user's vault status and active suite id to an administrator, and refuses a non-administrator. +- No response of the member endpoint contains a certificate, private key blob or ciphertext. +- An administrator can start offboarding and suite revocation from a list row without typing an id. diff --git a/openspec/changes/admin-public-api/.openspec.yaml b/openspec/changes/admin-public-api/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/admin-public-api/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/admin-public-api/design.md b/openspec/changes/admin-public-api/design.md new file mode 100644 index 000000000..96192d602 --- /dev/null +++ b/openspec/changes/admin-public-api/design.md @@ -0,0 +1,90 @@ +# Design: public admin API + +## Context + +Read at development `4c214a9d`. + +- `appinfo/routes.php:27` and `:28` serve the admin settings at `/api/settings/admin`, outside the `/api/v1/` prefix; the application admin routes sit at `:271` to `:286` (`/api/v1/applications*`); audit at `:377` to `:379`; compliance at `:195` to `:199`; SIEM sinks at `:203` to `:207`; suites at `:35` to `:43`. +- The only documented, versioned contract is the machine API under `/api/v1/app/*` (`appinfo/routes.php:295` to `:321`), with a discovery document and a rule that breaking changes ship as a new version (`openspec/specs/secret-store-api/spec.md`, "Machine API Discovery Document"). +- `lib/Controller/AuditController.php:180` `index()` and the settings methods are guarded by `#[AuthorizedAdminSetting(AdminSettings::class)]`. Other admin checks are inline `isAdmin()` calls (see change `admin-scoped-roles`). +- Nextcloud's `Request::passesCSRFCheck()` accepts a request carrying an `OCS-APIRequest` header when no session cookie is present (`server/lib/private/AppFramework/Http/Request.php:436`). An app password over HTTP Basic plus that header therefore reaches a regular controller with CSRF protection on. +- `tests/integration/machine-secret-api.postman_collection.json` and `run-newman.sh` already run in CI (`.github/workflows/code-quality.yml:159`, `enable-newman: true`). +- `docs/` is a Docusaurus site with `docs/tutorials/admin/`. + +## Goals / Non-Goals + +**Goals:** + +- One stable, documented path per admin job a script needs. +- No new credential type: Nextcloud app passwords, scoped by the account's admin areas. +- The document and the routes cannot drift apart unnoticed. + +**Non-Goals:** + +- Suite force revocation over the API (see D4). +- User provisioning. Nextcloud's own provisioning API and SCIM apps create users; Keepiq has no user store. +- Reading secrets, certificates or key material. The admin API is metadata only, like the admin screens. +- Moving the admin screens to the new paths in this change. + +## Decisions + +### D1: A new `/api/v1/admin/` prefix with thin controllers + +v1 endpoints: + +| Method and path | Area | Service | +|---|---|---| +| `GET /api/v1/admin` | any area | index: `apiVersion`, paths | +| `GET /api/v1/admin/members` | People | member overview (change `admin-member-overview-and-offboarding`) | +| `POST /api/v1/admin/offboarding` | People | `TeamFolderOffboardingService::offboard()` | +| `GET /api/v1/admin/suites`, `POST /api/v1/admin/suites/{id}/reinstate` | People | `EncryptionSuiteService` | +| `GET`, `PUT /api/v1/admin/policies` | Policies | `AdminSettingsService` | +| `GET /api/v1/admin/applications`, `POST .../{id}/approve`, `POST .../{id}/reject`, `DELETE .../{id}` | Applications | `ApplicationService` | +| `GET /api/v1/admin/audit` | Audit | `AuditService` | +| `GET`, `POST /api/v1/admin/compliance/reports`, `GET .../{id}` | Audit | `ComplianceReportService` | +| `GET`, `POST`, `PUT`, `DELETE /api/v1/admin/siem/sinks` | Audit | `SiemSinkService` | + +Controllers live in `lib/Controller/Admin/` and hold no logic beyond parameter mapping, so the screen and the API share one code path. Responses use the same shapes and error envelope as the existing endpoints (org ADR-050). + +Alternative considered: document the existing internal routes as the public API. Rejected: their paths are inconsistent (`/api/settings/admin` next to `/api/v1/...`) and some mix owner and admin behaviour behind one path, so freezing them would freeze that. + +Alternative considered: OCS controllers with Nextcloud's openapi-extractor. Rejected: every other Keepiq endpoint uses the ADR-050 envelope; an OCS envelope for admin only would give scripts two response shapes. + +### D2: Nextcloud credentials, scoped by admin area + +A script authenticates as a Nextcloud user: a session, or an app password over HTTP Basic with `OCS-APIRequest: true`. The guard on each endpoint is one admin area (change `admin-scoped-roles`), so the recommended setup is a service account in a group that holds only the needed areas, with one app password per integration. Revoking the app password in the account's security settings cuts the integration off. + +Alternative considered: admin tokens as Keepiq applications with admin scopes over the RFC 7523 flow. Rejected: an application is a vault owner with its own suite; making it an admin principal mixes two roles and adds a second admin credential store to secure. + +### D3: The document is checked in and contract-tested + +`docs/api/admin-v1.openapi.json` (OpenAPI 3.1) describes every v1 path, parameter, response and the auth scheme. A PHPUnit test parses `appinfo/routes.php` and the document and fails when a `/api/v1/admin` route is missing from the document or the other way round. A Newman collection `tests/integration/admin-api.postman_collection.json` runs every endpoint against the CI instance, including a refusal for a user outside the area. The docs site renders the document on an "Admin API" page. + +### D4: No force revocation over the API + +`POST /api/v1/suites/{id}/force-revoke` carries `#[PasswordConfirmationRequired]` (ADR-005): the administrator re-confirms their own password at that moment. A stored app password cannot give that proof, so the API leaves force revocation out and the index says so. Reinstatement has no such guard and is in. + +### D5: v1 only grows + +Additive fields and endpoints may land in v1. Removing or renaming a field, or changing a status code, ships as `/api/v2/admin/` next to v1, with v1 kept for at least one minor release and marked with a `Sunset` header. The index lists every served version. + +## Security and zero-knowledge + +- The server never holds plaintext in any admin flow, and the admin API adds none. It returns identifiers, statuses, counts, dates and settings. +- Stored encrypted versus plain: nothing new is stored. App passwords are Nextcloud's, hashed by Nextcloud. +- Every endpoint runs Nextcloud's admin area guard before the controller. CSRF stays on, so a logged-in browser cannot be tricked into an admin call from another site. +- Rate limiting uses `#[UserRateLimit]` on the write endpoints so a leaked app password cannot hammer them. + +## Risks / Trade-offs + +- Two paths serve the same admin action until the screens move over. Both call one service, so behaviour cannot differ. +- A service account with an app password is a standing credential. The docs page tells administrators to hold it in a secret store, ideally Keepiq's own machine API. +- The document is hand-written. The contract test catches missing paths, not wrong field types; the Newman collection covers the shapes. + +## Seed data + +None in the app. The Newman collection creates its own service account and delegation through `tests/e2e/ci-seed.sh` before it runs. + +## Migration + +None. No table or column; new routes only. `` in `appinfo/info.xml` does not need a bump for schema reasons. diff --git a/openspec/changes/admin-public-api/proposal.md b/openspec/changes/admin-public-api/proposal.md new file mode 100644 index 000000000..33b7a3332 --- /dev/null +++ b/openspec/changes/admin-public-api/proposal.md @@ -0,0 +1,54 @@ +--- +kind: code +--- + +# Public admin API + +## Why + +The admin screens call internal routes with unstable paths and no documentation. A script can reach them with an app password, but nothing promises they stay the same. Organisations that automate onboarding, offboarding and audits need a documented, versioned admin API. + +| Row | Capability | What keepiq does today | +|---|---|---| +| admin-13 | Manage the organisation through a public admin API | The admin screens call internal REST routes that a script could reach with a Nextcloud app password, but there is no documented, versioned admin API or scoped admin token. | + +Matrix: keepiq `openspec/parity/capabilities.json` + +### Demand + +No demand row. + +### Competitors rated yes + +- Bitwarden: "bitwarden/server@v2026.9.1 src/Api/AdminConsole/Public/Controllers/MembersController.cs, GroupsController.cs, CollectionsController.cs, PoliciesController.cs, OrganizationController.cs:48 import; src/Api/Dirt/Public/Controllers/EventsController.cs Note: Public REST API (organisation API key) for members, groups, collections, policies, import and events." +- Passbolt: "passbolt/passbolt_api@v5.16.0 config/routes.php:133-155 /groups CRUD, users, permissions and share routes; plugins/PassboltEe/AuditLog action log routes; plugins/PassboltCe/JwtAuthentication/config/routes.php:36 JWT login for scripted admin access Note: Everything the admin UI does goes through a documented JSON API that an admin account can script." +- Keeper: "https://docs.keeper.io/enterprise-guide/developer-tools : Commander CLI and Python SDK manage the enterprise (users, roles, teams, reports); SCIM API for provisioning" +- HashiCorp Vault: "hashicorp/vault@v2.1.1 vault/logical_system_paths.go:2899 sys/internal/specs/openapi documents every admin path; api/ Go client Note: Every admin operation is an HTTP API call; the UI and CLI are clients of it." + +## What Changes + +- A versioned admin API under `/api/v1/admin/`, with an index at `GET /api/v1/admin` that returns the API version and every path. +- v1 covers members, offboarding, policies, applications, audit events, compliance reports, SIEM sinks and suite listing and reinstatement. Each endpoint delegates to the service the admin screen already uses. +- Authentication is Nextcloud's own: a browser session, or a Nextcloud app password over HTTP Basic with the `OCS-APIRequest: true` header. A scoped admin token is an app password of a service account whose group holds only the Keepiq admin areas it needs (change `admin-scoped-roles`). +- Every endpoint is guarded by one admin area, so a token can do exactly what its account may do. +- Suite force revocation stays out of the API. It needs a fresh password confirmation that a stored token cannot give. +- An OpenAPI 3.1 document at `docs/api/admin-v1.openapi.json`, a contract test that keeps it equal to `appinfo/routes.php`, a Newman collection, and a docs page. +- A versioning rule: v1 only grows; a breaking change ships as v2 next to v1. + +## Capabilities + +### New Capabilities + +- `admin-api`: a documented, versioned HTTP API for Keepiq administration, authenticated with Nextcloud credentials and scoped by admin area. + +### Modified Capabilities + +None. + +## Impact + +- **Backend**: new thin controllers under `lib/Controller/Admin/` that call `AdminSettingsService`, `ApplicationService`, `AuditService`, `ComplianceReportService`, `SiemSinkService`, `EncryptionSuiteService`, `TeamFolderOffboardingService` and the member overview service; new routes in `appinfo/routes.php` before the SPA catch-all. +- **Frontend**: none required. The admin screens may move to the new paths later; the old routes stay. +- **Database**: none. +- **Security**: no new credential type. The API returns metadata only, never a private key blob, a secret value or ciphertext. CSRF protection stays on; script clients pass it with the `OCS-APIRequest` header as Nextcloud clients do. +- **Cross-app**: the Terraform provider (change `apps-terraform-provider`) uses this API for application resources. diff --git a/openspec/changes/admin-public-api/specs/admin-api/spec.md b/openspec/changes/admin-public-api/specs/admin-api/spec.md new file mode 100644 index 000000000..c7cd6aa18 --- /dev/null +++ b/openspec/changes/admin-public-api/specs/admin-api/spec.md @@ -0,0 +1,65 @@ +## ADDED Requirements + +### Requirement: Versioned admin API index + +The system MUST serve `GET /api/v1/admin` to any user holding at least one Keepiq admin area. The response MUST contain `apiVersion`, the list of served admin API versions and every v1 path with its method. A breaking change to a path, field or status code MUST ship as a new version next to the old one, never as a change to v1. + +#### Scenario: Script discovers the admin API + +- **GIVEN** a service account in a group delegated the "Audit and compliance" area, with a Nextcloud app password +- **WHEN** a script calls `GET /api/v1/admin` with HTTP Basic auth and the header `OCS-APIRequest: true` +- **THEN** the response MUST contain `apiVersion` `1` and the paths of the v1 admin endpoints + +### Requirement: Admin API authenticates with Nextcloud credentials and honours admin areas + +Every admin API endpoint MUST accept a Nextcloud session or a Nextcloud app password over HTTP Basic, and MUST be guarded by exactly one Keepiq admin area. A caller outside that area MUST be refused before the controller runs. CSRF protection MUST stay enabled. + +#### Scenario: Audit token cannot change policies + +- **GIVEN** a service account whose group holds only the "Audit and compliance" area +- **WHEN** a script calls `PUT /api/v1/admin/policies` with its app password +- **THEN** the response MUST be a refusal and no setting MUST change + +#### Scenario: People token offboards a leaver + +- **GIVEN** a service account whose group holds the "People and offboarding" area, leaving user `carol` and successor `dave` +- **WHEN** a script calls `POST /api/v1/admin/offboarding` with `leavingUserId` `carol` and `successorUserId` `dave` +- **THEN** the response MUST report the revoked, transferred and removed counts, as the admin screen does + +### Requirement: Admin API covers the administration jobs + +The v1 admin API MUST offer: the member overview, offboarding, suite listing and reinstatement, reading and updating policies, listing, approving, rejecting and deleting applications, reading audit events, generating and reading compliance reports, and managing SIEM sinks. Each endpoint MUST call the same service the admin screen calls. + +#### Scenario: Script approves a pending application + +- **GIVEN** a service account whose group holds the "Applications and machine access" area and a pending application `ci-runner` +- **WHEN** a script calls `POST /api/v1/admin/applications/{id}/approve` for `ci-runner` +- **THEN** `ci-runner` MUST be approved with the service account recorded as approver +- **AND** the audit trail MUST show the approval + +### Requirement: Admin API returns metadata only + +No admin API response MUST contain a private key blob, a secret value, secret ciphertext or a SIEM sink credential in plain form. Suite force revocation MUST NOT be reachable through the admin API, because it requires a fresh password confirmation. + +#### Scenario: Suite listing carries no key material + +- **GIVEN** a service account holding the "People and offboarding" area +- **WHEN** a script calls `GET /api/v1/admin/suites` +- **THEN** each suite row MUST carry id, owner, status and dates +- **AND** no row MUST contain `privateKey` + +#### Scenario: Force revocation is not offered + +- **GIVEN** any admin API caller +- **WHEN** they read the path list from `GET /api/v1/admin` +- **THEN** the list MUST NOT contain a force revocation path + +### Requirement: Admin API is documented and contract-tested + +The system MUST ship an OpenAPI 3.1 document at `docs/api/admin-v1.openapi.json` that describes every v1 admin path. An automated test MUST fail when a `/api/v1/admin` route in `appinfo/routes.php` is missing from the document, or a documented path has no route. + +#### Scenario: Undocumented route fails the build + +- **GIVEN** a developer adds a new `/api/v1/admin` route without documenting it +- **WHEN** the PHPUnit suite runs +- **THEN** the admin API contract test MUST fail and name the undocumented route diff --git a/openspec/changes/admin-public-api/tasks.md b/openspec/changes/admin-public-api/tasks.md new file mode 100644 index 000000000..75fcbf820 --- /dev/null +++ b/openspec/changes/admin-public-api/tasks.md @@ -0,0 +1,27 @@ +## 1. Endpoints + +- [ ] 1.1 Add `lib/Controller/Admin/AdminIndexController.php` with `GET /api/v1/admin` returning `apiVersion`, the served versions and every path. Verify with a PHPUnit test for the payload and the route-reachability hydra gate. +- [ ] 1.2 Add the People endpoints: members, offboarding, suite list and reinstate, each guarded by the People area. Verify with PHPUnit tests for success and for a refused Audit-only user. +- [ ] 1.3 Add the Policies endpoints (`GET`, `PUT /api/v1/admin/policies`) on `AdminSettingsService`. Verify with PHPUnit tests that validation errors match the admin screen's errors. +- [ ] 1.4 Add the Applications endpoints (list, approve, reject, delete). Verify with PHPUnit tests for each status change and the no-admin-idor hydra gate. +- [ ] 1.5 Add the Audit endpoints (audit events, compliance reports, SIEM sinks). Verify with PHPUnit tests, including that no response carries a SIEM sink secret in plain form. +- [ ] 1.6 Add `#[UserRateLimit]` to every write endpoint and leave force revocation out. Verify with a PHPUnit test that no `/api/v1/admin` route maps to `forceRevoke`. + +## 2. Contract + +- [ ] 2.1 Write `docs/api/admin-v1.openapi.json` for every v1 path, including HTTP Basic with the `OCS-APIRequest` header. Verify with an OpenAPI 3.1 schema lint in CI. +- [ ] 2.2 Add `tests/Unit/Contract/AdminApiContractTest.php` that compares the document with `appinfo/routes.php`. Verify by removing one path locally and watching the test fail. +- [ ] 2.3 Add `tests/integration/admin-api.postman_collection.json` and its seed step (service account, delegation, app password). Verify with `tests/integration/run-newman.sh` in the CI Newman job. + +## 3. Documentation + +- [ ] 3.1 Add an "Admin API" page under `docs/tutorials/admin/` that renders the document and explains the service account setup and the versioning rule. Verify with the docs build (`npm run build` in `docs/`). + +## Acceptance criteria + +- `GET /api/v1/admin` returns `apiVersion` `1` and lists every v1 path. +- A service account whose group holds only the Audit area can read audit events with an app password and is refused `PUT /api/v1/admin/policies`. +- A script can approve a pending application and offboard a leaver without touching the web interface. +- No admin API response contains a private key, a certificate private part, a secret value or ciphertext. +- The contract test fails when a v1 route and the OpenAPI document disagree. +- Force revocation is not reachable through `/api/v1/admin`. diff --git a/openspec/changes/admin-scoped-roles/.openspec.yaml b/openspec/changes/admin-scoped-roles/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/admin-scoped-roles/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/admin-scoped-roles/design.md b/openspec/changes/admin-scoped-roles/design.md new file mode 100644 index 000000000..d36d2212e --- /dev/null +++ b/openspec/changes/admin-scoped-roles/design.md @@ -0,0 +1,87 @@ +# Design: admin scoped roles + +## Context + +Read at development `4c214a9d`. + +- `lib/Settings/AdminSettings.php:32` extends OpenRegister's AppHost `GenericAdminSettings`, which implements `IDelegatedSettings` with `getName()` returning null (`openregister/lib/AppHost/Settings/GenericAdminSettings.php:48` and `:123`). Nextcloud can therefore delegate the whole Keepiq section, and nothing smaller. +- `appinfo/info.xml` registers one `` class and one ``. +- Nextcloud's `SecurityMiddleware` lets a request through `#[AuthorizedAdminSetting]` when the user is an admin or belongs to a group delegated that settings class (`server/lib/private/AppFramework/Middleware/Security/SecurityMiddleware.php:141` to `:156`). The attribute takes one class. +- `OCP\Settings\IManager::getAllowedAdminSettings(string $section, IUser $user)` (since 23) returns the settings a user may see, including delegated ones. +- 14 methods carry `#[AuthorizedAdminSetting(AdminSettings::class)]`: `SettingsController` (6), `CACertificateController` (5), `EncryptionSuiteController` (2, including `forceRevoke()`), `AuditController` (1). +- Inline admin checks with `IGroupManager::isAdmin()` sit in `ApplicationController`, `ApplicationRequestAdminController`, `CertificateController:80`, `ComplianceReportController:69`, `DashboardController:84`, `HoneyController:81`, `LeaseAdminController:152`, `SecretTypeController`, `SiemSinkController:71`, and in `ApplicationService`, `SettingsService` and `TeamFolderOffboardingService:140`. +- `vault_admin` is hard-coded in `lib/Service/DelegationAuthorizer.php:49` (admin handover) and `lib/Service/TeamFolderOffboardingService.php:45` (offboarding). `lib/Controller/DelegationController.php:203` `capabilities()` returns `isVaultAdmin` for `src/components/share/AdminHandoverPanel.vue`. +- `src/views/settings/Settings.vue:16` to `:30` renders every admin section in one list. + +## Goals / Non-Goals + +**Goals:** + +- A person can be given only the Keepiq admin areas they need. +- One authorisation model for endpoint guards, service checks and in-app panels. +- No second role store next to Nextcloud's. + +**Non-Goals:** + +- Per-action permissions below the area level. Five areas cover the jobs the competitors name (policies, members, audit, applications); an area can be split later without a schema change. +- Scoping a role to a subset of users or groups, like Keeper nodes. Keepiq has no organisational tree. +- A Keepiq-owned role editor. Nextcloud's "Administration privileges" page already edits delegations. + +## Decisions + +### D1: A role is a Nextcloud group delegated one or more Keepiq areas + +Keepiq registers five settings classes, each implementing `IDelegatedSettings` with a translated `getName()`: + +| Class | Area | Sections | +|---|---|---| +| `AdminSettings` | General | version, CA health and actions, attachment limits, offline cache, breach check, secret types | +| `PolicyAdminSettings` | Policies | master password, org password, rotation, session timeout, vault policies | +| `ApplicationAdminSettings` | Applications and machine access | application queue, application requests, machine leases | +| `PeopleAdminSettings` | People and offboarding | members, team offboarding, encryption suites, admin handover | +| `AuditAdminSettings` | Audit and compliance | audit log, compliance, SIEM sinks, honey alerts | + +`AdminSettings` keeps its class name, so its existing delegations keep meaning something. Each class returns the Keepiq section from `getSection()` and an ascending priority, so a full administrator sees the page in today's order. + +Alternative considered: Keepiq role tables with a permission list per role, a role editor and a middleware. Rejected: it duplicates Nextcloud's delegation, and a non-admin role holder could only use it through an in-app admin route, which the hydra admin-router gate forbids. + +### D2: Every admin endpoint names one area + +Each of the 14 attribute guards changes to its area class. Each inline `isAdmin()` check becomes `AdminAreaAuthorizer::holds($userId, ::class)`, which is true for instance admins and for users whose `getAllowedAdminSettings('keepiq', $user)` contains the class. Examples: `forceRevoke()` and `reinstate()` go to People; `ComplianceReportController` and `SiemSinkController` to Audit; `LeaseAdminController` and the approval routes to Applications. + +Alternative considered: keep `AdminSettings` on every endpoint and add a second check in the body. Rejected: two checks per endpoint drift apart, and the semantic-auth gate reads the attribute. + +### D3: The admin bundle renders one area per mount + +Each settings class provides `area` through `IInitialState` and returns the same template. `Settings.vue` renders only the sections listed for that area. `CnAdminSettingsShell` with the version card renders in the General area only. No DOM data attribute is read, per the initial-state gate. + +### D4: `vault_admin` becomes an alias with an end date + +`AdminAreaAuthorizer::holds()` also returns true for People when the user is in `vault_admin`. The admin settings show a notice while that group has members, asking the administrator to delegate the People area to a group instead. The alias is removed one minor release later; the removal is its own task. + +Alternative considered: a repair step that turns `vault_admin` into a delegation row. Rejected: Nextcloud offers no public API to create delegations, and writing its table directly bypasses its checks. + +### D5: The in-app handover asks the same question + +`DelegationController::capabilities()` returns `canHandover` from `holds($userId, PeopleAdminSettings::class)`. `DelegationAuthorizer::requireVaultAdmin()` and `TeamFolderOffboardingService::assertOffboardingAdmin()` call the same method, so the button and the enforcement can never disagree. + +## Security and zero-knowledge + +- No area grants any access to plaintext or keys. Keepiq administration never had it: the server holds no usable private key (ADR-003), and ADR-005 force revocation works without one. That stays true for every area. +- Stored plain: nothing new in Keepiq. Delegations live in Nextcloud's `authorized_groups` table. +- The guard runs in Nextcloud's middleware before any controller body. A delegated user outside an area gets the same refusal a non-admin gets today. +- `#[PasswordConfirmationRequired]` on `forceRevoke()` stays, so a People holder still re-confirms their own password. + +## Risks / Trade-offs + +- Five areas are coarser than Bitwarden's thirteen flags. The area table is the unit a later change can split. +- An existing delegation of `AdminSettings` shrinks from the whole section to the General area. The release note tells administrators to delegate the other four areas to the same group if they want the old scope. +- Moving 14 guards and the inline checks in 12 files touches many controllers. Each move is small and covered by a guard test. + +## Seed data + +None. Delegations are made on Nextcloud's own page. PHPUnit tests mock `IManager::getAllowedAdminSettings()`; the Playwright test creates a group and a delegation through `occ` in `tests/e2e/ci-seed.sh`. + +## Migration + +No table or column. `appinfo/info.xml` gains four `` entries; bump `` so existing installs pick up the new settings classes on upgrade. diff --git a/openspec/changes/admin-scoped-roles/proposal.md b/openspec/changes/admin-scoped-roles/proposal.md new file mode 100644 index 000000000..a4451e558 --- /dev/null +++ b/openspec/changes/admin-scoped-roles/proposal.md @@ -0,0 +1,57 @@ +--- +kind: code +--- + +# Admin scoped roles + +## Why + +Keepiq administration is all or nothing: a person either gets the whole Keepiq admin section or nothing, plus a hard-coded `vault_admin` group for offboarding and handover. An organisation cannot give a helpdesk only offboarding, or an auditor only the audit log. + +| Row | Capability | What keepiq does today | +|---|---|---| +| admin-11 | Hand out admin roles with only the permissions a person needs | There are two coarse levers: Nextcloud's delegation of the whole Keepiq admin section, and a hard-coded vault_admin group that unlocks offboarding and admin handover. The handover now has a route, a controller call and a UI panel (f13ad8e6, closing #184), so both levers work end to end. There is still no role editor and no per-permission role, so a person cannot be given only the permissions they need: partial. | + +Matrix: keepiq `openspec/parity/capabilities.json` + +### Demand + +No demand row. + +### Competitors rated yes + +- Bitwarden: "bitwarden/server@v2026.9.1 src/Core/AdminConsole/Enums/OrganizationUserType.cs:5 Owner, :6 Admin, :7 User, :9 Custom; src/Core/AdminConsole/Models/Data/Permissions.cs:8 13 granular permission flags (event logs, import/export, reports, collections, groups, users, policies, SSO, SCIM, account recovery); ... Note: Custom role with 13 granular permissions besides owner and admin." +- 1Password: "https://support.1password.com/custom-groups/ : custom groups with chosen administrative permissions (Business)" +- Keeper: "https://docs.keeper.io/enterprise-guide/delegated-administration : administrative permissions granted per role and scoped to nodes" +- HashiCorp Vault: "hashicorp/vault@v2.1.1 vault/policy.go:25 fine-grained capabilities per path; ui/app/components/policy-form.ts:170 policy editor; ui/app/router.js access.namespaces (Enterprise namespaces for delegated admins) ..." + +### Missing half + +admin-11 is partial. Built: Nextcloud delegation of the whole Keepiq admin section, and the `vault_admin` group for offboarding and admin handover. Missing: named admin roles with a chosen set of permissions. + +## What Changes + +- The Keepiq admin settings split into five delegable areas, each its own Nextcloud admin settings class with a name: General, Policies, Applications and machine access, People and offboarding, Audit and compliance. +- A role is a Nextcloud group. An administrator creates a group such as "Keepiq helpdesk" and delegates the areas it needs on Nextcloud's "Administration privileges" page. That page is the role editor. +- Every Keepiq admin endpoint names exactly one area in its `#[AuthorizedAdminSetting]` guard. The inline `isAdmin()` checks in nine controllers and three services move to the same area model. +- A delegated user sees only the sections of the areas they hold. +- The in-app admin handover and the offboarding action check the People and offboarding area. The `vault_admin` group keeps working as an alias for that area for one release, then is removed. +- The Keepiq admin settings show which areas exist and what each one covers. + +## Capabilities + +### New Capabilities + +- `admin-scoped-roles`: Keepiq administration split into named, delegable areas, so a Nextcloud group can hold only the areas a person needs. + +### Modified Capabilities + +None. + +## Impact + +- **Backend**: five settings classes under `lib/Settings/` implementing `IDelegatedSettings`; an `AdminAreaAuthorizer` for checks inside services and in-app panels; the guards on 14 attribute-guarded methods in four controllers and the inline admin checks move to one area each; `info.xml` lists the five classes. +- **Frontend**: the admin bundle renders only the sections of the area it is mounted for, read from initial state; `AdminHandoverPanel.vue` reads the area check instead of the `vault_admin` flag. +- **Database**: none. Nextcloud stores delegations in its own table. +- **Security**: narrower grants. Instance administrators keep every area. A delegated user cannot reach an endpoint outside their areas, because Nextcloud's middleware refuses it before the controller runs. +- **Cross-app**: none. diff --git a/openspec/changes/admin-scoped-roles/specs/admin-scoped-roles/spec.md b/openspec/changes/admin-scoped-roles/specs/admin-scoped-roles/spec.md new file mode 100644 index 000000000..9fbf9bfa4 --- /dev/null +++ b/openspec/changes/admin-scoped-roles/specs/admin-scoped-roles/spec.md @@ -0,0 +1,50 @@ +## ADDED Requirements + +### Requirement: Keepiq administration is split into delegable areas + +The system MUST register five named Keepiq admin settings areas that Nextcloud can delegate separately: General, Policies, Applications and machine access, People and offboarding, and Audit and compliance. Each area MUST implement `IDelegatedSettings` with a translated name so it appears on Nextcloud's "Administration privileges" page. A role MUST be a Nextcloud group delegated one or more areas. + +#### Scenario: Administrator builds an auditor role + +- **GIVEN** an instance administrator on Nextcloud's "Administration privileges" page +- **WHEN** they delegate the Keepiq "Audit and compliance" area to group `keepiq-auditors` +- **THEN** a member of `keepiq-auditors` MUST see the audit log, compliance and SIEM sections in the Keepiq admin settings +- **AND** that member MUST NOT see the policy, application or offboarding sections + +### Requirement: Every Keepiq admin endpoint is guarded by exactly one area + +Every Keepiq endpoint that requires administration MUST be guarded by `#[AuthorizedAdminSetting]` naming exactly one area class, or by `AdminAreaAuthorizer::holds()` naming exactly one area class inside a service. Instance administrators MUST pass every guard. A user outside the area MUST be refused before the controller body runs. + +#### Scenario: Auditor cannot change policies + +- **GIVEN** a member of a group delegated only the "Audit and compliance" area +- **WHEN** they call `PUT /api/settings/admin` +- **THEN** Nextcloud MUST refuse the request and no setting MUST change + +#### Scenario: Applications holder approves an application + +- **GIVEN** a member of a group delegated only the "Applications and machine access" area and a pending application +- **WHEN** they call `POST /api/v1/applications/{id}/approve` +- **THEN** the application MUST be approved with that member recorded as approver + +#### Scenario: Instance administrator keeps every action + +- **GIVEN** an instance administrator with no Keepiq delegation +- **WHEN** they call `POST /api/v1/suites/{id}/force-revoke` after password confirmation +- **THEN** the guard MUST let the request through + +### Requirement: In-app admin actions follow the People and offboarding area + +The admin handover panel in the secret sidebar and the team offboarding action MUST be available exactly to instance administrators and holders of the "People and offboarding" area. `GET /api/v1/delegations/capabilities` MUST report `canHandover` from the same check the handover endpoint enforces. Membership of the `vault_admin` group MUST count as holding that area only until the alias is removed, and the admin settings MUST warn while that group has members. + +#### Scenario: Helpdesk member sees the handover panel + +- **GIVEN** a member of a group delegated only "People and offboarding", holding a share of a secret owned by another user +- **WHEN** they open that secret's sidebar at `/secrets/{id}` +- **THEN** the admin handover panel MUST be shown + +#### Scenario: Legacy vault_admin member is warned about + +- **GIVEN** the `vault_admin` group has one member and no area is delegated to it +- **WHEN** an instance administrator opens the Keepiq admin settings +- **THEN** the General area MUST show a notice asking to delegate the "People and offboarding" area instead diff --git a/openspec/changes/admin-scoped-roles/tasks.md b/openspec/changes/admin-scoped-roles/tasks.md new file mode 100644 index 000000000..751b7ca3f --- /dev/null +++ b/openspec/changes/admin-scoped-roles/tasks.md @@ -0,0 +1,32 @@ +## 1. Areas + +- [ ] 1.1 Add `PolicyAdminSettings`, `ApplicationAdminSettings`, `PeopleAdminSettings` and `AuditAdminSettings` under `lib/Settings/`, each implementing `IDelegatedSettings` with a translated name, the Keepiq section and an area in initial state; give `AdminSettings` its General name. Verify with a PHPUnit test per class for name, section, priority and initial state. +- [ ] 1.2 Register the four classes in `appinfo/info.xml` and bump ``. Verify manually that Nextcloud's "Administration privileges" page lists five Keepiq areas after `occ upgrade`. +- [ ] 1.3 Add `AdminAreaAuthorizer::holds()` on top of `IManager::getAllowedAdminSettings()` with the `vault_admin` alias for People. Verify with a PHPUnit test for admin, delegated user, alias member and outsider. + +## 2. Guards + +- [ ] 2.1 Move the 14 `#[AuthorizedAdminSetting(AdminSettings::class)]` guards in `SettingsController`, `CACertificateController`, `EncryptionSuiteController` and `AuditController` to their area classes. Verify with the route-auth and semantic-auth hydra gates and one guard test per controller. +- [ ] 2.2 Replace the inline `isAdmin()` checks in `ApplicationController`, `ApplicationRequestAdminController`, `LeaseAdminController` and `DashboardController` with the Applications area. Verify with PHPUnit tests where an Applications holder approves an application and an Audit holder is refused. +- [ ] 2.3 Replace the inline checks in `ComplianceReportController`, `SiemSinkController` and `HoneyController` with the Audit area, and in `CertificateController` and `SecretTypeController` with General. Verify with PHPUnit guard tests per controller. +- [ ] 2.4 Route `TeamFolderOffboardingService`, `DelegationAuthorizer` and `DelegationController::capabilities()` through `holds(PeopleAdminSettings)`. Verify with PHPUnit tests that the capabilities flag and the enforcement agree for all four user kinds. +- [ ] 2.5 Replace the admin checks in `ApplicationService` and `SettingsService` with the matching area. Verify with the no-admin-idor and unsafe-auth-resolver hydra gates. + +## 3. Frontend + +- [ ] 3.1 Render only the sections of the mounted area in `Settings.vue`, and the shell in General only. Verify with a vitest per area and the initial-state and admin-router hydra gates. +- [ ] 3.2 Switch `AdminHandoverPanel.vue` and the delegation store to `canHandover`. Verify with a vitest in `tests/store/`. +- [ ] 3.3 Add an "Admin areas" note in the General area that lists the five areas, links to "Administration privileges", and warns while `vault_admin` has members. Verify with a vitest. +- [ ] 3.4 Cover delegation end to end. Verify with a Playwright test in `tests/e2e/workflows/` where a user in a group delegated only the Audit area sees the audit sections and gets 403 from `PUT /api/settings/admin`. + +## 4. Alias removal + +- [ ] 4.1 One minor release after 1.2, remove the `vault_admin` alias and its notice. Verify with a PHPUnit test that a `vault_admin` member without a delegation is refused. + +## Acceptance criteria + +- Nextcloud's "Administration privileges" page lists five named Keepiq areas. +- A user in a group delegated only the Audit area can read the audit log and compliance reports and is refused every other Keepiq admin endpoint. +- A user in a group delegated only People can offboard, force-revoke a suite after password confirmation, and use the admin handover panel. +- An instance administrator keeps every Keepiq admin action. +- No Keepiq admin endpoint keeps an inline `isAdmin()` check or the `vault_admin` literal after task 4.1. diff --git a/openspec/changes/admin-vault-policies/.openspec.yaml b/openspec/changes/admin-vault-policies/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/admin-vault-policies/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/admin-vault-policies/design.md b/openspec/changes/admin-vault-policies/design.md new file mode 100644 index 000000000..63d0c6799 --- /dev/null +++ b/openspec/changes/admin-vault-policies/design.md @@ -0,0 +1,111 @@ +# Design: admin vault policies + +## Context + +Read at development `4c214a9d`. + +Policy area today: + +- `lib/Service/AdminSettingsService.php:132` `getAdminSettings()` and `:232` `updateAdminSettings()` read and write app config keys in validated groups. `:254` `getPolicy()` serves the user-visible policy floor through `PasswordPolicyService`. +- `lib/Service/PasswordPolicyService.php:49` lists the org password policy keys; `:142` `getPolicy()` is what `GET /api/settings/policy` returns (`lib/Controller/SettingsController.php:284`, `#[NoAdminRequired]`). +- `src/components/settings/OrgPasswordPolicySection.vue:13` renders the org password policy as a `CnSettingsSection`; `src/views/settings/Settings.vue:17` mounts it. +- `lib/Event/Audit/AuditEventTypes.php:248` whitelists `before` and `after` for `PASSWORD_POLICY_UPDATED`, the pattern a policy audit follows. + +Export today: + +- Export runs in the browser. `lib/Controller/ExportController.php` `events()` records the export mode (`encrypted-backup`, `plaintext-csv`, `cxf`, `cxp`) for the session user. +- `src/store/modules/export.js:68` `reportExport()` posts to `/api/v1/export/events`, and `exportBackup()` reports before offering the download (`:93` to `:97`): a failed report aborts the export. `exportCsv()`, `exportCxf()` and `exportCxpSealed()` follow the same order. `exportGdprPackage()` does not report an export mode. + +Unlock today: + +- `src/store/modules/session.js:61` `unlock()` fetches `GET /api/v1/suites` and unwraps `privateKey` of the active suite in the browser. The CLI (`cli/internal/client/client.go:84`) and the browser extension (`browser-extension/src/lib/api.js:85`) read the same endpoint. +- `lib/Controller/EncryptionSuiteController.php:89` `index()` and `:117` `show()` return `EncryptionSuite::jsonSerialize()`, which includes `privateKey` (`lib/Db/EncryptionSuite.php:203`). `:176` `create()` stores a first suite generated in the browser. +- `lib/Service/OfflineManifestService.php:89` puts the active suite blob in the offline snapshot. +- Nextcloud offers `OCP\Authentication\TwoFactorAuth\IRegistry::getProviderStates(IUser)` (since 14), which returns every provider id with its enabled state for a user. + +Ownership today: + +- `lib/Service/SecretService.php:239` `create()` stores any `folderId` as given; `:825` `update()` can move a secret. `lib/Controller/ImportController.php:98` creates secrets in batches. +- `lib/Service/TeamFolderQueryService.php:333` `ancestorTeamFolders()` (private) walks a folder's ancestors to the team folders above it. +- `lib/Service/TeamFolderService.php:620` `resolveGrade()` computes a member's effective `read` or `write` grade; `lib/Controller/ShareController.php:438` `writeContext()` hands a write-grade member the owner-row material for a fan-out update (`folder-permission-grades` spec). +- `lib/Repair/SeedSecretTypes.php:62` seeds the types, including `login`, `api_key` and `database`. + +## Goals / Non-Goals + +**Goals:** + +- An administrator switches each vault policy on for all users or for chosen groups. +- A blocked export fails before any file is offered, in every supported client flow. +- A user without Nextcloud two-factor login cannot unlock or create a vault while the policy applies to them. +- New work logins of an in-scope user end up in a team folder, where the organisation keeps access through the existing offboarding transfer. + +**Non-Goals:** + +- Enforcing two-factor login itself. Nextcloud's own "Enforce two-factor authentication" setting stays the tool for that. +- Detecting MFA done at an external identity provider. An administrator who relies on it scopes the policy to the groups that do not use single sign-on. +- Moving existing personal secrets on the server. The server cannot re-encrypt; the user moves them in the browser. +- Blocking the GDPR access package. It is a legal right of access and stays available. + +## Decisions + +### D1: One policy service, app config keys, group scope per policy + +`VaultPolicyService` owns seven app config keys: `vault_export_disabled`, `vault_export_disabled_groups`, `vault_require_two_factor`, `vault_require_two_factor_groups`, `vault_org_ownership`, `vault_org_ownership_groups` and `vault_org_ownership_types`. `appliesTo(policy, userId)` is true when the policy is on and the group list is empty or shares a group with the user (`IGroupManager::getUserGroupIds()`). `AdminSettingsService::updateAdminSettings()` calls a new `updateVaultPolicySettings()` group; `getPolicy()` adds the three effective booleans for the session user, so the browser knows what applies without learning the group lists. + +Alternative considered: a policy table with one row per policy. Rejected: every other keepiq setting is app config, and three switches do not need a table. + +### D2: The export ban rides the existing report-before-download order + +`ExportController::events()` returns 403 with `code: export_disabled_by_policy` when the ban applies to the session user. Because every export action reports before it offers the file, the browser aborts. `ExportDialog.vue` hides the modes up front from `getPolicy()`. The GDPR package does not call `events()` and is not affected. + +Alternative considered: a new export-token endpoint that the browser must call first. Rejected: the report already sits before the download in all four modes, so a second round trip adds nothing. + +### D3: Two-factor gating withholds the wrapped private key + +When `vault_require_two_factor` applies and `IRegistry::getProviderStates()` returns no enabled provider other than `backup_codes`, the server: + +- returns the suite list and single suite without `privateKey`, adding `unlockBlocked: "two_factor_required"`; +- leaves the suite out of the offline manifest, so an offline unlock is impossible too; the browser drops its stored snapshot on this signal; +- refuses `POST /api/v1/suites` with 403 and the same code, so no first suite is created. + +`LockScreen.vue` shows "Your organisation requires two-factor login before you can open your vault" with a link to `/settings/user/security`. The CLI and the browser extension read the same endpoint and fail with the same code. + +Alternative considered: return 403 from `GET /api/v1/suites`. Rejected: other screens read suite status and certificates from that endpoint and would break for a reason that has nothing to do with them. + +### D4: Ownership policy checks the target folder on every write path + +For an in-scope user and an in-scope type, `SecretService::create()`, `update()` (when `folderId` changes) and the import batch refuse a target folder that has no team folder owned by the user among its ancestors. The check exposes `ancestorTeamFolders()` as a public query on `TeamFolderQueryService`. The refusal is 403 with `code: org_ownership_required`. Types outside `vault_org_ownership_types` stay personal. The default types are `login`, `api_key` and `database`, the credential types a tender means by work logins. + +Alternative considered: count any folder shared with the user as organisational. Rejected: a folder the user owns but never shared is still personal, and a folder owned by someone else cannot hold the user's own secret today. + +### D5: Write-grade members contribute into a team folder + +A member who owns no team folder must still be able to comply. `POST /api/v1/team-folders/{id}/secrets` accepts a new secret from a member whose effective grade on the target folder is `write`. The member's browser encrypts the value under the folder owner's certificate (write without read, as the secret request fill already does) and under every effective member's certificate, including their own. The server stores the owner row with `owner_id` set to the folder owner, registers the derived copies through `TeamFolderShareService`, and audits the creation with the member as actor. + +Alternative considered: let each member share a personal folder as their own team folder. Rejected: the organisation then depends on every user adding the right members, and offboarding transfers only work when a successor already holds a copy. + +### D6: Personal items that break the policy are listed, not moved + +The health report gains a "Not in a team folder" list for in-scope types. Its "Move to a team folder" action changes `folderId` into an owned team folder (the fan-out then runs), or, for a non-owner, contributes through D5 and deletes the personal copy after the contribution succeeds. + +## Security and zero-knowledge + +- The server never sees a plaintext value or the master password in any of these flows. The contribution in D5 carries only ciphertext encrypted in the member's browser; the server checks the grade and the folder, never the content. +- Stored plain: the policy switches, group lists and type lists (app config). Stored encrypted: nothing new; secret fields stay RSA ciphertext as today. +- The two-factor policy withholds the AES-wrapped private key. Withholding ciphertext weakens nothing, and it is the only lever that holds for every client. +- The export ban and the ownership policy govern supported clients. A tampered browser can still read what it can decrypt; the proposal says so, as the export controller docblock already does. +- A contributor can write a wrong value into the owner's row. A `write` grade already lets that member change every copy, so D5 adds no new trust. + +## Risks / Trade-offs + +- An in-scope user without two-factor login loses vault access the moment the policy is switched on. The admin section warns with the number of in-scope users without an enabled provider before saving. +- Users who rely on identity provider MFA are blocked until an administrator scopes them out. The admin section says so next to the switch. +- The ownership policy can block a user who owns no team folder and holds no `write` grade. The error names the policy and the health report lists what to do. + +## Seed data + +None. The policies ship off. PHPUnit tests mock `IRegistry` and `IGroupManager`; the Playwright test switches a policy on through the admin settings. + +## Migration + +None. No table or column; seven app config keys with defaults read on demand. `` in `appinfo/info.xml` does not need a bump for schema reasons. diff --git a/openspec/changes/admin-vault-policies/proposal.md b/openspec/changes/admin-vault-policies/proposal.md new file mode 100644 index 000000000..f0ed68937 --- /dev/null +++ b/openspec/changes/admin-vault-policies/proposal.md @@ -0,0 +1,60 @@ +--- +kind: code +--- + +# Admin vault policies + +## Why + +An organisation cannot stop users exporting their personal vault, cannot demand two-factor login before a vault opens, and cannot require that work logins live in a team folder. This change adds those three vault policies. + +| Row | Capability | What keepiq does today | +|---|---|---| +| admin-10 | Enforce rules such as two-factor login or a ban on exporting personal vaults | Keepiq has no policy to require two-factor login or to block personal vault export. Nextcloud can enforce two-factor for the whole login, which also guards keepiq, but that is a server setting, not a vault rule. | +| admin-22 | Require that work logins are kept in the organisation's vault rather than in personal vaults | Every secret starts in the creator's personal vault; nothing forces work logins into a team folder. | + +Matrix: keepiq `openspec/parity/capabilities.json` + +### Demand + +- admin-22, tender: https://www.tenderned.nl/aankondigingen/overzicht/295007 +- admin-10: no demand row. + +### Competitors rated yes + +- Bitwarden (admin-10): "bitwarden/server@v2026.9.1 src/Core/AdminConsole/Enums/PolicyType.cs:5 TwoFactorAuthentication, :19 DisablePersonalVaultExport; bitwarden/clients@web-v2026.9.0 apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/two-factor-authentication.component.ts; ... Note: Require two-step login and remove individual vault export are among 23 policies." +- 1Password (admin-10): "https://support.1password.com/team-policies/ : 'Two-factor authentication' enforcement and further sign-in, sharing and file policies" +- Keeper (admin-10): "https://docs.keeper.io/enterprise-guide/roles/enforcement-policies : 2FA enforcement ('it cannot be disabled by the user') and Import and Export restriction ('RESTRICT_EXPORT')" +- Bitwarden (admin-22): "bitwarden/server@v2026.9.1 src/Core/AdminConsole/Enums/PolicyType.cs:10 OrganizationDataOwnership, :52 'Enforce organization data ownership'; ... apps/web/src/locales/en/messages.json:8554 'Require all items to be owned by an organization, removing the option to store items at the account level' ..." + +### Scope of admin-10 + +The decision specifies the vault half only: a policy that blocks personal vault export, and a policy that requires the user to have Nextcloud two-factor login enabled before the vault unlocks. Enforcing two-factor login itself stays with Nextcloud. + +## What Changes + +- Three vault policies in the admin settings, next to the org password policy. Each is off by default and can be scoped to Nextcloud groups (empty means every user). +- **Block personal vault export.** `POST /api/v1/export/events` refuses the encrypted backup, plaintext CSV, CXF and CXP modes for an in-scope user. The browser already aborts the download when that report fails, and the export dialog hides the blocked modes. The GDPR access package stays available. +- **Require Nextcloud two-factor login before unlock.** For an in-scope user without an enabled Nextcloud two-factor provider (backup codes do not count), the server withholds the wrapped private key from the suite endpoints and the offline manifest, and refuses to create a first suite. The lock screen explains why and links to the Nextcloud security settings. +- **Keep work logins in team folders.** For an in-scope user and in-scope secret types (default `login`, `api_key`, `database`), creating, importing or moving a secret outside a team folder subtree the user owns is refused. +- A member with a `write` grade can save a new secret straight into a team folder they do not own. Their browser encrypts the value for the folder owner and every member; the server authorises on the grade. +- In-scope users see which of their personal items break the ownership policy, with a move action. +- Every policy change is audited with a before and after snapshot. + +## Capabilities + +### New Capabilities + +- `vault-policies`: organisation-wide vault rules an administrator switches on per group: an export ban, two-factor login before unlock, and team folder ownership of work logins. + +### Modified Capabilities + +None. + +## Impact + +- **Backend**: a new `VaultPolicyService` (read, scope check, update, audit); checks in `ExportController::events()`, `EncryptionSuiteController::index()`, `show()` and `create()`, `OfflineManifestService`, `SecretService::create()` and `update()`, and `ImportController::batchCreate()`; a new `POST /api/v1/team-folders/{id}/secrets` for write-grade contributions; the policy keys join `GET /api/settings/policy`. +- **Frontend**: a new `VaultPolicySection.vue` in the admin settings; `ExportDialog.vue` hides blocked modes; `LockScreen.vue` shows the two-factor notice; the secret form restricts the folder picker; the health report lists personal items that break the ownership policy. +- **Database**: none. The policies are app config keys. +- **Security**: the export ban and the ownership policy govern the supported clients; a tampered client can still read what it can decrypt, as the export audit already states. The two-factor policy withholds ciphertext the user needs to unlock, so it holds for every client, including the CLI and the browser extension. +- **Cross-app**: none. OpenConnector and other application vaults are not users and are outside every policy. diff --git a/openspec/changes/admin-vault-policies/specs/vault-policies/spec.md b/openspec/changes/admin-vault-policies/specs/vault-policies/spec.md new file mode 100644 index 000000000..f96854df5 --- /dev/null +++ b/openspec/changes/admin-vault-policies/specs/vault-policies/spec.md @@ -0,0 +1,99 @@ +## ADDED Requirements + +### Requirement: Administrator configures vault policies per group + +The system MUST offer three vault policies in the Keepiq admin settings: a personal vault export ban, a two-factor login requirement before unlock, and team folder ownership of work logins. Each policy MUST be off by default and MUST apply either to every user or to members of administrator-chosen Nextcloud groups. Only an administrator MUST be able to change them through `PUT /api/settings/admin`. Every change MUST dispatch one audit event carrying a before and after snapshot. `GET /api/settings/policy` MUST return, for the session user, whether each policy applies to them, and MUST NOT return the group lists. + +#### Scenario: Administrator scopes the export ban to a group + +- **GIVEN** an administrator on the Keepiq admin settings page +- **WHEN** they switch on "Block personal vault export" for group `staff` in the "Vault policies" section and save +- **THEN** `GET /api/settings/policy` MUST report the export ban as applying for a member of `staff` +- **AND** it MUST report the ban as not applying for a user outside `staff` +- **AND** one policy audit event with the before and after values MUST be recorded + +### Requirement: Personal vault export can be blocked + +When the export ban applies to a user, `POST /api/v1/export/events` MUST refuse the modes `encrypted-backup`, `plaintext-csv`, `cxf` and `cxp` with 403 and code `export_disabled_by_policy`, and the browser MUST NOT offer the export file. The export dialog MUST hide the blocked modes. The GDPR access package MUST stay available. + +#### Scenario: Blocked user gets no backup file + +- **GIVEN** the export ban applies to vault owner `erin` +- **WHEN** `erin` opens the export dialog from the secret list at `/secrets` +- **THEN** the encrypted backup and CSV options MUST NOT be offered +- **AND** a direct `POST /api/v1/export/events` with mode `encrypted-backup` MUST return 403 with code `export_disabled_by_policy` + +#### Scenario: GDPR package still downloads + +- **GIVEN** the export ban applies to vault owner `erin` +- **WHEN** `erin` requests her GDPR data package from the user settings +- **THEN** the package MUST download + +### Requirement: Vault unlock requires Nextcloud two-factor login + +When the two-factor policy applies to a user and Nextcloud reports no enabled two-factor provider for them other than backup codes, the system MUST omit `privateKey` from `GET /api/v1/suites` and `GET /api/v1/suites/{id}` and MUST add `unlockBlocked` with value `two_factor_required`. It MUST leave the suite out of `GET /api/v1/offline/manifest` and MUST refuse `POST /api/v1/suites` with 403 and code `two_factor_required`. The lock screen MUST explain the reason and link to the Nextcloud security settings. Enforcing two-factor login itself MUST stay with Nextcloud. + +#### Scenario: User without two-factor login cannot unlock + +- **GIVEN** the two-factor policy applies to vault owner `frank` and `frank` has no two-factor provider enabled +- **WHEN** `frank` opens the lock screen at `/lock` and enters his master password +- **THEN** the vault MUST stay locked +- **AND** the lock screen MUST show that the organisation requires two-factor login, with a link to `/settings/user/security` +- **AND** `GET /api/v1/suites` MUST return his suite without `privateKey` + +#### Scenario: Enabling two-factor login restores access + +- **GIVEN** the two-factor policy applies to vault owner `frank` and he enables a TOTP provider in Nextcloud +- **WHEN** `frank` enters his master password on the lock screen +- **THEN** the vault MUST unlock + +#### Scenario: The CLI gets the same answer + +- **GIVEN** the two-factor policy applies to vault owner `frank`, who has no two-factor provider enabled +- **WHEN** `frank` runs `keepiq list` with an app password +- **THEN** the CLI MUST exit with an error naming `two_factor_required` + +### Requirement: Work logins are kept in team folders + +When the ownership policy applies to a user, the system MUST refuse to create, import or move a secret of an in-scope type (default `login`, `api_key` and `database`) into a folder that has no team folder owned by that user among its ancestors. The refusal MUST be 403 with code `org_ownership_required`. Secrets of other types MUST stay unaffected. + +#### Scenario: Personal login refused + +- **GIVEN** the ownership policy applies to vault owner `gina` +- **WHEN** `gina` calls `POST /api/v1/secrets` for a `login` secret in her personal folder `Private` +- **THEN** the response MUST be 403 with code `org_ownership_required` +- **AND** no secret MUST be stored + +#### Scenario: Exempt type stays personal + +- **GIVEN** the ownership policy applies to vault owner `gina` with the default types +- **WHEN** `gina` saves a `card` secret in her personal folder `Private` +- **THEN** the secret MUST be stored + +### Requirement: Write-grade members save new secrets into a team folder + +The system MUST let a member whose effective grade on a team folder is `write` create a new secret in that folder through `POST /api/v1/team-folders/{id}/secrets`. The request MUST carry the value encrypted in the member's browser under the folder owner's certificate and under every effective member's certificate. The server MUST store the owner row as owned by the folder owner, MUST register a derived copy per member, MUST audit the creation with the member as actor, and MUST NOT decrypt any blob. A member with a `read` grade and a non-member MUST be refused. + +#### Scenario: Member saves a work login into the team folder + +- **GIVEN** `hank` holds a `write` grade on team folder `Ops`, owned by `iris`, with members `hank` and `jack` +- **WHEN** `hank` saves a new `login` secret into `Ops` from the secret form +- **THEN** `iris` MUST own the stored secret +- **AND** `hank` and `jack` MUST each receive a copy they can decrypt +- **AND** the audit trail MUST show `hank` as the actor of the creation + +#### Scenario: Read-grade member is refused + +- **GIVEN** `jack` holds a `read` grade on team folder `Ops` +- **WHEN** `jack` calls `POST /api/v1/team-folders/{id}/secrets` for `Ops` +- **THEN** the response MUST be 403 and no secret MUST be stored + +### Requirement: Users see personal items that break the ownership policy + +When the ownership policy applies to a user, the health report MUST list their secrets of in-scope types that sit outside a team folder, and MUST offer a move action into a team folder they own or can contribute to. + +#### Scenario: Existing personal login is listed + +- **GIVEN** vault owner `gina` has an older `login` secret in folder `Private` and the ownership policy now applies to her +- **WHEN** `gina` opens the health report +- **THEN** the "Not in a team folder" list MUST show that secret with a "Move to a team folder" action diff --git a/openspec/changes/admin-vault-policies/tasks.md b/openspec/changes/admin-vault-policies/tasks.md new file mode 100644 index 000000000..ba64627c5 --- /dev/null +++ b/openspec/changes/admin-vault-policies/tasks.md @@ -0,0 +1,34 @@ +## 1. Policy settings + +- [ ] 1.1 Add `VaultPolicyService` with the policy keys, validation, `appliesTo()` and a `VAULT_POLICY_UPDATED` audit event (before and after snapshot, whitelisted in `AuditEventTypes`). Verify with a PHPUnit test for group scope, invalid types and the audit metadata. +- [ ] 1.2 Wire `updateVaultPolicySettings()` into `AdminSettingsService::updateAdminSettings()` and add the effective booleans for the session user to `getPolicy()`. Verify with a PHPUnit test that `getPolicy()` never returns the group lists. +- [ ] 1.3 Add `VaultPolicySection.vue` next to `OrgPasswordPolicySection.vue`, with group pickers (`NcSelect` with `inputLabel`) and the count of in-scope users without two-factor login. Verify with a vitest in `tests/components/` and the nc-input-labels hydra gate. + +## 2. Export ban + +- [ ] 2.1 Refuse `POST /api/v1/export/events` with 403 `export_disabled_by_policy` for in-scope users. Verify with a PHPUnit test in `tests/Unit/Controller/ExportControllerTest.php` for all four modes. +- [ ] 2.2 Hide the blocked modes in `ExportDialog.vue` and keep the GDPR package. Verify with a vitest that no download starts when the report is refused. + +## 3. Two-factor before unlock + +- [ ] 3.1 Withhold `privateKey` and add `unlockBlocked` in `EncryptionSuiteController::index()` and `show()` when the policy applies and no provider other than `backup_codes` is enabled. Verify with a PHPUnit test with a mocked `IRegistry`. +- [ ] 3.2 Refuse `POST /api/v1/suites` and leave the suite out of the offline manifest under the same condition. Verify with PHPUnit tests for `create()` and `OfflineManifestService`. +- [ ] 3.3 Show the two-factor notice in `LockScreen.vue` and drop the offline snapshot on `two_factor_required`. Verify with a vitest for the lock screen and the offline store. +- [ ] 3.4 Map `two_factor_required` to a clear error in the CLI (`cli/`) and the browser extension. Verify with `go test ./...` and the extension vitest suite. + +## 4. Team folder ownership + +- [ ] 4.1 Make `ancestorTeamFolders()` a public query on `TeamFolderQueryService` and check it in `SecretService::create()`, `update()` and the import batch for in-scope users and types. Verify with PHPUnit tests for create, move and import, including an exempt type. +- [ ] 4.2 Add `POST /api/v1/team-folders/{id}/secrets` for write-grade contributions: owner row plus derived copies, grade checked with `resolveGrade()`, audit with the member as actor. Verify with PHPUnit tests for a `write` member, a `read` member and a non-member, plus the no-admin-idor hydra gate. +- [ ] 4.3 Restrict the folder picker in the secret form to owned team folders and contributable team folders for in-scope types, and add the contribution flow to the secret store. Verify with a vitest for the picker and for the per-recipient encryption. +- [ ] 4.4 Add the "Not in a team folder" list with the move action to the health report. Verify with a vitest for both the owner move and the contribution move. +- [ ] 4.5 Cover the policies end to end. Verify with a Playwright test in `tests/e2e/workflows/` where an administrator switches on the export ban and a user finds the export modes gone, and where a user in scope saves a login into a team folder after being refused a personal folder. + +## Acceptance criteria + +- With the export ban on for a user, no encrypted backup, CSV, CXF or CXP file is offered to them, and the GDPR package still downloads. +- With the two-factor policy on, a user without an enabled Nextcloud two-factor provider receives no wrapped private key from any endpoint and cannot create a first suite. +- A user who enables a two-factor provider can unlock again without an administrator action. +- With the ownership policy on, an in-scope user cannot save a `login` secret outside a team folder, and can still save a `card` secret personally. +- A write-grade member can save a new secret into a team folder they do not own, and the owner and all members can read it. +- Every policy change produces one audit event with a before and after snapshot. From 1c2cf99495d8c9f7d2be4ce8e41377af25f9b6be Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 27 Sep 2026 20:18:35 +0200 Subject: [PATCH 2/3] docs(openspec): specify scheduled vault backups, Kubernetes injection, client libraries and CI, rotation runner and Terraform provider Five OpenSpec changes for the keepiq parity pass (rows admin-27, apps-17, apps-18, apps-21, apps-20, apps-25, apps-22). Specs only, no code. --- .../.openspec.yaml | 2 + .../admin-scheduled-vault-backups/design.md | 93 +++++++++++++++++++ .../admin-scheduled-vault-backups/proposal.md | 54 +++++++++++ .../specs/vault-backups/spec.md | 71 ++++++++++++++ .../admin-scheduled-vault-backups/tasks.md | 32 +++++++ .../.openspec.yaml | 2 + .../apps-client-libraries-and-ci/design.md | 79 ++++++++++++++++ .../apps-client-libraries-and-ci/proposal.md | 65 +++++++++++++ .../specs/ci-integrations/spec.md | 42 +++++++++ .../specs/client-libraries/spec.md | 53 +++++++++++ .../apps-client-libraries-and-ci/tasks.md | 28 ++++++ .../apps-kubernetes-injection/.openspec.yaml | 2 + .../apps-kubernetes-injection/design.md | 78 ++++++++++++++++ .../apps-kubernetes-injection/proposal.md | 54 +++++++++++ .../specs/kubernetes-integration/spec.md | 76 +++++++++++++++ .../apps-kubernetes-injection/tasks.md | 27 ++++++ .../.openspec.yaml | 2 + .../design.md | 90 ++++++++++++++++++ .../proposal.md | 55 +++++++++++ .../specs/secret-rotation-runner/spec.md | 77 +++++++++++++++ .../specs/secret-store-api/spec.md | 28 ++++++ .../tasks.md | 32 +++++++ .../apps-terraform-provider/.openspec.yaml | 2 + .../changes/apps-terraform-provider/design.md | 80 ++++++++++++++++ .../apps-terraform-provider/proposal.md | 55 +++++++++++ .../specs/terraform-provider/spec.md | 72 ++++++++++++++ .../changes/apps-terraform-provider/tasks.md | 26 ++++++ 27 files changed, 1277 insertions(+) create mode 100644 openspec/changes/admin-scheduled-vault-backups/.openspec.yaml create mode 100644 openspec/changes/admin-scheduled-vault-backups/design.md create mode 100644 openspec/changes/admin-scheduled-vault-backups/proposal.md create mode 100644 openspec/changes/admin-scheduled-vault-backups/specs/vault-backups/spec.md create mode 100644 openspec/changes/admin-scheduled-vault-backups/tasks.md create mode 100644 openspec/changes/apps-client-libraries-and-ci/.openspec.yaml create mode 100644 openspec/changes/apps-client-libraries-and-ci/design.md create mode 100644 openspec/changes/apps-client-libraries-and-ci/proposal.md create mode 100644 openspec/changes/apps-client-libraries-and-ci/specs/ci-integrations/spec.md create mode 100644 openspec/changes/apps-client-libraries-and-ci/specs/client-libraries/spec.md create mode 100644 openspec/changes/apps-client-libraries-and-ci/tasks.md create mode 100644 openspec/changes/apps-kubernetes-injection/.openspec.yaml create mode 100644 openspec/changes/apps-kubernetes-injection/design.md create mode 100644 openspec/changes/apps-kubernetes-injection/proposal.md create mode 100644 openspec/changes/apps-kubernetes-injection/specs/kubernetes-integration/spec.md create mode 100644 openspec/changes/apps-kubernetes-injection/tasks.md create mode 100644 openspec/changes/apps-secret-sync-and-rotation-runner/.openspec.yaml create mode 100644 openspec/changes/apps-secret-sync-and-rotation-runner/design.md create mode 100644 openspec/changes/apps-secret-sync-and-rotation-runner/proposal.md create mode 100644 openspec/changes/apps-secret-sync-and-rotation-runner/specs/secret-rotation-runner/spec.md create mode 100644 openspec/changes/apps-secret-sync-and-rotation-runner/specs/secret-store-api/spec.md create mode 100644 openspec/changes/apps-secret-sync-and-rotation-runner/tasks.md create mode 100644 openspec/changes/apps-terraform-provider/.openspec.yaml create mode 100644 openspec/changes/apps-terraform-provider/design.md create mode 100644 openspec/changes/apps-terraform-provider/proposal.md create mode 100644 openspec/changes/apps-terraform-provider/specs/terraform-provider/spec.md create mode 100644 openspec/changes/apps-terraform-provider/tasks.md diff --git a/openspec/changes/admin-scheduled-vault-backups/.openspec.yaml b/openspec/changes/admin-scheduled-vault-backups/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/admin-scheduled-vault-backups/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/admin-scheduled-vault-backups/design.md b/openspec/changes/admin-scheduled-vault-backups/design.md new file mode 100644 index 000000000..7118418b5 --- /dev/null +++ b/openspec/changes/admin-scheduled-vault-backups/design.md @@ -0,0 +1,93 @@ +# Design: scheduled vault backups + +## Context + +Read at development `4c214a9d`. + +- `lib/` has no `Command` directory and `appinfo/info.xml` declares no ``: Keepiq has no `occ` command yet. +- `appinfo/info.xml:110` to `:123` lists twelve background jobs. `lib/BackgroundJob/PurgeAuditLogJob.php:44` shows the `TimedJob` pattern (`setInterval()` at `:62`). +- `lib/Migration/Version001000Date20260908000000.php:53` lists the 33 Keepiq tables in a private `TABLES` constant. +- `lib/Service/AttachmentService.php:44` stores attachment ciphertext blobs in `IAppData` under the folder `attachments` (`:60`), namespace `keepiq` (`:69`). +- `lib/Service/CertificateAuthorityService.php:66` encrypts the CA private keys with Nextcloud's `ICrypto`, which is keyed to the instance `secret` in `config.php`. `lib/Db/SiemSink.php:109` stores the SIEM HMAC secret the same way. +- ADR-003: names and URLs are stored plain so search works; secret fields are RSA ciphertext; private keys are AES-wrapped with a key derived from the master password. +- The per-user encrypted export (`openspec/specs/secret-export/spec.md`, "Encrypted Backup Export"; `src/store/modules/export.js:86`) runs in the browser, by hand, for one vault. + +## Goals / Non-Goals + +**Goals:** + +- A complete, restorable copy of every Keepiq vault on a schedule, without a Nextcloud-wide restore. +- An archive that proves its own integrity before a restore touches the database. +- An option to make archives unreadable on the server itself. + +**Non-Goals:** + +- Restoring one user's vault into a live instance. Shares, team folders and delegations link vaults; a partial restore would break those links. It can follow as its own change. +- Any plaintext in a backup. The server has none to write. +- Off-site transport. Administrators copy archives with their existing backup tooling; `keepiq:backup:list` prints the path. +- Web download of archives (see D6). + +## Decisions + +### D1: One archive per run, every table and every blob + +`BackupTableRegistry` lists the 33 tables. A PHPUnit test reads the migration's `TABLES` constant by reflection and fails when the two lists differ, so a new table can never be left out silently. The archive is a zip (`ext-zip` is a Nextcloud requirement) with `manifest.json`, `tables/.jsonl` (one row per line, written as rows are read) and `blobs/`. The manifest carries the format `keepiq-vault-backup-v1`, the app version, the schema fingerprint (sorted table and column names), the creation time, the instance id, and per file the row count and SHA-256. + +Alternative considered: a SQL dump per table. Rejected: the dump dialect differs across PostgreSQL, MySQL and SQLite, all three of which Keepiq supports. + +### D2: Optional encryption to an administrator-held public key + +The admin settings accept a PEM certificate or public key (`backup_recipient_public_key`). When set, the writer streams the zip through segmented AES-256-GCM (1 MiB segments, a nonce and tag per segment) under a random content key, and wraps that key with RSA-OAEP-SHA256 under the recipient key, the same primitives ADR-003 uses. The private key stays with the administrator; `restore` and `verify` take it with `--key-file`. Without a key, the archive holds what a database dump holds. + +Alternative considered: encrypt with Nextcloud's `ICrypto`. Rejected: the key sits in `config.php` on the same server, so it protects nothing an attacker on that server cannot read. + +### D3: A timed job with an administrator schedule + +`ScheduledVaultBackupJob` is a `TimedJob` that wakes hourly (`TIME_INSENSITIVE`) and runs when `backup_interval_hours` (default 24, minimum 1) has passed since `backup_last_run_at`. It is off until `backup_enabled` is true. Archives go to the `backups` folder in `IAppData`; the oldest beyond `backup_retention_count` (default 7) are removed after a successful run. The job records `backup_last_status` and `backup_last_error` in app config. + +### D4: Four occ commands + +| Command | Does | +|---|---| +| `keepiq:backup:create` | Runs a backup now, same code as the job | +| `keepiq:backup:list` | Name, size, time, encrypted or not, path on disk | +| `keepiq:backup:verify [--key-file=]` | Decrypts if needed, checks every checksum and the format | +| `keepiq:backup:restore [--key-file=] [--dry-run] [--force]` | Restores the archive | + +`restore` refuses unless maintenance mode is on, so no request writes while tables are replaced. It verifies the archive first and refuses a schema fingerprint that differs from the installed one. In one database transaction it empties each Keepiq table and inserts the archive rows; then it replaces the attachment blobs. `--dry-run` prints current and archive row counts per table and changes nothing. `--force` is required when the archive is older than the newest row in `keepiq_audit_log`, so an administrator cannot roll back by accident. + +### D5: Restore gives back ciphertext as it was + +After a restore: + +- every user unlocks with the master password that was valid when the backup ran, because their private key blob is wrapped with it; +- secrets written after the backup are gone, and key rotations after the backup are undone; +- CA keys and SIEM secrets are readable only with the same Nextcloud `secret`; the command probes one `ICrypto` value and warns when it fails; +- rows owned by users that no longer exist in Nextcloud are listed as a warning, not dropped. + +The command prints these points before it asks for confirmation. + +### D6: No web download + +The admin section shows status, schedule, key and the archive list, and a "Back up now" button that queues the job. It offers no download. A download link would let anyone with the admin area copy every vault's ciphertext and metadata through the browser. Reading the archive needs shell access, which already implies database access. + +## Security and zero-knowledge + +- No plaintext secret value or master password exists on the server, so none can reach an archive. +- Stored encrypted in the archive: secret fields (RSA), private keys (AES under the master password), attachment blobs and their metadata (AES-GCM), CA keys and SIEM secrets (`ICrypto`). Stored plain in the archive: user ids, secret names and URLs, folder and team folder structure, audit entries, dates, statuses. +- With a recipient key set, the whole archive is ciphertext on the server. +- Restore never asks for, derives or stores a master password or a user private key. + +## Risks / Trade-offs + +- Archives double the disk use of Keepiq data per retained copy. The section shows the total size; retention is configurable. +- A long backup on a large instance holds a read over every table. Rows are streamed, not loaded, and the job runs time-insensitive. +- Restoring rolls back every vault, including changes users made after the backup. The dry run and the `--force` rule make that explicit. + +## Seed data + +None. PHPUnit tests write an archive from fixture rows into a temporary `IAppData` mock and restore it into SQLite. The seeded development vault (`lib/Repair/SeedDevelopmentData.php`) is enough for a manual `keepiq:backup:create` on the dev instance. + +## Migration + +No table or column. `appinfo/info.xml` gains the new `` entry and a `` block, so `` must be bumped for existing installs to register the job. diff --git a/openspec/changes/admin-scheduled-vault-backups/proposal.md b/openspec/changes/admin-scheduled-vault-backups/proposal.md new file mode 100644 index 000000000..d05f6404b --- /dev/null +++ b/openspec/changes/admin-scheduled-vault-backups/proposal.md @@ -0,0 +1,54 @@ +--- +kind: code +--- + +# Scheduled vault backups + +## Why + +Keepiq has no backup of its own. An instance relies on the Nextcloud database backup, and restoring that restores everything else too. Administrators want scheduled Keepiq backups they can restore on their own, from the command line. + +| Row | Capability | What keepiq does today | +|---|---|---| +| admin-27 | Administrators schedule automatic encrypted backups of every vault on the server and restore them from the command line | There is no scheduled server-side backup of all vaults and no restore command; an instance relies on the Nextcloud database backup. | + +Matrix: keepiq `openspec/parity/capabilities.json` + +### Demand + +- featureRequest: https://community.bitwarden.com/t/adjusting-timezone-and-database-backup-schedule-in-bitwarden/59341 + +### Competitors rated yes + +- Nextcloud Passwords: "marius-wieschollek/passwords@2026.9.0 src/lib/Cron/BackupJob.php:47 backup/interval; src/lib/Helper/AppSettings/BackupSettingsHelper.php:35-37 interval, max files, auto-restore after update; src/lib/Command/BackupRestoreCommand.php:45 passwords:backup:restore ..." + +### What a server backup can hold + +The server never holds plaintext secret values or master passwords (ADR-003). A server-side backup of every vault can therefore only contain ciphertext plus the metadata the server already stores in plain form. Restoring it gives back ciphertext that still needs each user's own key. + +## What Changes + +- A background job writes a backup archive of every Keepiq table and every attachment blob on a schedule the administrator sets (default: daily, keep 7). +- The archive holds ciphertext and metadata exactly as stored, with a manifest of row counts and checksums. +- Optionally, the administrator uploads a backup public key; each archive is then encrypted to it, and only the matching private key, held off the server, can open it. +- Four `occ` commands: `keepiq:backup:create`, `keepiq:backup:list`, `keepiq:backup:verify` and `keepiq:backup:restore`. Restore needs maintenance mode, checks the schema version, and supports `--dry-run`. +- A "Vault backups" section in the admin settings: schedule, retention, public key, last result and the list of archives. Archives are not downloadable from the web. +- Backup runs, failures and restores are audited. + +## Capabilities + +### New Capabilities + +- `vault-backups`: scheduled, ciphertext-only backups of every vault on the server, with verification and restore from the command line. + +### Modified Capabilities + +None. + +## Impact + +- **Backend**: `lib/Backup/` (table registry, archive writer and reader, archive encryption), `lib/BackgroundJob/ScheduledVaultBackupJob.php`, the first `lib/Command/` classes, settings keys in `AdminSettingsService`, three audit event types. +- **Frontend**: `VaultBackupSection.vue` in the admin settings. +- **Database**: none. Archives live in the app's data folder; status lives in app config. +- **Security**: an archive is as sensitive as the database it copies, so the optional public key encryption is recommended. No new plaintext exists anywhere. Restoring never needs or learns a master password. +- **Cross-app**: none. Application vaults are backed up like user vaults; an application still decrypts with its own key after a restore. diff --git a/openspec/changes/admin-scheduled-vault-backups/specs/vault-backups/spec.md b/openspec/changes/admin-scheduled-vault-backups/specs/vault-backups/spec.md new file mode 100644 index 000000000..c0494dc52 --- /dev/null +++ b/openspec/changes/admin-scheduled-vault-backups/specs/vault-backups/spec.md @@ -0,0 +1,71 @@ +## ADDED Requirements + +### Requirement: Administrator schedules vault backups + +The system MUST let an administrator switch scheduled backups on in the Keepiq admin settings, choose the interval in hours (default 24, minimum 1) and the number of archives to keep (default 7). When on, a background job MUST write one archive per interval to the app data folder and MUST remove the oldest archives beyond the retention count after a successful run. The section MUST show the last run time, its result and the archive list. Every run MUST be audited as `BACKUP_CREATED` or `BACKUP_FAILED`. + +#### Scenario: Daily backup appears + +- **GIVEN** an administrator switched on "Vault backups" with interval 24 and retention 7 in the Keepiq admin settings +- **WHEN** 24 hours pass and Nextcloud cron runs +- **THEN** a new archive MUST appear in the "Vault backups" archive list +- **AND** a `BACKUP_CREATED` audit event MUST be recorded + +### Requirement: Archives hold ciphertext and metadata only + +Each archive MUST contain every Keepiq table and every attachment blob exactly as stored, plus a manifest with the format `keepiq-vault-backup-v1`, the app version, the schema fingerprint, and a row count and SHA-256 per file. An archive MUST NOT contain any plaintext secret value, master password or unwrapped private key. The table list MUST be checked against the schema by an automated test. + +#### Scenario: Secret values stay ciphertext in the archive + +- **GIVEN** vault owner `alice` has a secret whose value is `YOUR_TOKEN_HERE` +- **WHEN** an administrator runs `occ keepiq:backup:create` and inspects the archive +- **THEN** the archive MUST contain `alice`'s secret row with RSA ciphertext in its value fields +- **AND** the string `YOUR_TOKEN_HERE` MUST NOT occur anywhere in the archive + +### Requirement: Archives can be encrypted to an administrator-held key + +When a backup public key is configured, each archive MUST be encrypted with a random AES-256-GCM content key that is wrapped with RSA-OAEP-SHA256 under that public key. The server MUST NOT hold the matching private key. Verify and restore MUST require the private key through `--key-file`. + +#### Scenario: Archive cannot be read without the private key + +- **GIVEN** an administrator uploaded a backup public key and a backup ran +- **WHEN** they run `occ keepiq:backup:verify ` without `--key-file` +- **THEN** the command MUST fail and say the archive is encrypted + +### Requirement: Archives are verified and restored from the command line + +The system MUST offer `occ keepiq:backup:list`, `occ keepiq:backup:verify` and `occ keepiq:backup:restore`. Restore MUST refuse unless Nextcloud maintenance mode is on, MUST verify every checksum first, MUST refuse an archive whose schema fingerprint differs from the installed schema, and MUST replace all Keepiq tables in one database transaction before replacing the attachment blobs. `--dry-run` MUST print per-table current and archive row counts and change nothing. Restoring an archive older than the newest audit entry MUST require `--force`. Every restore MUST be audited as `BACKUP_RESTORED`. + +#### Scenario: Restore outside maintenance mode is refused + +- **GIVEN** maintenance mode is off +- **WHEN** an administrator runs `occ keepiq:backup:restore ` +- **THEN** the command MUST exit with an error and no table MUST change + +#### Scenario: Dry run shows the difference + +- **GIVEN** maintenance mode is on and a valid archive +- **WHEN** an administrator runs `occ keepiq:backup:restore --dry-run` +- **THEN** the command MUST print current and archive row counts per table +- **AND** no table MUST change + +### Requirement: A restore returns ciphertext that still needs each user's key + +After a restore, every user MUST unlock with the master password that was valid when the archive was written, and MUST read the values as they were then. The restore command MUST state before it asks for confirmation that later changes are lost, that users need their master password from backup time, and that CA keys need the same Nextcloud instance secret. Restore MUST NOT ask for, derive or store any master password or user private key. + +#### Scenario: User unlocks the restored vault + +- **GIVEN** a backup ran, then vault owner `alice` changed a secret value +- **WHEN** an administrator restores that backup and `alice` unlocks on the lock screen at `/lock` with her master password from backup time +- **THEN** `alice` MUST see the secret value from before her change + +### Requirement: Archives are not downloadable from the web + +The admin settings MUST NOT offer a download of a backup archive, and no Keepiq HTTP endpoint MUST serve archive content. + +#### Scenario: Admin section lists without download + +- **GIVEN** an administrator on the "Vault backups" section with three archives +- **WHEN** they look at the archive list +- **THEN** each row MUST show name, size, time and whether it is encrypted +- **AND** no row MUST offer a download diff --git a/openspec/changes/admin-scheduled-vault-backups/tasks.md b/openspec/changes/admin-scheduled-vault-backups/tasks.md new file mode 100644 index 000000000..cb2effd14 --- /dev/null +++ b/openspec/changes/admin-scheduled-vault-backups/tasks.md @@ -0,0 +1,32 @@ +## 1. Archive + +- [ ] 1.1 Add `lib/Backup/BackupTableRegistry.php` with the 33 tables and a test that compares it with the migration's `TABLES` by reflection. Verify with that PHPUnit test. +- [ ] 1.2 Add the archive writer (zip, JSON lines per table, blobs, manifest with row counts, SHA-256 and schema fingerprint), streaming rows. Verify with a PHPUnit test that writes fixture rows and checks every checksum. +- [ ] 1.3 Add segmented AES-256-GCM archive encryption with the content key wrapped by RSA-OAEP-SHA256 under the configured public key. Verify with a PHPUnit round-trip test and a test that a wrong key fails. + +## 2. Schedule and settings + +- [ ] 2.1 Add the settings keys (`backup_enabled`, `backup_interval_hours`, `backup_retention_count`, `backup_recipient_public_key`) with validation in `AdminSettingsService`. Verify with a PHPUnit test for bounds and key parsing. +- [ ] 2.2 Add `ScheduledVaultBackupJob` (hourly, runs when due, retention clean-up, status in app config), register it in `appinfo/info.xml` and bump ``. Verify with a PHPUnit test for due and not-due runs and retention. +- [ ] 2.3 Add the `BACKUP_CREATED`, `BACKUP_FAILED` and `BACKUP_RESTORED` audit events with whitelisted metadata. Verify with a PHPUnit test on the dispatched metadata. + +## 3. Commands + +- [ ] 3.1 Add `keepiq:backup:create` and `keepiq:backup:list` and a `` block in `appinfo/info.xml`. Verify manually with `occ keepiq:backup:create` and `occ keepiq:backup:list` on the dev instance. +- [ ] 3.2 Add `keepiq:backup:verify` with `--key-file`. Verify with a PHPUnit command test for a good archive, a tampered file and a wrong key. +- [ ] 3.3 Add `keepiq:backup:restore` with the maintenance mode check, schema fingerprint check, single transaction, blob replacement, `--dry-run` and the `--force` rule. Verify with a PHPUnit test that restores into SQLite and compares every table. +- [ ] 3.4 Print the restore warnings (old master password, lost later changes, instance secret probe, missing users). Verify with a PHPUnit command output test. + +## 4. Admin UI + +- [ ] 4.1 Add `VaultBackupSection.vue` with schedule, retention, public key upload, last result, archive list and "Back up now", and no download action. Verify with a vitest in `tests/components/`. +- [ ] 4.2 Prove a restored vault still unlocks. Verify manually on the dev instance: create a backup, change a secret, restore, unlock as `admin` with the master password from before, and see the old value. + +## Acceptance criteria + +- With backups on, an archive of every Keepiq table and attachment blob appears in the app data folder at the chosen interval, and old archives beyond the retention count are removed. +- No archive contains a plaintext secret value or a master password. +- With a backup public key set, an archive cannot be verified or restored without the matching private key. +- `occ keepiq:backup:restore` refuses to run outside maintenance mode and refuses an archive from a different schema. +- After a restore, each user unlocks with the master password valid at backup time and reads the values as they were then. +- The web interface offers no way to download an archive. diff --git a/openspec/changes/apps-client-libraries-and-ci/.openspec.yaml b/openspec/changes/apps-client-libraries-and-ci/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/apps-client-libraries-and-ci/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/apps-client-libraries-and-ci/design.md b/openspec/changes/apps-client-libraries-and-ci/design.md new file mode 100644 index 000000000..981d4ae08 --- /dev/null +++ b/openspec/changes/apps-client-libraries-and-ci/design.md @@ -0,0 +1,79 @@ +# Design: client libraries and CI integrations + +## Context + +Read at development `4c214a9d`. + +- `cli/go.mod` is the module `github.com/ConductionNL/keepiq/cli`, Go 1.22, stdlib only. +- `cli/internal/client/client.go:137` `Discover()`, `:151` `MachineToken()` and `:215` `FetchByName()` implement discovery, the assertion and the by-name read; `cli/internal/crypto/crypto.go:139` `DecryptField()` and `:199` `SignRS256()` implement the crypto. They are `internal` and cannot be imported from outside `cli/`. There is no encrypt function: the CLI is read-only by design (`openspec/specs/keepiq-cli/spec.md`, "Read-only vault access in v1"). +- `cli/internal/client/client.go:201` `MachineEnvelope` expects a top-level `scheme` and `payload.value`, and `cli/ci.go:65` refuses any other scheme. The server sends `encryption.scheme` and `ciphertext.key`, `ciphertext.login`, `ciphertext.additionalFields` (`lib/Service/MachineSecretEnvelopeService.php:129` to `:150`). The CLI's unit test fakes the shape the CLI expects (`cli/internal/client/client_test.go:28`), so it cannot see the difference; only the token exchange has a live probe (`cli/internal/client/live_token_test.go`). +- `cli/internal/crypto/testdata/webcrypto_envelope.json` holds a browser-produced vector (wrapped key, field, plaintext). +- `lib/Service/DecryptService.php` and `lib/Service/EncryptService.php` are the stateless PHP crypto (ADR-003); `openspec/config.yaml` requires cross-implementation round-trip tests. +- `.github/workflows/cli-release.yml` builds six binaries and attaches them to `cli-v*` releases, with no checksum file and no container image. +- `cli/ci.go:97` `cmdCIRun()` injects values into a child process environment only; the CLI spec forbids writing decrypted values to a file. + +## Goals / Non-Goals + +**Goals:** + +- A developer in Go, Python or TypeScript reads and writes their application's secrets in a few lines, with decryption in their own process. +- One recipe, one set of vectors, byte-identical across PHP, the browser, Go, Python and TypeScript. +- A pipeline gets secrets from Keepiq with a single step and no plaintext in the pipeline configuration. + +**Non-Goals:** + +- Libraries for Java, .NET, Ruby, Rust or PHP outside Nextcloud. They can follow on the same vectors. +- User vault access in the libraries. Human access stays in the browser and the CLI's human mode. +- OIDC federation for CI (trusting GitHub or GitLab tokens instead of an application key). It would replace authentication only; decryption still needs the application private key, so the pipeline would hold that key anyway. +- A GitHub Marketplace listing, which requires a dedicated repository with `action.yml` at its root. + +## Decisions + +### D1: Libraries live in `sdk/`, one directory per language + +`sdk/go/` becomes module `github.com/ConductionNL/keepiq/sdk/go`, released with tags `sdk/go/vX.Y.Z`, stdlib only, so the CLI stays dependency free. `sdk/python/` is a `pyproject.toml` package depending only on `cryptography`. `sdk/js/` is a TypeScript package with no runtime dependency, using WebCrypto (`globalThis.crypto.subtle`, Node 20 and later, browsers). Package names are reserved at first release; the working names are `keepiq-sdk` on PyPI and `@conduction/keepiq-sdk` on npm. + +Alternative considered: one Rust core with bindings, as Bitwarden does. Rejected: three small native implementations of one documented recipe are easier to audit than a native build chain in every consumer. + +### D2: One surface in every language + +`Client(url, applicationId, privateKeyPem)` with: `getByName(name, folder?)`, `getById(id)`, `list(updatedSince?)`, `create(fields)`, `update(id, fields)`. Reads return decrypted fields plus metadata and the lease; writes encrypt with the public half of the caller's own key after checking it against the envelope fingerprint. Errors are typed: not found, ambiguous (with candidates), unauthorized, not modified. Tokens are cached until expiry; ETags are sent with `If-None-Match`. + +### D3: Parse the envelope the server sends, and prove it + +The Go extraction replaces the CLI's `MachineEnvelope` with the server's shape (`format`, `secret`, `encryption`, `ciphertext`). The conformance vectors in `sdk/testdata/` are produced from the real serializer: a PHPUnit fixture generator writes an envelope for a test application key, and the browser vector is moved from `cli/internal/crypto/testdata/`. Every library and the CLI decrypt the same vectors; a PHPUnit test decrypts vectors that each library encrypted, through `DecryptService`. The test key is a throwaway, labelled test-only and allow-listed by path for secret scanning. + +### D4: The GitHub Action runs a command by default, exports only on request + +`integrations/github-action/action.yml` is a composite action with inputs `url`, `application-id`, `private-key` (from a GitHub secret, passed as `KEEPIQ_APP_KEY`), `secrets` (names, one per line, optional `NAME=ENV_VAR`), `run` and `export-env` (default false). It downloads the CLI for the runner's OS and architecture from the matching `cli-v*` release and checks it against the release checksums. With `run`, it executes `keepiq ci run` around that command, so values never touch disk. With `export-env: true`, it masks each value with `::add-mask::` (per line for multi-line values) and appends it to `$GITHUB_ENV`; the input's description says this writes the value to the runner's environment file. With neither, the step fails with a message naming both options. + +Alternative considered: export by default, as some competitor actions do. Rejected: the CLI spec promises no plaintext on disk; export stays a deliberate choice. + +### D5: A GitLab CI template included by URL + +`integrations/gitlab-ci/keepiq.gitlab-ci.yml` defines a hidden job `.keepiq` whose `before_script` installs the checked CLI. A job `extends: .keepiq` and wraps its command with `keepiq ci run`. GitLab cannot mask values fetched at run time, so the template only offers the wrapped form. Projects include it with `include: remote:` pointing at the file on a `cli-v*` tag. A CI/CD Catalog component needs its own GitLab project and is left for later. + +### D6: Release and test per directory + +`cli-release.yml` adds `SHA256SUMS` to each `cli-v*` release and pushes `ghcr.io/conductionnl/keepiq-cli` (static binary on a distroless base). New workflows test and release each library on its own tag prefix (`sdk/go/v*`, `sdk-py-v*`, `sdk-js-v*`): Python through PyPI trusted publishing, TypeScript through npm with provenance. The action and template are tested by a workflow that runs them against a stub Keepiq server serving the vectors. + +## Security and zero-knowledge + +- The server never sees plaintext: libraries send ciphertext on write and receive ciphertext on read. No server change. +- Plain on the caller's side: decrypted values in process memory (and, with `export-env`, in the runner's environment file). Encrypted: everything that crosses the network. +- The application private key is supplied by the caller and never sent; only a signed assertion leaves the process. +- The action verifies the CLI binary's checksum before running it, so a tampered download is refused. + +## Risks / Trade-offs + +- Three libraries triple the maintenance of the recipe. The shared vectors make any drift fail CI in the language that drifted. +- Fixing the CLI's envelope parser changes CLI behaviour. Today the parser cannot read a server envelope, so no working pipeline depends on the old shape. +- The action exists only as a path in this repository, so it is not discoverable in the Marketplace. + +## Seed data + +None in the app. The PHPUnit fixture generator creates a throwaway application key and envelope for the vectors; nothing is written to a dev database. + +## Migration + +None. No server change, so no table, column or `` bump. diff --git a/openspec/changes/apps-client-libraries-and-ci/proposal.md b/openspec/changes/apps-client-libraries-and-ci/proposal.md new file mode 100644 index 000000000..a6bd8ebac --- /dev/null +++ b/openspec/changes/apps-client-libraries-and-ci/proposal.md @@ -0,0 +1,65 @@ +--- +kind: code +--- + +# Client libraries and CI integrations + +## Why + +A Python service, a Node job or a CI pipeline that needs a Keepiq application secret has to implement the RFC 7523 assertion, the envelope and the chunked RSA decryption itself. There is one Go CLI and nothing else. + +| Row | Capability | What keepiq does today | +|---|---|---| +| apps-18 | Use a ready-made GitHub Actions or GitLab CI integration | No ready-made GitHub Actions or GitLab CI step exists; a pipeline would have to install and script the keepiq CLI itself. | +| apps-21 | Use client libraries for common programming languages | There is one cross-compiled CLI binary, not per-language client libraries; a Python or Node consumer would call the documented HTTP+JWT API directly with no official SDK. | + +Matrix: keepiq `openspec/parity/capabilities.json` + +### Demand + +No demand row. + +### Competitors rated yes + +- Bitwarden (apps-18): "bitwarden/clients@web-v2026.9.0 bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.component.ts:25 GitHub Actions, :32 GitLab CI/CD, :39 Ansible | docs: https://bitwarden.com/help/github-actions-integration/ ..." +- 1Password (apps-18): "https://developer.1password.com/docs/ci-cd/github-actions/ : load secrets into GitHub Actions with secret references" +- Keeper (apps-18): "https://docs.keeper.io/keeperpam/secrets-manager/integrations/github-actions : Keeper Secrets Manager GitHub Action; GitLab integration at https://docs.keeper.io/keeperpam/secrets-manager/integrations/gitlab-plugin" +- HashiCorp Vault (apps-18): "hashicorp/vault@v2.1.1 go.mod:158 vault-plugin-auth-jwt bundled for GitHub and GitLab OIDC tokens | docs: https://developer.hashicorp.com/vault/docs/platform/github-actions (hashicorp/vault-action, separate repo) ..." +- Bitwarden (apps-21): "bitwarden/clients@web-v2026.9.0 bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.component.ts:45 C#, :51 C++, :57 Go, :63 Java, :70 JS WebAssembly, :76 php, :82 Python, :88 Ruby, :18 Rust (bitwarden/sdk-sm) | docs: https://bitwarden.com/help/secrets-manager-sdk/ ..." +- 1Password (apps-21): "https://developer.1password.com/docs/sdks/ : SDKs for Go, JavaScript and Python" +- Keeper (apps-21): "https://docs.keeper.io/keeperpam/secrets-manager/developer-sdk-library : Python, Java/Kotlin, JavaScript, .NET, Go, Ruby, Rust, PowerShell SDKs" +- HashiCorp Vault (apps-21): "hashicorp/vault@v2.1.1 api/auth.go official Go client package api/ in-tree | docs: https://developer.hashicorp.com/vault/api-docs/libraries ..." + +### Missing half + +apps-21 is partial. Built: the Go command-line client in `cli/`. Missing: client libraries for common languages. + +## What Changes + +- Three client libraries for the machine API: Go (`sdk/go/`, extracted from `cli/internal/`), Python (`sdk/python/`) and TypeScript for Node and browsers (`sdk/js/`). +- Each library discovers the instance, signs the RFC 7523 assertion, caches the token, reads by name, id and list, decrypts locally, writes back values encrypted to the application's own key, honours ETags and leases, and reports the 409 candidates. +- One set of conformance vectors in `sdk/testdata/`, produced by the PHP serializer and the browser crypto, that every library and the CLI must pass. +- The CLI moves onto the Go library. Its envelope parser follows the envelope the server actually sends. +- A GitHub Action in `integrations/github-action/`, used as `ConductionNL/keepiq/integrations/github-action@`. It runs a command with secrets in its environment, or, when the workflow opts in, exports masked values to later steps. +- A GitLab CI template in `integrations/gitlab-ci/`, included by URL, that installs the CLI and wraps a job's command with `keepiq ci run`. +- The CLI release publishes checksums and a container image `ghcr.io/conductionnl/keepiq-cli`. +- No change to the Keepiq server. + +## Capabilities + +### New Capabilities + +- `client-libraries`: official Go, Python and TypeScript libraries for the Keepiq machine API, decrypting only in the calling process. +- `ci-integrations`: a GitHub Action and a GitLab CI template that bring Keepiq application secrets into pipelines. + +### Modified Capabilities + +None. + +## Impact + +- **Backend**: none. A PHPUnit test decrypts library-produced vectors with `DecryptService` to prove the round trip. +- **Frontend**: none. +- **Database**: none. +- **Security**: plaintext exists only in the calling process or the pipeline step. The application private key never leaves the caller. The GitHub Action masks every value before any later step can print it. +- **Cross-app**: the Kubernetes operator, the rotation runner and the Terraform provider build on `sdk/go/`. diff --git a/openspec/changes/apps-client-libraries-and-ci/specs/ci-integrations/spec.md b/openspec/changes/apps-client-libraries-and-ci/specs/ci-integrations/spec.md new file mode 100644 index 000000000..1e9d8b22a --- /dev/null +++ b/openspec/changes/apps-client-libraries-and-ci/specs/ci-integrations/spec.md @@ -0,0 +1,42 @@ +## ADDED Requirements + +### Requirement: GitHub Action runs a step with Keepiq secrets + +The project MUST ship a composite GitHub Action at `integrations/github-action/`, usable as `ConductionNL/keepiq/integrations/github-action@`. It MUST take the instance URL, application id, application private key and a list of secret names. It MUST install the CLI for the runner from the matching release and MUST refuse a binary whose checksum does not match the release checksums. With the `run` input it MUST execute that command through `keepiq ci run`, so values exist only in that command's environment and nothing is written to disk. + +#### Scenario: Deploy step gets a database password + +- **GIVEN** a workflow with the application private key in GitHub secret `KEEPIQ_APP_KEY` +- **WHEN** a step uses the action with `secrets: DB_PASSWORD` and `run: ./deploy.sh` +- **THEN** `./deploy.sh` MUST see `KEEPIQ_DB_PASSWORD` in its environment +- **AND** no file on the runner MUST contain the value + +### Requirement: GitHub Action exports only on request and masks every value + +The action MUST NOT export values to later steps unless `export-env` is `true`. When it exports, it MUST register every value with `::add-mask::` (every line of a multi-line value) before writing it to `$GITHUB_ENV`. Without `run` and without `export-env`, the step MUST fail and name both options. + +#### Scenario: Exported value is masked in logs + +- **GIVEN** a step using the action with `secrets: API_TOKEN` and `export-env: true` +- **WHEN** a later step echoes `$KEEPIQ_API_TOKEN` +- **THEN** the workflow log MUST show the value masked + +### Requirement: GitLab CI template wraps a job command + +The project MUST ship `integrations/gitlab-ci/keepiq.gitlab-ci.yml` defining a hidden job `.keepiq` that installs the checksum-verified CLI. A job extending it MUST be able to run its command through `keepiq ci run`, so values exist only in that command's environment. + +#### Scenario: GitLab job runs a migration with a secret + +- **GIVEN** a `.gitlab-ci.yml` that includes the template by URL and a job `migrate` with `extends: .keepiq` +- **WHEN** the job runs `keepiq ci run DB_PASSWORD` with `./migrate.sh` as the wrapped command +- **THEN** `./migrate.sh` MUST see `KEEPIQ_DB_PASSWORD` in its environment + +### Requirement: The CLI release is verifiable and containerised + +Each `cli-v*` release MUST include a `SHA256SUMS` file for every binary and MUST publish the container image `ghcr.io/conductionnl/keepiq-cli` with the same version. + +#### Scenario: Pipeline verifies the downloaded CLI + +- **GIVEN** release `cli-v0.2.0` +- **WHEN** a pipeline downloads `keepiq-linux-amd64` and `SHA256SUMS` from it +- **THEN** the binary's SHA-256 MUST match its line in `SHA256SUMS` diff --git a/openspec/changes/apps-client-libraries-and-ci/specs/client-libraries/spec.md b/openspec/changes/apps-client-libraries-and-ci/specs/client-libraries/spec.md new file mode 100644 index 000000000..d0ecc3a54 --- /dev/null +++ b/openspec/changes/apps-client-libraries-and-ci/specs/client-libraries/spec.md @@ -0,0 +1,53 @@ +## ADDED Requirements + +### Requirement: Official libraries for Go, Python and TypeScript + +The project MUST ship client libraries for the Keepiq machine API in Go (`sdk/go/`), Python (`sdk/python/`) and TypeScript (`sdk/js/`). Each MUST discover the instance from `/api/v1/app/.well-known/keepiq`, sign the RFC 7523 assertion with the caller's application private key, cache the bearer token until it expires, and offer read by name, read by id, list with an `updatedSince` filter, create and update for the application's own vault. + +#### Scenario: Python service reads a secret by name + +- **GIVEN** an approved application `billing` with its private key in `/run/secrets/keepiq.pem` and a secret `stripe-key` in its vault +- **WHEN** a Python service calls `Client(url, "billing", key).get_by_name("stripe-key")` +- **THEN** the call MUST return the decrypted value and the secret metadata +- **AND** no request from the library MUST carry the plaintext value or the private key + +### Requirement: Libraries decrypt and encrypt only in the calling process + +Every library MUST decrypt the `rsa-oaep-sha256-chunked-v1` ciphertext in the calling process, MUST check the envelope's certificate fingerprint against the caller's key before decrypting, and MUST encrypt write-back values with the public half of the caller's own key before sending them to `POST /api/v1/app/secrets` or `PUT /api/v1/app/secrets/{id}`. + +#### Scenario: Node job rotates its own value + +- **GIVEN** a TypeScript job holding the private key of application `billing` +- **WHEN** it calls `client.update(id, { key: "YOUR_TOKEN_HERE" })` +- **THEN** the request body MUST contain only ciphertext for `key` +- **AND** a later `getById(id)` MUST return `YOUR_TOKEN_HERE` + +### Requirement: Libraries follow the machine API contract + +Every library MUST send `If-None-Match` with the last ETag and report "not modified" on 304, MUST expose the `Doriath-Lease-Id` and `Doriath-Lease-Expires` headers, and MUST raise a typed ambiguous-name error carrying the candidates' ids and folder paths on 409. + +#### Scenario: Ambiguous name is reported with candidates + +- **GIVEN** two secrets named `api-token` in the application's vault +- **WHEN** a Go program calls `GetByName("api-token", "")` +- **THEN** the call MUST return an ambiguous-name error listing both candidates' ids and folder paths + +### Requirement: One set of conformance vectors binds every implementation + +The repository MUST hold shared vectors in `sdk/testdata/` produced by the PHP envelope serializer and the browser crypto. Every library and the CLI MUST decrypt them in CI, and a PHPUnit test MUST decrypt values that each library encrypted, using `DecryptService`. The CLI MUST parse the envelope shape the server sends. + +#### Scenario: CLI reads a real envelope + +- **GIVEN** an envelope written by `MachineSecretEnvelopeService::serialize()` with `encryption.scheme` and `ciphertext.key` +- **WHEN** the CLI's CI mode decrypts it with the matching application key +- **THEN** it MUST return the plaintext value + +### Requirement: Libraries are released from this repository + +Each library MUST be tested on every pull request that touches its directory and released on its own tag prefix: `sdk/go/v*` for Go, `sdk-py-v*` for PyPI through trusted publishing, and `sdk-js-v*` for npm with provenance. + +#### Scenario: Tagged Python release + +- **GIVEN** a maintainer pushes tag `sdk-py-v0.1.0` +- **WHEN** the release workflow finishes +- **THEN** version `0.1.0` of the Python package MUST be on PyPI with a trusted-publishing attestation diff --git a/openspec/changes/apps-client-libraries-and-ci/tasks.md b/openspec/changes/apps-client-libraries-and-ci/tasks.md new file mode 100644 index 000000000..b76ebe432 --- /dev/null +++ b/openspec/changes/apps-client-libraries-and-ci/tasks.md @@ -0,0 +1,28 @@ +## 1. Go library and vectors + +- [ ] 1.1 Extract `cli/internal/client` and `cli/internal/crypto` into module `sdk/go/`, move the CLI onto it, and keep both stdlib only. Verify with `go vet ./...` and `go test ./...` in `sdk/go/` and `cli/`. +- [ ] 1.2 Replace the envelope struct with the server's shape (`encryption.scheme`, `ciphertext.*`) and fix the CLI's scheme check. Verify with a Go test against an envelope written by `MachineSecretEnvelopeService::serialize()`, and manually with `keepiq ci fetch` against the dev instance. +- [ ] 1.3 Create `sdk/testdata/` with vectors from the PHP serializer (fixture generator in `tests/Unit/`) and the moved browser vector; allow-list the test key by path for secret scanning. Verify with the generator test and a gitleaks run. +- [ ] 1.4 Add encrypt, list with `updatedSince`, by-id, create, update, typed errors and lease reporting to `sdk/go/`. Verify with Go tests against an httptest stub and a PHPUnit test that decrypts Go-encrypted vectors with `DecryptService`. + +## 2. Python and TypeScript + +- [ ] 2.1 Build `sdk/python/` with the D2 surface on `cryptography`. Verify with `pytest` on the shared vectors and the PHPUnit round trip for Python-encrypted vectors. +- [ ] 2.2 Build `sdk/js/` in TypeScript on WebCrypto with no runtime dependency. Verify with vitest on the shared vectors in Node 20 and the PHPUnit round trip for TypeScript-encrypted vectors. +- [ ] 2.3 Add release workflows for `sdk/go/v*`, `sdk-py-v*` (PyPI trusted publishing) and `sdk-js-v*` (npm with provenance). Verify with a dry run of each workflow on a pull request. + +## 3. CI integrations + +- [ ] 3.1 Add `SHA256SUMS` and the `ghcr.io/conductionnl/keepiq-cli` image to `cli-release.yml`. Verify with a workflow dry run and `docker run ghcr.io/conductionnl/keepiq-cli --version`. +- [ ] 3.2 Add `integrations/github-action/action.yml` with the `run` and `export-env` modes, checksum check and masking. Verify with a workflow that runs the action against a stub server and asserts the value is masked in the log. +- [ ] 3.3 Add `integrations/gitlab-ci/keepiq.gitlab-ci.yml` with the `.keepiq` hidden job. Verify with `gitlab-ci-local` or a GitLab lint call in the same workflow. +- [ ] 3.4 Document the libraries, the action and the template on the docs site. Verify with the docs build in `docs/`. + +## Acceptance criteria + +- A Python script with an application id and private key reads a secret by name in under ten lines, and the value never crosses the network in plain form. +- Every library and the CLI decrypt the shared vectors, and `DecryptService` decrypts what each library encrypts. +- `keepiq ci fetch` decrypts an envelope from a real Keepiq instance. +- A GitHub workflow step using the action runs a command with a Keepiq secret in its environment and nothing is written to disk. +- With `export-env: true`, later steps see the value and the log shows it masked. +- A GitLab job extending `.keepiq` runs its command with a Keepiq secret in its environment. diff --git a/openspec/changes/apps-kubernetes-injection/.openspec.yaml b/openspec/changes/apps-kubernetes-injection/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/apps-kubernetes-injection/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/apps-kubernetes-injection/design.md b/openspec/changes/apps-kubernetes-injection/design.md new file mode 100644 index 000000000..8c20578a4 --- /dev/null +++ b/openspec/changes/apps-kubernetes-injection/design.md @@ -0,0 +1,78 @@ +# Design: Kubernetes secret injection + +## Context + +Read at development `4c214a9d`. + +- The machine API is the only surface an operator needs: discovery at `/api/v1/app/.well-known/keepiq` (`appinfo/routes.php:295`), the RFC 7523 token exchange at `/api/v1/token` (`:299`), list, by-id and by-name reads at `/api/v1/app/secrets*` (`:304` to `:309`), and leases at `/api/v1/app/leases*` (`:319` to `:321`). +- Reads return the `doriath-machine-secret-v1` envelope with ciphertext only and a strong ETag; `If-None-Match` yields 304 (`lib/Service/MachineSecretResponseService.php:98` to `:101`). Lease id and expiry arrive as `Doriath-Lease-Id` and `Doriath-Lease-Expires` headers (`:176`, `:177`). +- `cli/internal/client/client.go:137` `Discover()`, `:151` `MachineToken()` and `:215` `FetchByName()` implement discovery, the assertion and the by-name read in stdlib Go. `cli/internal/crypto/crypto.go:139` `DecryptField()` decrypts the `rsa-oaep-sha256-chunked-v1` scheme. Both packages are `internal`, so Go forbids importing them from outside `cli/`. +- `cli/ci.go:97` `cmdCIRun()` injects fetched values into a child process environment only. +- `.github/workflows/cli-release.yml` builds the CLI for six platforms and attaches binaries to `cli-v*` releases. No container image is published. +- `grep -rli 'kubernetes|k8s|helm' lib src cli browser-extension` finds nothing. + +## Goals / Non-Goals + +**Goals:** + +- A Kubernetes user gets a Keepiq application secret into a pod with one custom resource and no scripting. +- Decryption happens only inside the cluster, with an application key the cluster holds. +- A rotated value reaches the pod without a manual step. + +**Non-Goals:** + +- A mutating admission webhook that rewrites pods automatically. The recipe in D5 covers the no-Secret case by hand; a webhook can follow as its own change. +- A CSI driver. +- An External Secrets Operator provider. ESO providers live in the ESO repository and would need the RSA decryption there; that is an upstream contribution, not part of this repository. +- Reading user vaults. The operator is a machine client and sees only its application's vault. + +## Decisions + +### D1: An operator in this repository, on the shared Go SDK + +The operator is a Go module at `integrations/kubernetes/` built with controller-runtime. It imports `sdk/go/` (discovery, token, reads, lease headers, decryption), extracted from `cli/internal/` by change `apps-client-libraries-and-ci`, so the CLI, the operator, the runner and the Terraform provider share one implementation of the crypto recipe. + +Alternative considered: copy the client and crypto code into the operator. Rejected: the chunked RSA-OAEP recipe must stay byte-identical to the browser's (ADR-003, dual implementation); a second copy is a second place to break it. + +### D2: Two custom resources + +`KeepiqConnection` (namespaced) holds `url`, `applicationId` and `privateKeySecretRef` (name and key of a Kubernetes Secret with the application private key PEM). `KeepiqSecret` (namespaced) holds `connectionRef`, `target.name`, `refreshInterval` (default 60 seconds, minimum 10), optional `restartTargets` (Deployments or StatefulSets), and `items`: each item names a Keepiq secret (`name`, optional `folder`), a field (`key`, `login` or `additionalFields.`) and the key in the target Secret. + +One Keepiq application per namespace or team is the recommended layout, so a namespace can read only its own application vault. + +### D3: Reconcile loop + +Per `KeepiqSecret`: load the connection and key, get a bearer token (cached until expiry), fetch each item by name with the last ETag, decrypt changed envelopes in memory, check the envelope's certificate fingerprint against the key before decrypting, write the target Secret with an owner reference, patch a checksum annotation on each restart target when a value changed, record lease id and expiry, and requeue after `refreshInterval`. A 404 or a 409 (ambiguous name, with candidates) sets `Ready=False` with the reason and an event. Plaintext is never logged or put in status or events. + +### D4: Leases + +When discovery advertises leases, the operator renews a lease through `POST /api/v1/app/leases/{id}/renew` before it expires, and treats a refused renewal or a revoked lease as a signal to refetch on the next loop. Against an instance without leases it works unchanged. + +### D5: A no-Secret recipe with the CLI + +For pods that must not keep a value in etcd, the Helm chart documents a pod template: an init container from `ghcr.io/conductionnl/keepiq-cli` copies the static binary into an `emptyDir`, and the app container starts its original command through `/keepiq/keepiq ci run NAME1,NAME2`, which takes that command after its `--` separator. The value then exists only in the child process environment (`cli/ci.go:97`). The application key comes from a Kubernetes Secret mounted as a file (`KEEPIQ_APP_KEY_FILE`). + +### D6: Release and test + +`.github/workflows/integrations-kubernetes.yml` runs `go vet` and `go test` with envtest on pull requests touching `integrations/kubernetes/**`, and a kind cluster test against a stub Keepiq server that serves envelopes from the shared test vectors in `sdk/testdata/`. On a `k8s-v*` tag it builds a multi-arch image to `ghcr.io/conductionnl/keepiq-operator`, signs it with cosign keyless, and pushes the Helm chart as an OCI artifact to `ghcr.io/conductionnl/charts`. A live test against a real instance runs only when `KEEPIQ_LIVE_URL` is set, as the CLI's live test does. + +## Security and zero-knowledge + +- The Keepiq server never sees plaintext and never holds the application private key; nothing changes on the server. +- In the cluster: the application private key sits in a Kubernetes Secret; decrypted values sit in the target Kubernetes Secret (sync mode) or only in process memory (D5). The chart's documentation recommends etcd encryption at rest for sync mode. +- The operator's RBAC is namespaced by default: it reads `KeepiqConnection`, `KeepiqSecret` and the referenced key Secret, and writes only target Secrets it owns. A cluster-wide mode is an explicit chart value. +- Every fetch is audited on the Keepiq side as an application read, with lease id when leases are on. + +## Risks / Trade-offs + +- A Kubernetes Secret is readable by anyone with Secret read rights in the namespace. That is the cluster's access model, and D5 exists for workloads that need more. +- Polling every 60 seconds per `KeepiqSecret` adds load on large clusters. ETag reads are cheap (304, no body), and the interval is configurable. +- A new Go module with controller-runtime brings dependencies the stdlib-only CLI avoided. They stay in `integrations/kubernetes/go.mod` and never enter the CLI binary. + +## Seed data + +None in the app. The kind test registers its application against the stub server; the live test uses an application the operator of the test instance registers by hand. + +## Migration + +None. No server change, so no table, column or `` bump. diff --git a/openspec/changes/apps-kubernetes-injection/proposal.md b/openspec/changes/apps-kubernetes-injection/proposal.md new file mode 100644 index 000000000..6416fb8a0 --- /dev/null +++ b/openspec/changes/apps-kubernetes-injection/proposal.md @@ -0,0 +1,54 @@ +--- +kind: code +--- + +# Kubernetes secret injection + +## Why + +Teams that run workloads on Kubernetes have to script the Keepiq machine API themselves to get a credential into a pod. Every competitor that serves machine secrets ships a ready-made Kubernetes integration. + +| Row | Capability | What keepiq does today | +|---|---|---| +| apps-17 | Inject secrets into a Kubernetes cluster | No Kubernetes secret injection (operator, CSI driver, sidecar) exists; keepiq's machine surface is a plain HTTP+JWT API a cluster could call itself, but nothing ships to do that integration. | + +Matrix: keepiq `openspec/parity/capabilities.json` + +### Demand + +No demand row. + +### Competitors rated yes + +- Bitwarden: "bitwarden/clients@web-v2026.9.0 bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.component.ts:94 Kubernetes Operator; bitwarden/server@v2026.9.1 src/Api/SecretsManager/Controllers/SecretsController.cs:308 secrets/sync used by the operator | docs: https://bitwarden.com/help/secrets-manager-kubernetes-operator/ ..." +- 1Password: "https://developer.1password.com/docs/k8s/integrations/ : Kubernetes Secrets Injector, Operator and Helm charts" +- Keeper: "https://docs.keeper.io/keeperpam/secrets-manager/integrations/kubernetes-external-secrets-operator : External Secrets Operator provider syncs Keeper secrets into Kubernetes Secrets (also a Secrets Injector)" +- HashiCorp Vault: "hashicorp/vault@v2.1.1 go.mod:160 vault-plugin-auth-kubernetes and :173 vault-plugin-secrets-kubernetes bundled; ui/app/router.js mounts the kubernetes engine UI | docs: https://developer.hashicorp.com/vault/docs/platform/k8s/vso ..." + +## What Changes + +- A Kubernetes operator in `integrations/kubernetes/`, released as a container image and a Helm chart from this repository. +- Two custom resources: `KeepiqConnection` (instance URL, application id, and a reference to a Kubernetes Secret holding the application private key) and `KeepiqSecret` (which Keepiq secrets, which fields, which target Kubernetes Secret, how often to refresh). +- The operator exchanges an RFC 7523 assertion for a bearer token, fetches each secret by name through the machine API, decrypts it in its own process with the application private key, and writes the target Kubernetes Secret. +- It polls with `If-None-Match`, so a rotated value reaches the cluster within one refresh interval, and it can restart named Deployments when a value changes. +- It reports status conditions and events on each `KeepiqSecret`, and honours machine leases. +- A documented recipe for pods that must not keep a Kubernetes Secret: an init container copies the static `keepiq` CLI into the pod, and the container starts through `keepiq ci run`, so the value lives only in the process environment. +- No change to the Keepiq server. + +## Capabilities + +### New Capabilities + +- `kubernetes-integration`: a Kubernetes operator and an injection recipe that deliver Keepiq application secrets into pods, decrypting only inside the cluster. + +### Modified Capabilities + +None. + +## Impact + +- **Backend**: none. The operator uses the existing machine API (`/api/v1/token`, `/api/v1/app/secrets*`, `/api/v1/app/leases*`) and discovery document. +- **Frontend**: none. +- **Database**: none. +- **Security**: the Keepiq server keeps serving ciphertext only. Plaintext exists in the operator's memory and in the target Kubernetes Secret, inside the cluster the application owner controls. The application private key stays in the cluster. +- **Cross-app**: the operator imports the shared Go SDK in `sdk/go/` from change `apps-client-libraries-and-ci`; whichever change lands first extracts that module from `cli/internal/`. diff --git a/openspec/changes/apps-kubernetes-injection/specs/kubernetes-integration/spec.md b/openspec/changes/apps-kubernetes-injection/specs/kubernetes-integration/spec.md new file mode 100644 index 000000000..b28e3a3eb --- /dev/null +++ b/openspec/changes/apps-kubernetes-injection/specs/kubernetes-integration/spec.md @@ -0,0 +1,76 @@ +## ADDED Requirements + +### Requirement: Operator syncs application secrets into Kubernetes Secrets + +The Keepiq Kubernetes operator MUST, for each `KeepiqSecret` resource, authenticate as the Keepiq application named by its `KeepiqConnection` through the RFC 7523 token exchange, fetch each listed secret by name through `GET /api/v1/app/secrets/by-name/{name}`, decrypt the envelope inside the operator process with the application private key, and write the chosen fields into the target Kubernetes Secret. The Keepiq server MUST receive no plaintext and MUST NOT be changed for this. + +#### Scenario: Platform engineer syncs a database password + +- **GIVEN** a `KeepiqConnection` for application `shop-prod` with its private key in Kubernetes Secret `keepiq-app-key`, and a Keepiq secret `db-password` in the `shop-prod` vault +- **WHEN** a platform engineer applies a `KeepiqSecret` that maps field `key` of `db-password` to key `DB_PASSWORD` of target Secret `shop-db` +- **THEN** Kubernetes Secret `shop-db` MUST contain `DB_PASSWORD` with the decrypted value +- **AND** the `KeepiqSecret` MUST report condition `Ready=True` + +### Requirement: The application key stays in the cluster + +The operator MUST read the application private key only from the Kubernetes Secret named in `KeepiqConnection.privateKeySecretRef`, MUST NOT send it to any endpoint, and MUST check each envelope's certificate fingerprint against that key before decrypting. The operator MUST NOT write any decrypted value to its logs, resource status or events. + +#### Scenario: Wrong key is reported, not used + +- **GIVEN** a `KeepiqConnection` whose key Secret holds a key that does not match the application's certificate +- **WHEN** the operator reconciles a `KeepiqSecret` on that connection +- **THEN** the `KeepiqSecret` MUST report `Ready=False` with reason `FingerprintMismatch` +- **AND** the target Secret MUST NOT change + +### Requirement: Rotated values reach the cluster + +The operator MUST poll each item with `If-None-Match` at the resource's `refreshInterval` (default 60 seconds, minimum 10 seconds). When a value changed, it MUST update the target Secret and MUST patch a checksum annotation on each listed restart target so the workload restarts. When nothing changed, it MUST NOT write the target Secret. + +#### Scenario: Rotation restarts the workload + +- **GIVEN** a `KeepiqSecret` for `db-password` with restart target Deployment `shop-api` +- **WHEN** a machine client writes a new value for `db-password` through `PUT /api/v1/app/secrets/{id}` +- **THEN** Kubernetes Secret `shop-db` MUST hold the new value within one refresh interval +- **AND** Deployment `shop-api` MUST roll out new pods + +### Requirement: Errors are visible on the resource + +The operator MUST report `Ready=False` with a reason and an event for an unknown name (404), an ambiguous name (409, listing the candidates' ids and folder paths), a refused token and a fingerprint mismatch, and MUST leave the target Secret unchanged in each case. + +#### Scenario: Ambiguous name + +- **GIVEN** two secrets named `api-token` in the application vault +- **WHEN** a `KeepiqSecret` asks for `api-token` without a folder +- **THEN** the resource MUST report `Ready=False` with reason `AmbiguousName` +- **AND** an event MUST list both candidates' ids and folder paths + +### Requirement: Leases are honoured when advertised + +When the discovery document advertises lease support, the operator MUST record the `Doriath-Lease-Id` and `Doriath-Lease-Expires` headers in the resource status, MUST renew the lease through `POST /api/v1/app/leases/{id}/renew` before it expires, and MUST refetch after a refused renewal. Against an instance without lease support it MUST work unchanged. + +#### Scenario: Lease is renewed before expiry + +- **GIVEN** an instance that advertises leases and a lease that expires in two minutes +- **WHEN** the operator's next loop runs +- **THEN** the operator MUST renew the lease and record the new expiry in status + +### Requirement: Pods can receive values without a Kubernetes Secret + +The Helm chart MUST document a pod recipe in which an init container copies the static `keepiq` CLI from the published CLI image into the pod, and the container starts its original command through `keepiq ci run `, which takes that command after its `--` separator, so values exist only in the process environment. + +#### Scenario: Recipe pod reads its password from the environment + +- **GIVEN** a pod built from the documented recipe for secret `db-password` +- **WHEN** the pod starts +- **THEN** the main process MUST see `KEEPIQ_DB_PASSWORD` in its environment +- **AND** no Kubernetes Secret in the namespace MUST contain the value + +### Requirement: The operator is released from this repository + +A tag `k8s-v` MUST publish a signed multi-arch container image and a Helm chart built from `integrations/kubernetes/`. Pull requests touching that directory MUST run its unit, envtest and kind tests. + +#### Scenario: Tagged release publishes image and chart + +- **GIVEN** a maintainer pushes tag `k8s-v0.1.0` +- **WHEN** the release workflow finishes +- **THEN** image `ghcr.io/conductionnl/keepiq-operator:0.1.0` and chart version `0.1.0` MUST be published diff --git a/openspec/changes/apps-kubernetes-injection/tasks.md b/openspec/changes/apps-kubernetes-injection/tasks.md new file mode 100644 index 000000000..b7ba83158 --- /dev/null +++ b/openspec/changes/apps-kubernetes-injection/tasks.md @@ -0,0 +1,27 @@ +## 1. Module and resources + +- [ ] 1.1 Create the Go module `integrations/kubernetes/` on controller-runtime, importing `sdk/go/`; if `sdk/go/` does not exist yet, extract it from `cli/internal/client` and `cli/internal/crypto` first. Verify with `go vet ./...` and `go test ./...` in both modules. +- [ ] 1.2 Define the `KeepiqConnection` and `KeepiqSecret` CRDs with validation (refresh minimum, field syntax). Verify with envtest tests that invalid resources are rejected. + +## 2. Reconcile + +- [ ] 2.1 Implement the reconcile loop: token cache, by-name fetch with ETag, fingerprint check, in-memory decrypt, target Secret with owner reference, requeue. Verify with envtest tests against an httptest stub serving envelopes from `sdk/testdata/`. +- [ ] 2.2 Set `Ready` conditions and events for 404, 409 with candidates, token refusal and fingerprint mismatch, never including a value. Verify with envtest tests that assert status and events contain no plaintext. +- [ ] 2.3 Patch a checksum annotation on each restart target when a value changes. Verify with an envtest test that the Deployment template annotation changes once per rotation. +- [ ] 2.4 Renew leases before expiry and refetch after a refused renewal. Verify with envtest tests against a stub that advertises leases and one that does not. + +## 3. Distribution + +- [ ] 3.1 Add the Helm chart with namespaced RBAC by default and a cluster-wide option. Verify with `helm lint` and `helm template` snapshot tests in CI. +- [ ] 3.2 Document the no-Secret recipe (init container with the CLI image, `keepiq ci run` wrapper) in the chart README and the docs site. Verify with a kind test that starts a pod with the recipe and reads the value from the process environment. +- [ ] 3.3 Add `.github/workflows/integrations-kubernetes.yml`: tests on pull requests, kind test, signed multi-arch image and OCI chart on `k8s-v*` tags. Verify with a dry run of the workflow on a pull request. +- [ ] 3.4 Add a live test gated by `KEEPIQ_LIVE_URL` that syncs one secret from a real instance. Verify manually against the dev instance. + +## Acceptance criteria + +- A `KeepiqSecret` naming an application secret produces a Kubernetes Secret with the decrypted value within one refresh interval. +- Changing the value in Keepiq updates the Kubernetes Secret within one refresh interval and, when configured, restarts the named Deployment. +- No request from the operator to Keepiq carries plaintext, and no status, event or log line carries a value. +- A 409 for an ambiguous name leaves the target Secret unchanged and shows the candidates in an event. +- The recipe pod reads the value from its process environment and no Kubernetes Secret holds it. +- A tagged release publishes a signed image and a Helm chart. diff --git a/openspec/changes/apps-secret-sync-and-rotation-runner/.openspec.yaml b/openspec/changes/apps-secret-sync-and-rotation-runner/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/apps-secret-sync-and-rotation-runner/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/apps-secret-sync-and-rotation-runner/design.md b/openspec/changes/apps-secret-sync-and-rotation-runner/design.md new file mode 100644 index 000000000..b3719376f --- /dev/null +++ b/openspec/changes/apps-secret-sync-and-rotation-runner/design.md @@ -0,0 +1,90 @@ +# Design: secret rotation and sync runner + +## Context + +Read at development `4c214a9d`. + +- Rotation today is reminders and a proof check: `openspec/specs/rotation-expiry-policies/spec.md` ("Proven mark-rotated flow") closes a flag only when `key_updated_at` advanced; `lib/Controller/RotationController.php` and `src/store/modules/rotation.js` serve it to users. Nothing changes a credential at its target. +- The machine API: token exchange `appinfo/routes.php:299`; list, by-id, by-name, create and update at `:304` to `:309`. `lib/Controller/ApplicationSecretsController.php:329` `update()` replaces ciphertext through `lib/Service/SecretService.php` `updateByApplication()`, which scopes to the application's own vault, advances `key_updated_at` when the key changes, snapshots the previous version and audits `SECRET_UPDATED` with the application as actor. It has no precondition: a write based on a stale read silently wins. +- `lib/Service/MachineSecretEnvelopeService.php:129` `serialize()` returns `format`, `secret` (id, name, url, folderPath, type, createdAt, updatedAt, keyUpdatedAt), `encryption` and `ciphertext`. `expiresAt` exists on the entity (`lib/Db/Secret.php:173`) but not in the envelope. +- `lib/Service/MachineSecretResponseService.php:98` handles `If-None-Match` for reads. +- The machine API has no delete by design (`openspec/specs/secret-store-api/spec.md`, "Application Write-Back"). +- `cli/` is a stdlib-only single binary (`cli/README.md`); `sdk/go/` is introduced by change `apps-client-libraries-and-ci`. +- `git grep -i 'aws|azure|key vault' lib src` finds nothing. + +## Goals / Non-Goals + +**Goals:** + +- A credential in an application vault is rotated at its target on a schedule, with proof that the new value works before Keepiq records it. +- Secrets in an application vault reach AWS Secrets Manager, Azure Key Vault and GitHub Actions secrets and stay in sync. +- The Keepiq server never sees plaintext and needs no key. + +**Non-Goals:** + +- Rotating secrets in user vaults. Only an application's own key can decrypt its vault; a user vault needs the user's master password, which never leaves their client. +- Giving humans a readable copy of a rotated value. Application vault values are readable only by the application (write without read). A team that needs a human copy points an `exec` destination at its own process. +- Deleting at the destination when a Keepiq secret is deleted. The machine API cannot see deletions; the runner reports destination entries it no longer finds in Keepiq. +- More connectors in v1 (LDAP, Active Directory, GCP, Vercel). The `exec` hook covers them until a native connector follows. + +## Decisions + +### D1: A separate runner binary, not a CLI mode + +`integrations/runner/` is a Go module on `sdk/go/`, producing `keepiq-runner` and image `ghcr.io/conductionnl/keepiq-runner`. It runs as a daemon or once (`keepiq-runner run --once`) for cron and Kubernetes CronJobs. + +Alternative considered: a `keepiq runner` mode in the CLI. Rejected: database drivers and cloud SDKs would end the CLI's stdlib-only, dependency-free build that its README and spec promise. + +### D2: Configuration names Keepiq secrets, never values + +`runner.yaml` holds the Keepiq URL, application id and private key file, then `rotations` (secret name and folder, connector, target address, the Keepiq secret holding the admin credential for the target, cron schedule, `followExpiry`, generator length and character classes) and `syncs` (secret names, destination, destination address, the Keepiq secret holding destination credentials, or ambient cloud identity). Every credential the runner needs is itself a secret in the same application vault. + +### D3: Rotation is prove-then-record, with a journal + +For one rotation: + +1. Read the secret and its ETag. +2. Generate the new value with `crypto/rand`. +3. Append to the local journal the new value encrypted to the application's own public key, plus the secret id and ETag. The journal holds ciphertext only. +4. Set the new value at the target with the admin credential (`ALTER ROLE ... PASSWORD` for PostgreSQL, `ALTER USER ... IDENTIFIED BY` for MySQL, or the `exec` hook with current and new values on stdin as JSON). +5. Log in to the target with the new value. +6. `PUT /api/v1/app/secrets/{id}` with the new ciphertext and `If-Match` set to the ETag from step 1. +7. Remove the journal entry. + +If step 5 fails, the runner sets the old value back at the target and leaves Keepiq unchanged. If step 6 fails or the process dies after step 4, the next start decrypts the journal entry and retries the write-back. On 412 the value changed in Keepiq during the rotation; the runner sets the old value back at the target and reports a conflict. + +Alternative considered: write the new value to Keepiq first, then change the target. Rejected: until the target accepts it, every consumer polling Keepiq would read a password that does not work yet. + +### D4: When a rotation is due + +A rotation runs when its cron schedule fires, or, with `followExpiry`, when the envelope's `expiresAt` is within the configured lead time. Because the write-back advances `key_updated_at`, any open rotation flag on that secret meets the existing proof rule. + +### D5: Sync polls `updated_since` and pushes on change + +Every sync interval (default 60 seconds) the runner lists the application's secrets with `updated_since`, fetches each changed secret in a sync set, decrypts it, and pushes it: `PutSecretValue` (creating on first sync) for AWS Secrets Manager, `SetSecret` for Azure Key Vault, and the GitHub REST secrets API for repository, environment or organisation secrets, encrypted with the repository public key as a libsodium sealed box as GitHub requires. The state directory keeps, per destination entry, the ETag last pushed, never a value. A failed push is retried with backoff and never blocks other entries. + +### D6: Two additive changes to the machine API + +`PUT /api/v1/app/secrets/{id}` accepts `If-Match`; when it does not match the current strong ETag the server answers 412 and changes nothing. A write without `If-Match` behaves as today. The envelope's `secret` block gains `expiresAt` (ISO 8601 or null). Both are additive to `doriath-machine-secret-v1`, so existing consumers keep working, and the discovery document advertises `conditionalWrite: true` and `expiresAt: true`. + +## Security and zero-knowledge + +- The server never sees plaintext or the application private key. Rotation writes back ciphertext produced in the runner; sync reads ciphertext and decrypts in the runner. +- Encrypted: the write-back value (RSA to the application key), the journal entries (same), everything on the wire to Keepiq. Plain, outside Keepiq: the value in the runner's memory, at the target, and at the destination, which is the purpose of rotation and sync. +- Plain in the state directory: secret ids, destination names and ETags only. +- Every rotation is audited on the server as `SECRET_UPDATED` by the application, with the previous version kept by version history, so an administrator can see and roll back a rotation. +- The runner's host holds the application key; the docs recommend one application per runner with only the secrets it rotates or syncs. + +## Risks / Trade-offs + +- A bug in a connector can lock a service out. The prove-then-record order and the automatic set-back on a failed login keep the old credential working until the new one is proven. +- Sync copies plaintext into another system with its own access model. That is the request; the docs state it, and the runner pushes only secrets named in a sync set. +- A cloud SDK per destination grows the runner. They stay in `integrations/runner/go.mod`, away from the CLI and the libraries. + +## Seed data + +None in the app. The runner's tests start PostgreSQL and MySQL containers and a stub Keepiq server serving the shared vectors from `sdk/testdata/`; cloud destinations are tested against local emulators (LocalStack for AWS, an httptest stub for Azure and GitHub). + +## Migration + +None. `expires_at` already exists; the server changes are code only. `` in `appinfo/info.xml` does not need a bump for schema reasons. diff --git a/openspec/changes/apps-secret-sync-and-rotation-runner/proposal.md b/openspec/changes/apps-secret-sync-and-rotation-runner/proposal.md new file mode 100644 index 000000000..e3c62781e --- /dev/null +++ b/openspec/changes/apps-secret-sync-and-rotation-runner/proposal.md @@ -0,0 +1,55 @@ +--- +kind: code +--- + +# Secret rotation and sync runner + +## Why + +Keepiq reminds people to rotate and lets them mark a secret rotated, but it never changes a password at the database or service itself, and it never pushes a secret to a cloud secret store. The server cannot do either: it never sees plaintext. This change puts both jobs in a runner that holds an application's private key. + +| Row | Capability | What keepiq does today | +|---|---|---| +| apps-20 | Rotate a database or service password automatically | keepiq flags stale/expiring secrets and lets a user mark one rotated, but it never rotates a password at the destination service itself the way Vault/1Password-style rotation connectors do. | +| apps-25 | Push secrets out to cloud secret stores such as AWS Secrets Manager, Azure Key Vault or GitHub and keep them in sync | No push of secrets to cloud secret stores. | + +Matrix: keepiq `openspec/parity/capabilities.json` + +The matrix recorded apps-20 as built; the decision corrected that: the evidence shows only reminders and a manual mark-rotated flow, so the state before this change is none. + +### Demand + +No demand row. + +### Competitors rated yes + +- Keeper (apps-20): "https://docs.keeper.io/keeperpam/privileged-access-manager/password-rotation/rotation-overview : scheduled rotation of database, AD, cloud and machine credentials via the Keeper Gateway (KeeperPAM / rotation add-on)" +- HashiCorp Vault (apps-20): "hashicorp/vault@v2.1.1 builtin/logical/database/path_creds_create.go:57 static-creds/; builtin/logical/database/path_rotate_credentials.go:21 rotate-root, :48 rotate-role Note: Static roles rotate a database user's password on a period or schedule, with manual rotate endpoints." +- Keeper (apps-25): "https://docs.keeper.io/keeperpam/privileged-access-manager/universal-secrets-sync : 'Synchronize Shared Secrets to Cloud Secret Management Services'; Universal Secrets Sync automatically pushes shared secrets to cloud secret stores (KeeperPAM)." +- HashiCorp Vault (apps-25): "hashicorp/vault@v2.1.1 ui/lib/sync/addon/routes.js:10 destinations and sync routes; ui/lib/sync/addon/utils/constants.ts:12 aws-sm, azure-kv, gcp-sm, gh, vercel-project; ... | docs: https://developer.hashicorp.com/vault/docs/sync ..." + +## What Changes + +- A runner, `keepiq-runner`, in `integrations/runner/`, released as a static binary and a container image. It authenticates as one Keepiq application and works only on that application's vault through the machine API under `/api/v1/app/*`. +- **Rotation**: on a cron schedule or when a secret nears its expiry date, the runner generates a new password locally, sets it at the target (PostgreSQL, MySQL, or any system through an `exec` hook), logs in with it to prove it works, and writes it back to Keepiq encrypted to the application's own key. +- A local recovery journal holds the new value encrypted to the application's key between the target change and the write-back, so a crash never loses the only copy. +- **Sync**: the runner polls `updated_since`, decrypts changed secrets, and pushes them to AWS Secrets Manager, Azure Key Vault, GitHub Actions secrets or an `exec` hook. +- Two additive server changes to the machine API: `PUT /api/v1/app/secrets/{id}` honours `If-Match` and answers 412 on a mismatch, and the envelope carries `expiresAt`. + +## Capabilities + +### New Capabilities + +- `secret-rotation-runner`: a runner outside the server that rotates credentials at their target and pushes secrets to cloud secret stores, decrypting only with an application's own key. + +### Modified Capabilities + +- `secret-store-api`: conditional write-back with `If-Match`, and the secret's expiry date in the machine envelope. + +## Impact + +- **Backend**: `ApplicationSecretsController::update()` and `SecretService::updateByApplication()` check `If-Match`; `MachineSecretEnvelopeService::serialize()` adds `expiresAt`; the discovery document advertises both. +- **Frontend**: none. +- **Database**: none. `expires_at` already exists on `keepiq_secrets`. +- **Security**: the server keeps receiving and returning ciphertext only. Plaintext exists in the runner's memory, at the rotation target and at the sync destination, all outside the Keepiq server and under the application owner's control. +- **Cross-app**: the runner builds on `sdk/go/` from change `apps-client-libraries-and-ci`. OpenConnector and other machine consumers see rotated values through the existing ETag and `updated_since` polling. diff --git a/openspec/changes/apps-secret-sync-and-rotation-runner/specs/secret-rotation-runner/spec.md b/openspec/changes/apps-secret-sync-and-rotation-runner/specs/secret-rotation-runner/spec.md new file mode 100644 index 000000000..857102af4 --- /dev/null +++ b/openspec/changes/apps-secret-sync-and-rotation-runner/specs/secret-rotation-runner/spec.md @@ -0,0 +1,77 @@ +## ADDED Requirements + +### Requirement: Runner works on one application's vault outside the server + +The project MUST ship `keepiq-runner`, built from `integrations/runner/`, that authenticates as one Keepiq application with that application's private key and uses only the machine API under `/api/v1/app/*`. It MUST decrypt and encrypt only in its own process, MUST NOT send the private key or any plaintext to Keepiq, and MUST NOT write any plaintext value to its logs or state directory. + +#### Scenario: Runner starts with an application key + +- **GIVEN** an approved application `ops-runner` and a `runner.yaml` pointing at its private key file +- **WHEN** an operator starts `keepiq-runner run --once` +- **THEN** the runner MUST obtain a bearer token through `POST /api/v1/token` +- **AND** no request body or log line MUST contain the private key or a secret value + +### Requirement: Rotation proves the new value before Keepiq records it + +For each configured rotation, the runner MUST generate a new value locally, record it in a local journal encrypted to the application's own key, set it at the target, log in to the target with it, and only then write it back through `PUT /api/v1/app/secrets/{id}` with `If-Match` set to the ETag it read. When the login fails, the runner MUST set the old value back at the target and MUST leave Keepiq unchanged. + +#### Scenario: Weekly database password rotation + +- **GIVEN** application `ops-runner` owns secret `pg-app-password` and a rotation for it with the `postgres` connector and schedule `0 3 * * 0` +- **WHEN** the schedule fires +- **THEN** the PostgreSQL role MUST accept the new password and refuse the old one +- **AND** `GET /api/v1/app/secrets/by-name/pg-app-password` MUST return an envelope whose decrypted value is the new password + +#### Scenario: Failed proof keeps the old credential + +- **GIVEN** a rotation whose target accepts the change but refuses the login with the new value +- **WHEN** the rotation runs +- **THEN** the target MUST be set back to the old value +- **AND** the secret in Keepiq MUST keep its previous ciphertext and ETag + +### Requirement: A crashed rotation is completed from the journal + +When the runner starts and finds a journal entry, it MUST decrypt it with the application key and retry the write-back. After a successful write-back it MUST remove the entry. The journal MUST hold ciphertext only. + +#### Scenario: Power loss after the target changed + +- **GIVEN** the runner set a new value at the target and stopped before the write-back +- **WHEN** the runner starts again +- **THEN** it MUST write the journalled value back to Keepiq and remove the journal entry + +### Requirement: Concurrent changes are never overwritten + +When the conditional write-back answers 412, the runner MUST set the old value back at the target, MUST NOT retry the write, and MUST report a conflict naming the secret. + +#### Scenario: Human changed the secret during rotation + +- **GIVEN** a rotation read `pg-app-password` with ETag `A` and another client updated it to ETag `B` +- **WHEN** the runner writes back with `If-Match: A` +- **THEN** the server MUST answer 412 +- **AND** the runner MUST restore the old value at the target and log a conflict for `pg-app-password` + +### Requirement: Rotations run on schedule or ahead of expiry + +A rotation MUST run when its cron schedule fires, and, when `followExpiry` is set, when the envelope's `expiresAt` falls within the configured lead time. + +#### Scenario: Expiry triggers a rotation + +- **GIVEN** a rotation with `followExpiry` and a lead time of 7 days, and a secret whose `expiresAt` is in 5 days +- **WHEN** the runner checks its rotations +- **THEN** it MUST rotate that secret + +### Requirement: Sync pushes changed secrets to cloud secret stores + +For each configured sync set, the runner MUST poll `GET /api/v1/app/secrets?updated_since=`, decrypt each changed secret in the set, and push it to the destination: AWS Secrets Manager, Azure Key Vault, GitHub Actions secrets (encrypted as a sealed box with the repository public key) or an `exec` hook. It MUST keep per destination entry only the ETag last pushed, MUST NOT push an unchanged secret again, and MUST retry a failed push with backoff without blocking other entries. + +#### Scenario: Rotated key reaches AWS + +- **GIVEN** a sync set with secret `stripe-key` and destination `aws-secrets-manager` with prefix `prod/` +- **WHEN** `stripe-key` is updated in the application vault +- **THEN** AWS Secrets Manager secret `prod/stripe-key` MUST hold the new value within one sync interval + +#### Scenario: GitHub secret is sealed for the repository + +- **GIVEN** a sync set with destination `github-actions` for repository `example/app` +- **WHEN** the runner pushes `deploy-token` +- **THEN** the request to GitHub MUST carry the value encrypted with the repository public key diff --git a/openspec/changes/apps-secret-sync-and-rotation-runner/specs/secret-store-api/spec.md b/openspec/changes/apps-secret-sync-and-rotation-runner/specs/secret-store-api/spec.md new file mode 100644 index 000000000..68c0ac83e --- /dev/null +++ b/openspec/changes/apps-secret-sync-and-rotation-runner/specs/secret-store-api/spec.md @@ -0,0 +1,28 @@ +## ADDED Requirements + +### Requirement: Conditional machine write-back + +`PUT /api/v1/app/secrets/{id}` MUST accept an `If-Match` header. When the header is present and does not equal the secret's current strong ETag, the server MUST answer 412 Precondition Failed and MUST NOT change the secret. When the header is absent, the endpoint MUST behave as before. The discovery document MUST advertise `conditionalWrite: true`. + +#### Scenario: Stale write is refused + +- **GIVEN** an application read secret `pg-app-password` with ETag `A`, and the secret was later updated to ETag `B` +- **WHEN** the application calls `PUT /api/v1/app/secrets/{id}` with `If-Match: A` +- **THEN** the response MUST be 412 +- **AND** the stored ciphertext MUST still be the one behind ETag `B` + +#### Scenario: Matching write succeeds + +- **GIVEN** an application holds the current ETag of its secret +- **WHEN** it calls `PUT /api/v1/app/secrets/{id}` with that ETag in `If-Match` and new ciphertext +- **THEN** the ciphertext MUST be replaced and a new ETag returned + +### Requirement: Expiry date in the machine envelope + +The `secret` block of the machine envelope MUST include `expiresAt` as an ISO 8601 timestamp, or null when the secret has no expiry. Adding it MUST NOT change any other envelope field. The discovery document MUST advertise `expiresAt: true`. + +#### Scenario: Consumer reads the expiry date + +- **GIVEN** an application secret with an expiry date of 1 December 2026 +- **WHEN** the application fetches it through `GET /api/v1/app/secrets/{id}` +- **THEN** the envelope's `secret.expiresAt` MUST be `2026-12-01T00:00:00+00:00` diff --git a/openspec/changes/apps-secret-sync-and-rotation-runner/tasks.md b/openspec/changes/apps-secret-sync-and-rotation-runner/tasks.md new file mode 100644 index 000000000..a51c06ecc --- /dev/null +++ b/openspec/changes/apps-secret-sync-and-rotation-runner/tasks.md @@ -0,0 +1,32 @@ +## 1. Machine API additions + +- [ ] 1.1 Honour `If-Match` in `ApplicationSecretsController::update()` and answer 412 on a mismatch without writing. Verify with PHPUnit tests in `tests/Unit/Controller/ApplicationSecretsControllerTest.php` for match, mismatch and absent header. +- [ ] 1.2 Add `expiresAt` to the envelope's `secret` block and advertise `conditionalWrite` and `expiresAt` in the discovery document. Verify with PHPUnit tests on `MachineSecretEnvelopeService` and `DiscoveryController`, and a new assertion in `tests/integration/machine-secret-api.postman_collection.json`. + +## 2. Runner core + +- [ ] 2.1 Create module `integrations/runner/` on `sdk/go/` with config loading, daemon and `run --once` modes, and a structured log that never contains a value. Verify with Go tests for config validation and a log test that greps for the test value. +- [ ] 2.2 Implement the rotation procedure with generator, journal (ciphertext only), set, prove, conditional write-back, set-back on failed login, and recovery from the journal. Verify with Go tests that kill the process after the target change and assert the next start completes the write-back. +- [ ] 2.3 Schedule rotations by cron and by `expiresAt` lead time. Verify with Go tests using a fake clock. + +## 3. Connectors and destinations + +- [ ] 3.1 Add the `postgres` and `mysql` rotation connectors. Verify with Go integration tests against PostgreSQL and MySQL containers that log in with the new password and fail with the old one. +- [ ] 3.2 Add the `exec` connector and `exec` destination (JSON on stdin, exit code as result). Verify with Go tests using a script fixture. +- [ ] 3.3 Implement sync with `updated_since` polling, per-entry ETag state and backoff. Verify with Go tests against the stub server that a changed secret is pushed once and an unchanged one never. +- [ ] 3.4 Add the `aws-secrets-manager`, `azure-key-vault` and `github-actions` destinations. Verify with Go tests against LocalStack and httptest stubs, including the sealed-box encryption for GitHub. + +## 4. Release + +- [ ] 4.1 Add `.github/workflows/integrations-runner.yml`: tests on pull requests, static binaries and a signed image on `runner-v*` tags. Verify with a dry run on a pull request. +- [ ] 4.2 Document setup, the application-per-runner advice and every connector on the docs site. Verify with the docs build in `docs/`. +- [ ] 4.3 Rotate a real credential end to end. Verify manually on the dev instance: an application owns `pg-app-password`, the runner rotates it at a local PostgreSQL, and `keepiq ci fetch pg-app-password` returns a value that logs in. + +## Acceptance criteria + +- A scheduled rotation changes the PostgreSQL password, proves the new one by logging in, and only then stores it in Keepiq. +- If the new password does not log in, the old one keeps working and Keepiq is unchanged. +- A crash between the target change and the write-back is repaired on the next start. +- A concurrent change in Keepiq makes the write-back fail with 412 and the runner restores the old target value. +- A changed secret in a sync set reaches AWS Secrets Manager, Azure Key Vault or GitHub within one sync interval. +- No request from the runner to Keepiq, and no log line or state file, contains a plaintext value. diff --git a/openspec/changes/apps-terraform-provider/.openspec.yaml b/openspec/changes/apps-terraform-provider/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/apps-terraform-provider/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/apps-terraform-provider/design.md b/openspec/changes/apps-terraform-provider/design.md new file mode 100644 index 000000000..684c93128 --- /dev/null +++ b/openspec/changes/apps-terraform-provider/design.md @@ -0,0 +1,80 @@ +# Design: Terraform provider + +## Context + +Read at development `4c214a9d`. + +- The machine API covers what a provider needs for secrets: token exchange (`appinfo/routes.php:299`), list, by-id, by-name, create and update (`:304` to `:309`), ETag reads (`lib/Service/MachineSecretResponseService.php:98`). There is no delete route on purpose (`openspec/specs/secret-store-api/spec.md`, "Application Write-Back", scenario "Machine deletion refused"). +- `lib/Controller/ApplicationSecretsController.php:283` `create()` and `:329` `update()` accept fields already encrypted to the application's own certificate; the server validates shape only. +- Applications are registered and approved through session routes (`appinfo/routes.php:271` to `:286`); change `admin-public-api` adds `/api/v1/admin/applications` for scripts. +- `cli/internal/crypto/crypto.go` holds the Go crypto recipe; change `apps-client-libraries-and-ci` moves it to `sdk/go/` and adds encryption. +- `grep -rli terraform` over the repository finds nothing. +- Terraform stores every resource and data source attribute in plan and state files. Terraform 1.10 added ephemeral resources, which are never stored; Terraform 1.11 added write-only arguments, which are sent to the provider and never stored. OpenTofu added the same two features in its own releases; the provider docs state the tested minimum OpenTofu version. + +## Goals / Non-Goals + +**Goals:** + +- Declare application secrets and applications in Terraform or OpenTofu. +- Keep every secret value out of plan and state. +- Publish in the registries where Terraform and OpenTofu users look. + +**Non-Goals:** + +- User vault secrets. The provider is a machine client; user vaults need a master password that never leaves the user's client. +- A data source that returns a value. Data source attributes are written to state; the ephemeral resource replaces it. +- Hard deletion of secrets (see D4). +- Terraform versions before 1.11. Older versions cannot keep a written value out of state; the provider refuses to plan a `value_wo` there with a clear message. + +## Decisions + +### D1: Source here, published through a mirror repository + +The provider is a Go module at `integrations/terraform-provider-keepiq/` using `terraform-plugin-framework` and `sdk/go/`. The Terraform Registry only indexes public repositories named `terraform-provider-` with GPG-signed releases. A workflow on tag `tf-v*` pushes the module to `ConductionNL/terraform-provider-keepiq`, where GoReleaser builds, signs and publishes the release; the registries pick it up from there. Creating that repository and its deploy key is a one-time organisation admin step. + +Alternative considered: develop the provider only in its own repository. Rejected: the crypto recipe and its vectors live here, and the provider must fail CI in the same pull request that changes them. + +### D2: Provider configuration + +`url`, `application_id`, `private_key` (sensitive; defaults to `KEEPIQ_APP_KEY` or the file in `KEEPIQ_APP_KEY_FILE`) for secrets, and optionally `admin_username` and `admin_app_password` (sensitive; defaults to `KEEPIQ_ADMIN_USER` and `KEEPIQ_ADMIN_APP_PASSWORD`) for application resources. The provider discovers the instance and caches the bearer token for the run. + +### D3: Values only through ephemeral reads and write-only arguments + +- `ephemeral "keepiq_secret"` takes `name` and optional `folder`, or `id`, and returns `value`, `login` and `additional_fields`, decrypted in the provider. Terraform never persists them. +- `resource "keepiq_secret"` takes `name`, `folder`, `url`, and the write-only `value_wo`, `login_wo` and `additional_fields_wo`, plus `value_wo_version`. The provider encrypts the write-only values to the public half of the application key, after checking it against the vault's certificate fingerprint, and creates or updates the secret. A change of `value_wo_version` triggers an update. State holds `id`, metadata, `etag` and `key_updated_at`. +- Refresh reads the envelope. When `key_updated_at` moved outside Terraform (a rotation by the runner, for example), the provider records the new timestamp and reports no diff, because Terraform cannot know the value; `value_wo_version` stays the only trigger for a Terraform write. +- `data "keepiq_secret_metadata"` returns id, timestamps, `expires_at` and fingerprint, never a value. + +Alternative considered: a classic `sensitive` value attribute. Rejected: `sensitive` hides a value in output but still writes it to state in plain form. + +### D4: Destroy removes from state and warns + +The machine API refuses deletion by design: a leaked five-minute bearer token must not be able to destroy credentials. On destroy, `keepiq_secret` is removed from state and the provider emits a warning naming the secret and saying that an administrator deletes it in Keepiq. Import (`terraform import keepiq_secret.x `) adopts an existing secret. + +### D5: Applications through the admin API + +`keepiq_application` takes `name`, `description` and `csr_pem`, registers the application, approves it through `POST /api/v1/admin/applications/{id}/approve`, and exports `id` and `certificate_pem`. The private key stays with whoever made the CSR; the docs warn that generating it with `tls_private_key` stores it in state. Deleting an application deletes its vault, so destroy requires `allow_vault_deletion = true` on the resource and otherwise fails with an explanation. `keepiq_application_lease_policy` manages the lease TTL policy through the admin API. + +### D6: Tests and docs + +Unit tests run the provider against an httptest stub that serves the shared vectors from `sdk/testdata/`, with `terraform-plugin-testing`, and assert that no plan or state file contains the test value. Acceptance tests (`TF_ACC=1`) run against a real instance when `KEEPIQ_LIVE_URL` is set. Documentation is generated with `tfplugindocs` into the module's `docs/` folder, as the registry requires. + +## Security and zero-knowledge + +- The server never sees plaintext: the provider encrypts before `POST` or `PUT` and decrypts after reads, in its own process. +- Not stored anywhere by Terraform: secret values (ephemeral outputs and write-only arguments). Stored plain in state: ids, names, folders, URLs, timestamps, ETags, the application certificate. The private key and admin app password are provider arguments marked sensitive and taken from the environment by default. +- The test suite fails when a plan or state file contains the test value, so a regression that leaks a value into state cannot merge. + +## Risks / Trade-offs + +- Terraform 1.11, or an OpenTofu release with write-only arguments, is the minimum for managed values. Terraform 1.10 can still use the ephemeral read. +- Destroy does not delete the secret in Keepiq. The warning names it; the alternative would give a machine token deletion rights the API refuses on purpose. +- The mirror repository adds a release hop. The workflow is the only writer, and the mirror's README says changes go to this repository. + +## Seed data + +None in the app. The acceptance tests register their own application through the admin API on the test instance. + +## Migration + +None. No server change, so no table, column or `` bump. diff --git a/openspec/changes/apps-terraform-provider/proposal.md b/openspec/changes/apps-terraform-provider/proposal.md new file mode 100644 index 000000000..3e02cc431 --- /dev/null +++ b/openspec/changes/apps-terraform-provider/proposal.md @@ -0,0 +1,55 @@ +--- +kind: code +--- + +# Terraform provider + +## Why + +Teams that manage infrastructure as code cannot declare Keepiq secrets or applications in Terraform or OpenTofu. They copy values by hand or script the machine API. The usual provider pattern also puts secret values in Terraform state, which a zero-knowledge vault must avoid. + +| Row | Capability | What keepiq does today | +|---|---|---| +| apps-22 | Manage secrets as code with a Terraform provider | No Terraform provider exists for managing keepiq secrets/applications as code. | + +Matrix: keepiq `openspec/parity/capabilities.json` + +### Demand + +No demand row. + +### Competitors rated yes + +- Bitwarden: "bitwarden/clients@web-v2026.9.0 bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.component.ts:101 Terraform Provider (registry.terraform.io/providers/bitwarden/bitwarden-secrets) Note: Terraform provider listed in product; its code is in a separate repo. Docs rating kept." +- 1Password: "https://developer.1password.com/docs/terraform : reference, create or update items as Terraform resources" +- Keeper: "https://docs.keeper.io/keeperpam/secrets-manager/integrations/terraform : Terraform Provider for Keeper Secrets Manager" +- HashiCorp Vault: "hashicorp/vault@v2.1.1 vault/logical_system_paths.go:2899 OpenAPI spec the provider tooling builds on | docs: https://developer.hashicorp.com/vault/docs/secrets/kv/kv-v2 (Terraform provider is the separate hashicorp/terraform-provider-vault repo) ..." + +## What Changes + +- A Terraform and OpenTofu provider `keepiq` in `integrations/terraform-provider-keepiq/`, built on the plugin framework and on `sdk/go/`. +- An ephemeral resource `keepiq_secret` that reads and decrypts an application secret for one run and never writes it to plan or state. +- A resource `keepiq_secret` that manages a secret in the application's vault. Its value is a write-only argument (`value_wo`, with `value_wo_version`), encrypted by the provider to the application's key; state keeps metadata only. +- A data source `keepiq_secret_metadata` for id, timestamps, expiry and fingerprint, without the value. +- Resources `keepiq_application` and `keepiq_application_lease_policy` that register, approve and configure applications through the public admin API. +- `terraform destroy` on a `keepiq_secret` removes it from state and warns, because the machine API has no delete by design. +- Releases signed and published to the Terraform and OpenTofu registries through a mirror repository named `terraform-provider-keepiq`. +- No change to the Keepiq server. + +## Capabilities + +### New Capabilities + +- `terraform-provider`: manage Keepiq application secrets and applications as code, with secret values kept out of Terraform plan and state. + +### Modified Capabilities + +None. + +## Impact + +- **Backend**: none. Secrets go through the machine API; applications through the admin API from change `admin-public-api`. +- **Frontend**: none. +- **Database**: none. +- **Security**: values are decrypted and encrypted only in the provider process. Ephemeral resources and write-only arguments keep them out of plan and state files. The application private key and the admin app password are sensitive provider arguments, read from the environment by default. +- **Cross-app**: depends on `sdk/go/` (change `apps-client-libraries-and-ci`) and on `/api/v1/admin/applications` (change `admin-public-api`). diff --git a/openspec/changes/apps-terraform-provider/specs/terraform-provider/spec.md b/openspec/changes/apps-terraform-provider/specs/terraform-provider/spec.md new file mode 100644 index 000000000..74639d3c9 --- /dev/null +++ b/openspec/changes/apps-terraform-provider/specs/terraform-provider/spec.md @@ -0,0 +1,72 @@ +## ADDED Requirements + +### Requirement: Secret values never enter Terraform plan or state + +The `keepiq` provider MUST decrypt and encrypt secret values only in its own process, and MUST expose secret values only through the ephemeral resource `keepiq_secret` and the write-only arguments of the resource `keepiq_secret`. No resource or data source attribute stored in plan or state MUST contain a secret value. + +#### Scenario: Ephemeral read feeds another provider + +- **GIVEN** application `infra` with secret `db-password` and a configuration that passes `ephemeral.keepiq_secret.db.value` to a database provider +- **WHEN** an engineer runs `terraform apply` +- **THEN** the database provider MUST receive the decrypted value +- **AND** neither the saved plan nor the state file MUST contain it + +### Requirement: Managed secrets use write-only values + +The resource `keepiq_secret` MUST accept `value_wo`, `login_wo` and `additional_fields_wo` as write-only arguments, MUST encrypt them to the application's key after checking the vault certificate fingerprint, and MUST create or update the secret through `POST /api/v1/app/secrets` or `PUT /api/v1/app/secrets/{id}`. An update of the value MUST happen only when `value_wo_version` changes. State MUST hold only id, metadata, ETag and `key_updated_at`. + +#### Scenario: Engineer rotates a value by bumping the version + +- **GIVEN** a `keepiq_secret` resource `api_token` with `value_wo_version = 1` +- **WHEN** the engineer sets a new `value_wo` and `value_wo_version = 2` and runs `terraform apply` +- **THEN** the application MUST decrypt the new value from its vault +- **AND** the state file MUST NOT contain the old or the new value + +### Requirement: Destroy leaves the secret in Keepiq + +Destroying a `keepiq_secret` resource MUST remove it from state only and MUST emit a warning naming the secret and stating that an administrator deletes it in Keepiq, because the machine API offers no deletion. + +#### Scenario: Destroy warns instead of deleting + +- **GIVEN** a managed `keepiq_secret` named `old-token` +- **WHEN** the engineer runs `terraform destroy` +- **THEN** the run MUST succeed with a warning naming `old-token` +- **AND** `old-token` MUST still exist in the application vault + +### Requirement: Metadata without values + +The data source `keepiq_secret_metadata` MUST return id, name, folder, timestamps, `expires_at` and certificate fingerprint for a secret, and MUST NOT offer a value attribute. + +#### Scenario: Plan reacts to an expiry date + +- **GIVEN** a data source `keepiq_secret_metadata` for `db-password` +- **WHEN** Terraform reads it +- **THEN** it MUST expose `expires_at` and `key_updated_at` +- **AND** it MUST expose no value, login or additional field + +### Requirement: Applications are managed through the admin API + +The resource `keepiq_application` MUST register an application from a CSR, approve it through `POST /api/v1/admin/applications/{id}/approve` with the configured admin app password, and export its id and certificate. Destroying it MUST fail unless `allow_vault_deletion` is true, because deleting an application deletes its vault. + +#### Scenario: Pipeline application declared in code + +- **GIVEN** a service account app password holding the "Applications and machine access" area and a CSR for `ci-runner` +- **WHEN** an engineer applies a `keepiq_application` resource for `ci-runner` +- **THEN** `ci-runner` MUST be approved in Keepiq +- **AND** the resource MUST export its certificate + +#### Scenario: Accidental destroy is refused + +- **GIVEN** a `keepiq_application` resource without `allow_vault_deletion` +- **WHEN** the engineer runs `terraform destroy` +- **THEN** the run MUST fail with a message that the application's vault would be deleted + +### Requirement: The provider is published to both registries + +A tag `tf-v` MUST publish a GPG-signed provider release that the Terraform Registry and the OpenTofu Registry serve as `conductionnl/keepiq`. + +#### Scenario: Engineer installs the provider + +- **GIVEN** release `tf-v0.1.0` is published +- **WHEN** an engineer runs `terraform init` with `source = "conductionnl/keepiq"` and version `0.1.0` +- **THEN** Terraform MUST download and verify the signed provider diff --git a/openspec/changes/apps-terraform-provider/tasks.md b/openspec/changes/apps-terraform-provider/tasks.md new file mode 100644 index 000000000..fa601ffef --- /dev/null +++ b/openspec/changes/apps-terraform-provider/tasks.md @@ -0,0 +1,26 @@ +## 1. Provider + +- [ ] 1.1 Create module `integrations/terraform-provider-keepiq/` on `terraform-plugin-framework` and `sdk/go/`, with the provider configuration and environment defaults. Verify with `go test ./...` and a provider schema test. +- [ ] 1.2 Add the ephemeral resource `keepiq_secret` (by name and folder, or id). Verify with a `terraform-plugin-testing` test against the stub that the value is usable in a run and absent from plan and state. +- [ ] 1.3 Add the resource `keepiq_secret` with write-only value arguments, `value_wo_version`, fingerprint check, refresh and import. Verify with tests that create, update on version change, import, and assert no state file contains the value. +- [ ] 1.4 Make destroy of `keepiq_secret` remove from state with a warning naming the secret. Verify with a test on the diagnostics. +- [ ] 1.5 Add the data source `keepiq_secret_metadata`. Verify with a test that it exposes no value attribute. +- [ ] 1.6 Refuse `value_wo` on Terraform versions without write-only support, with a clear diagnostic. Verify with a test that sets an older client capability. + +## 2. Applications + +- [ ] 2.1 Add `keepiq_application` (register from CSR, approve, `allow_vault_deletion` guard) and `keepiq_application_lease_policy` on the admin API. Verify with stub tests for create, approve and a refused destroy, and an acceptance test when `KEEPIQ_LIVE_URL` is set. + +## 3. Release and docs + +- [ ] 3.1 Generate docs with `tfplugindocs` and add examples for each resource. Verify with a CI check that the generated docs are current. +- [ ] 3.2 Add `.github/workflows/integrations-terraform.yml`: tests on pull requests, and on `tf-v*` tags a push to the mirror repository. Verify with a dry run on a pull request. +- [ ] 3.3 Ask an organisation admin to create `ConductionNL/terraform-provider-keepiq` with a deploy key and GoReleaser signing, and register it in the Terraform and OpenTofu registries. Verify manually that `terraform init` resolves `conductionnl/keepiq` after the first tag. + +## Acceptance criteria + +- A configuration using `ephemeral "keepiq_secret"` passes a Keepiq value to another provider, and neither the plan file nor the state file contains that value. +- A `keepiq_secret` resource with `value_wo` creates a secret the application can decrypt, and changing `value_wo_version` updates it. +- `terraform destroy` leaves the secret in Keepiq and prints a warning naming it. +- An application can be registered and approved from Terraform with a CSR, and cannot be destroyed without `allow_vault_deletion`. +- A tagged release is installable with `terraform init` and `tofu init`. From fceed2c8015d679bec0fe4e962b74bedce968c32 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 27 Sep 2026 20:24:20 +0200 Subject: [PATCH 3/3] docs(parity): specify the 14 rows of batch 2 in the keepiq matrix admin-04, admin-10, admin-11, admin-12, admin-13, admin-22, admin-25, admin-27, apps-17, apps-18, apps-20, apps-21, apps-22 and apps-25 are specified by the ten admin and apps changes of this batch. apps-20's copied evidence loses a double dash, and the admin-04 Bitwarden quote matches the matrix text exactly. --- .../proposal.md | 2 +- openspec/parity/capabilities.json | 56 +++++++++---------- 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/openspec/changes/admin-member-overview-and-offboarding/proposal.md b/openspec/changes/admin-member-overview-and-offboarding/proposal.md index caef3dd26..81c43b85e 100644 --- a/openspec/changes/admin-member-overview-and-offboarding/proposal.md +++ b/openspec/changes/admin-member-overview-and-offboarding/proposal.md @@ -21,7 +21,7 @@ No demand row. ### Competitors rated yes -- Bitwarden (admin-04): "bitwarden/server@v2026.9.1 src/Api/AdminConsole/Controllers/OrganizationUsersController.cs:579 DELETE organizations/{orgId}/users/{id}, :605 POST remove (bulk), :669 revoke. Note: Removing or revoking a member drops every collection and group access in one step." +- Bitwarden (admin-04): "bitwarden/server@v2026.9.1 src/Api/AdminConsole/Controllers/OrganizationUsersController.cs:579 DELETE organizations/{orgId}/users/{id}, :605 POST remove (bulk), :669 revoke Note: Removing or revoking a member drops every collection and group access in one step." - 1Password (admin-04): "https://support.1password.com/offboarding/ : suspend or remove an offboarded team member, removing all vault access" - Passbolt (admin-04): "passbolt/passbolt_api@v5.16.0 src/Model/Table/UsersTable.php:458 softDelete: :522 GroupsUsers deleteAll for the user, :523 Permissions deleteAll for the user, folder relations removed; ... Note: Deleting a leaving user removes every permission, folder relation and group membership in one action, after sole-owned items are transferred ..." - Keeper (admin-04): "https://docs.keeper.io/enterprise-guide/user-management-and-lifecycle : Delete User removes the user 'from all Roles, Nodes and Teams'; Lock Account or SCIM/AD Bridge suspension blocks access while Account Transfer keeps the records" diff --git a/openspec/parity/capabilities.json b/openspec/parity/capabilities.json index d667da3aa..78a620bc2 100644 --- a/openspec/parity/capabilities.json +++ b/openspec/parity/capabilities.json @@ -3991,8 +3991,8 @@ "hashicorp-vault": "yes", "nextcloud-passwords": "no", "built": { - "state": "none", - "evidence": "grep -rli 'kubernetes|k8s|helm' lib src cli browser-extension: no hits", + "state": "specified", + "evidence": "Specified in openspec/changes/apps-kubernetes-injection on 2026-09-27. Before: grep -rli 'kubernetes|k8s|helm' lib src cli browser-extension: no hits", "note": "No Kubernetes secret injection (operator, CSI driver, sidecar) exists; keepiq's machine surface is a plain HTTP+JWT API a cluster could call itself, but nothing ships to do that integration.", "owner": "ConductionNL/keepiq" }, @@ -4020,8 +4020,8 @@ "hashicorp-vault": "yes", "nextcloud-passwords": "no", "built": { - "state": "none", - "evidence": "grep -rli 'github.action|gitlab.ci|.gitlab-ci' lib src cli browser-extension: no hits (only this repo's own CI workflows use GitHub Actions, which is unrelated to a keepiq integration product)", + "state": "specified", + "evidence": "Specified in openspec/changes/apps-client-libraries-and-ci on 2026-09-27. Before: grep -rli 'github.action|gitlab.ci|.gitlab-ci' lib src cli browser-extension: no hits (only this repo's own CI workflows use GitHub Actions, which is unrelated to a keepiq integration product)", "note": "No ready-made GitHub Actions or GitLab CI step exists; a pipeline would have to install and script the keepiq CLI itself.", "owner": "ConductionNL/keepiq" }, @@ -4078,8 +4078,8 @@ "hashicorp-vault": "yes", "nextcloud-passwords": "no", "built": { - "state": "none", - "evidence": "Matrix corrected 2026-09-27 in the openspec pass: the rating is no and nothing rotates a password at the destination service; the evidence below is the reminder and manual mark-rotated flow only. Before: openspec/specs/rotation-expiry-policies/spec.md:9 describes 'expiry, an admin-default and user-override max-age policy, approaching/overdue reminders ... a proven mark-rotated flow' -- this is a reminder + manual mark-rotated flow (lib/Controller/RotationController.php, src/store/modules/rotation.js), not automatic rotation of the underlying credential at the target service", + "state": "specified", + "evidence": "Specified in openspec/changes/apps-secret-sync-and-rotation-runner on 2026-09-27. Before: Matrix corrected 2026-09-27 in the openspec pass: the rating is no and nothing rotates a password at the destination service; the evidence below is the reminder and manual mark-rotated flow only. Before: openspec/specs/rotation-expiry-policies/spec.md:9 describes 'expiry, an admin-default and user-override max-age policy, approaching/overdue reminders ... a proven mark-rotated flow'; this is a reminder + manual mark-rotated flow (lib/Controller/RotationController.php, src/store/modules/rotation.js), not automatic rotation of the underlying credential at the target service", "owner": "ConductionNL/keepiq", "note": "keepiq flags stale/expiring secrets and lets a user mark one rotated, but it never rotates a password at the destination service itself the way Vault/1Password-style rotation connectors do." }, @@ -4107,8 +4107,8 @@ "hashicorp-vault": "yes", "nextcloud-passwords": "partial", "built": { - "state": "built", - "evidence": "cli/ is a single Go CLI (stdlib only) covering human read-only + CI fetch; no client SDKs for other languages exist (no python/node/java package in the repo)", + "state": "specified", + "evidence": "Specified in openspec/changes/apps-client-libraries-and-ci on 2026-09-27 for the missing half: client libraries for common languages; the Go command-line client is built. Before: cli/ is a single Go CLI (stdlib only) covering human read-only + CI fetch; no client SDKs for other languages exist (no python/node/java package in the repo)", "reachedOn": "keepiq CLI (Go binary, cross-compiled)", "note": "There is one cross-compiled CLI binary, not per-language client libraries; a Python or Node consumer would call the documented HTTP+JWT API directly with no official SDK.", "owner": "ConductionNL/keepiq" @@ -4137,8 +4137,8 @@ "hashicorp-vault": "yes", "nextcloud-passwords": "no", "built": { - "state": "none", - "evidence": "grep -rli 'terraform' . --include=*.md --include=*.php --include=*.go: no hits", + "state": "specified", + "evidence": "Specified in openspec/changes/apps-terraform-provider on 2026-09-27. Before: grep -rli 'terraform' . --include=*.md --include=*.php --include=*.go: no hits", "note": "No Terraform provider exists for managing keepiq secrets/applications as code.", "owner": "ConductionNL/keepiq" }, @@ -4226,8 +4226,8 @@ "hashicorp-vault": "yes", "nextcloud-passwords": "no", "built": { - "state": "none", - "evidence": "git grep -i 'aws|azure|key vault' lib src: no match; no outbound secret sync", + "state": "specified", + "evidence": "Specified in openspec/changes/apps-secret-sync-and-rotation-runner on 2026-09-27. Before: git grep -i 'aws|azure|key vault' lib src: no match; no outbound secret sync", "owner": "ConductionNL/keepiq", "reachedOn": "nothing", "note": "No push of secrets to cloud secret stores." @@ -5511,8 +5511,8 @@ "hashicorp-vault": "yes", "nextcloud-passwords": "no", "built": { - "state": "built", - "evidence": "src/components/settings/OffboardingSection.vue:150 (after OffboardingConfirmDialog) -> src/store/modules/teamFolder.js:220 POST /api/v1/team-folders/offboard -> lib/Controller/TeamFolderController.php:236 -> lib/Service/TeamFolderOffboardingService.php:83 offboard: revokeTeamSharesForUser (lib/Service/TeamFolderShareService.php:232) then transfer owned team secrets to a successor", + "state": "specified", + "evidence": "Specified in openspec/changes/admin-member-overview-and-offboarding on 2026-09-27 for the missing half: offboarding also removes the leaver's own team folder member rows; revoking derived shares and handing over owned team secrets is built. Before: src/components/settings/OffboardingSection.vue:150 (after OffboardingConfirmDialog) -> src/store/modules/teamFolder.js:220 POST /api/v1/team-folders/offboard -> lib/Controller/TeamFolderController.php:236 -> lib/Service/TeamFolderOffboardingService.php:83 offboard: revokeTeamSharesForUser (lib/Service/TeamFolderShareService.php:232) then transfer owned team secrets to a successor", "owner": "ConductionNL/keepiq", "reachedOn": "Nextcloud admin settings -> Keepiq -> Team offboarding (admin or vault_admin)", "note": "One action revokes every team-folder-derived share and hands owned team secrets to a successor. It does not delete the user's team-folder member rows, so a direct user membership survives.", @@ -5714,8 +5714,8 @@ "hashicorp-vault": "partial", "nextcloud-passwords": "partial", "built": { - "state": "none", - "evidence": "grep -rni 'export_disabled|allow_export|require_2fa|twofactor' lib/Service/AdminSettingsService.php lib/Controller/ExportController.php src/components/settings: no hits", + "state": "specified", + "evidence": "Specified in openspec/changes/admin-vault-policies on 2026-09-27. Before: grep -rni 'export_disabled|allow_export|require_2fa|twofactor' lib/Service/AdminSettingsService.php lib/Controller/ExportController.php src/components/settings: no hits", "owner": "ConductionNL/keepiq", "note": "Keepiq has no policy to require two-factor login or to block personal vault export. Nextcloud can enforce two-factor for the whole login, which also guards keepiq, but that is a server setting, not a vault rule." }, @@ -5743,8 +5743,8 @@ "hashicorp-vault": "yes", "nextcloud-passwords": "no", "built": { - "state": "built", - "evidence": "lib/Service/TeamFolderOffboardingService.php:45 and lib/Service/DelegationAuthorizer.php:49 vault_admin group; admin settings via #[AuthorizedAdminSetting(AdminSettings::class)] (lib/Controller/SettingsController.php:159) so Nextcloud admin delegation can hand the whole Keepiq section to a group; admin handover reachable since f13ad8e6: appinfo/routes.php:150 delegation#handover -> lib/Controller/DelegationController.php:168 createAdminHandover, called from src/components/share/AdminHandoverPanel.vue (mounted in SecretDetailSidebar.vue:700, shown when isVaultAdmin) via src/store/modules/delegation.js:169", + "state": "specified", + "evidence": "Specified in openspec/changes/admin-scoped-roles on 2026-09-27 for the missing half: named admin roles with a chosen set of permissions; the whole-section delegation and the vault_admin group are built. Before: lib/Service/TeamFolderOffboardingService.php:45 and lib/Service/DelegationAuthorizer.php:49 vault_admin group; admin settings via #[AuthorizedAdminSetting(AdminSettings::class)] (lib/Controller/SettingsController.php:159) so Nextcloud admin delegation can hand the whole Keepiq section to a group; admin handover reachable since f13ad8e6: appinfo/routes.php:150 delegation#handover -> lib/Controller/DelegationController.php:168 createAdminHandover, called from src/components/share/AdminHandoverPanel.vue (mounted in SecretDetailSidebar.vue:700, shown when isVaultAdmin) via src/store/modules/delegation.js:169", "owner": "ConductionNL/keepiq", "reachedOn": "Nextcloud admin delegation for the Keepiq settings section; vault_admin group for offboarding and for the admin handover panel in the secret sidebar", "note": "There are two coarse levers: Nextcloud's delegation of the whole Keepiq admin section, and a hard-coded vault_admin group that unlocks offboarding and admin handover. The handover now has a route, a controller call and a UI panel (f13ad8e6, closing #184), so both levers work end to end. There is still no role editor and no per-permission role, so a person cannot be given only the permissions they need: partial.", @@ -5774,8 +5774,8 @@ "hashicorp-vault": "partial", "nextcloud-passwords": "no", "built": { - "state": "built", - "evidence": "src/components/settings/ComplianceSection.vue:44 shows metrics.adoption.usersWithActiveSuite <- lib/Service/ComplianceReportService.php:107 countDistinct keepiq_enc_suites; no per-user list endpoint (open #37)", + "state": "specified", + "evidence": "Specified in openspec/changes/admin-member-overview-and-offboarding on 2026-09-27 for the missing half: a list of which users have set up a vault; the count is built. Before: src/components/settings/ComplianceSection.vue:44 shows metrics.adoption.usersWithActiveSuite <- lib/Service/ComplianceReportService.php:107 countDistinct keepiq_enc_suites; no per-user list endpoint (open #37)", "owner": "ConductionNL/keepiq", "reachedOn": "Nextcloud admin settings -> Keepiq -> Compliance section (count only)", "note": "Admins see how many users have an active vault, not which ones. The encryption-suite admin section needs a suite id typed in by hand.", @@ -5812,8 +5812,8 @@ "hashicorp-vault": "yes", "nextcloud-passwords": "partial", "built": { - "state": "none", - "evidence": "no OpenAPI or admin API docs in docs/; machine routes /api/v1/app/* (appinfo/routes.php:295-321) cover application secrets only; admin endpoints like PUT /api/settings/admin are internal session routes", + "state": "specified", + "evidence": "Specified in openspec/changes/admin-public-api on 2026-09-27. Before: no OpenAPI or admin API docs in docs/; machine routes /api/v1/app/* (appinfo/routes.php:295-321) cover application secrets only; admin endpoints like PUT /api/settings/admin are internal session routes", "owner": "ConductionNL/keepiq", "note": "The admin screens call internal REST routes that a script could reach with a Nextcloud app password, but there is no documented, versioned admin API or scoped admin token." }, @@ -6102,8 +6102,8 @@ "hashicorp-vault": "no", "nextcloud-passwords": "no", "built": { - "state": "none", - "evidence": "lib/Service/AdminSettingsService.php: no ownership or personal-vault policy; git grep -i 'personal vault' lib: descriptive text only", + "state": "specified", + "evidence": "Specified in openspec/changes/admin-vault-policies on 2026-09-27. Before: lib/Service/AdminSettingsService.php: no ownership or personal-vault policy; git grep -i 'personal vault' lib: descriptive text only", "owner": "ConductionNL/keepiq", "reachedOn": "nothing", "note": "Every secret starts in the creator's personal vault; nothing forces work logins into a team folder." @@ -6194,8 +6194,8 @@ "hashicorp-vault": "yes", "nextcloud-passwords": "yes", "built": { - "state": "none", - "evidence": "lib/Service/TeamFolderService.php:496 approveJoin returns a fan-out payload the owner's browser must encrypt; src/modals/TeamFolderDialog.vue:469 shows pendingCount and src/store/modules/teamFolder.js:278 runFanOut shares keys when the owner runs it; approveJoin (teamFolder.js:202) has no caller in src", + "state": "specified", + "evidence": "Specified in openspec/changes/admin-auto-confirm-members on 2026-09-27. Before: lib/Service/TeamFolderService.php:496 approveJoin returns a fan-out payload the owner's browser must encrypt; src/modals/TeamFolderDialog.vue:469 shows pendingCount and src/store/modules/teamFolder.js:278 runFanOut shares keys when the owner runs it; approveJoin (teamFolder.js:202) has no caller in src", "owner": "ConductionNL/keepiq", "reachedOn": "Team folder dialog, pending members count and fan-out run by the owner", "note": "A new team folder member gets access only when the owner's browser runs the key fan-out; there is no automatic confirmation." @@ -6254,8 +6254,8 @@ "hashicorp-vault": "partial", "nextcloud-passwords": "yes", "built": { - "state": "none", - "evidence": "searched 'backup' in lib/Command lib/BackgroundJob: no match; the encrypted-backup export (portability-09) is per user and started by hand", + "state": "specified", + "evidence": "Specified in openspec/changes/admin-scheduled-vault-backups on 2026-09-27. Before: searched 'backup' in lib/Command lib/BackgroundJob: no match; the encrypted-backup export (portability-09) is per user and started by hand", "owner": "ConductionNL/keepiq", "reachedOn": "nothing", "note": "There is no scheduled server-side backup of all vaults and no restore command; an instance relies on the Nextcloud database backup."