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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions openspec/changes/admin-auto-confirm-members/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-27
80 changes: 80 additions & 0 deletions openspec/changes/admin-auto-confirm-members/design.md
Original file line number Diff line number Diff line change
@@ -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. `<version>` in `appinfo/info.xml` does not need a bump for schema reasons.
52 changes: 52 additions & 0 deletions openspec/changes/admin-auto-confirm-members/proposal.md
Original file line number Diff line number Diff line change
@@ -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/[email protected] src/Core/AdminConsole/Enums/PolicyType.cs:27 AutomaticUserConfirmation ('Automatically confirm invited users'); bitwarden/[email protected] apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.ts:33 AutoConfirmPolicy ..."
- HashiCorp Vault: "hashicorp/[email protected] 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/[email protected] 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.
Original file line number Diff line number Diff line change
@@ -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
27 changes: 27 additions & 0 deletions openspec/changes/admin-auto-confirm-members/tasks.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-27
Loading
Loading