Peering recipient picker: opt-in live expansion of group receivers (implements #234 decision D) #243

Closed
opened 2026-08-15 15:21:39 +00:00 by Cordy · 3 comments
Owner

Implements the ratified decision from #234 (Option D): group-receiver discovery is off by default, enabled per-instance by the receiver admin, live and source-gated, never materialized, never via Manager.Users. Authorization is unchanged (already correct via live CanReceive).

Contract

  • Default: never expand. ListRecipients behaves exactly as today (explicit Receivers.Users ∩ residents). Safe floor.
  • When the receiver admin opts in AND a real group→members source exists: ListRecipients additionally resolves each Receivers.Groups entry to its members live, unions them with the explicit users, and keeps only those ∩ resident ∩ CanReceive(peer, u, groups) — so the picker never suggests anyone the delivery gate would refuse.
  • No source (openbao / app-owned) or opt-in off: unchanged free-text. (Group-name hint from Option C is explicitly not included — decision was member-expansion or nothing.)
  • Live only. A group→members query at ListRecipients time, never a stored/materialized member list (would reopen the #123/#125 revocation window).
  • Correctness guard: must use a real group→members query. Do not use #226's Manager.Users (whole realm) — it would suggest non-members and users who'd be refused at delivery.

Work

  1. Resolver interface — a GroupMembers(group string) ([]string, bool) (or similar) capability, implemented for:
    • keycloak-profile: Keycloak admin group-members API (GET /admin/realms/{realm}/groups/{id}/members) via the same admin client that backs #226 — confirm the client's role covers it (view-users / query-groups); document the role requirement.
    • LDAP: group member / memberOf attribute read.
    • openbao / app-owned: nil / not-supported → degrade.
  2. Registry.ReceiverGroups(peer) — expose the peer's inbound group allow-list (mirrors the existing ReceiverUsers).
  3. Receiver setting peeringExpandGroups (default false), persisted in the state-backend like the other runtime settings; surfaced as an admin toggle.
  4. Service.ListRecipients — when the setting is on and a resolver exists, union explicit users with live-expanded members, then ∩ residents ∩ CanReceive. Keep the single peer-recipient-list audit event (record expanded vs not in the detail).
  5. Docs — per-custody-mode behaviour table (who can expand, who degrades) + the §4.3 threat-model note that expansion is bulk membership disclosure to the sender, gated on receiver-admin opt-in.

Out of scope / parked

  • Send-to-group fan-out (the Nextcloud group@domain-as-address model): the cleaner long-term "send to a team" primitive, but a much larger change (delivery is per-username into /home/<recipient>/Inbox/<peer>/). Track separately if wanted.

Milestone: v0.7 (closed beta / hardening), same as #234.

Refs: #234 (decision), #125, #123, #96, #226.

Implements the ratified decision from #234 (Option **D**): group-receiver discovery is **off by default**, enabled per-instance by the receiver admin, **live** and **source-gated**, never materialized, never via `Manager.Users`. Authorization is unchanged (already correct via live `CanReceive`). ## Contract - **Default:** never expand. `ListRecipients` behaves exactly as today (explicit `Receivers.Users` ∩ residents). Safe floor. - **When the receiver admin opts in AND a real group→members source exists:** `ListRecipients` additionally resolves each `Receivers.Groups` entry to its members **live**, unions them with the explicit users, and keeps only those ∩ resident ∩ `CanReceive(peer, u, groups)` — so the picker never suggests anyone the delivery gate would refuse. - **No source (openbao / app-owned) or opt-in off:** unchanged free-text. (Group-name hint from Option C is explicitly *not* included — decision was member-expansion or nothing.) - **Live only.** A group→members query at `ListRecipients` time, never a stored/materialized member list (would reopen the #123/#125 revocation window). - **Correctness guard:** must use a real group→members query. Do **not** use #226's `Manager.Users` (whole realm) — it would suggest non-members and users who'd be refused at delivery. ## Work 1. **Resolver interface** — a `GroupMembers(group string) ([]string, bool)` (or similar) capability, implemented for: - **keycloak-profile:** Keycloak admin group-members API (`GET /admin/realms/{realm}/groups/{id}/members`) via the same admin client that backs #226 — confirm the client's role covers it (`view-users` / `query-groups`); document the role requirement. - **LDAP:** group `member` / `memberOf` attribute read. - **openbao / app-owned:** nil / not-supported → degrade. 2. **`Registry.ReceiverGroups(peer)`** — expose the peer's inbound group allow-list (mirrors the existing `ReceiverUsers`). 3. **Receiver setting** `peeringExpandGroups` (default **false**), persisted in the state-backend like the other runtime settings; surfaced as an admin toggle. 4. **`Service.ListRecipients`** — when the setting is on and a resolver exists, union explicit users with live-expanded members, then ∩ residents ∩ `CanReceive`. Keep the single `peer-recipient-list` audit event (record expanded vs not in the detail). 5. **Docs** — per-custody-mode behaviour table (who can expand, who degrades) + the §4.3 threat-model note that expansion is bulk membership disclosure to the sender, gated on receiver-admin opt-in. ## Out of scope / parked - **Send-to-group fan-out** (the Nextcloud `group@domain`-as-address model): the cleaner long-term "send to a team" primitive, but a much larger change (delivery is per-username into `/home/<recipient>/Inbox/<peer>/`). Track separately if wanted. Milestone: v0.7 (closed beta / hardening), same as #234. Refs: #234 (decision), #125, #123, #96, #226.
Author
Owner

Clarification from the follow-up discussion, relevant to the "openbao/app-owned has no source → must degrade" line:

That's true only for IdP groups (Receivers.Groups = Keycloak/LDAP group names). openbao/app-owned instances do have a native group→members source for app-owned Spaces (internal/spacestore, ADR 0001 — {name, owner, members[]} in the state-backend, works under every custody mode). It just isn't the same thing as an IdP group and isn't what this issue's Receivers.Groups references.

So #243 stays scoped to IdP-group receivers (needs Keycloak/LDAP; app-owned degrades). The app-owned-friendly and privacy-clean path for "deliver to a team" is #244 (Space as a peering recipient → shared Space inbox, no member disclosure, works in all modes). The two are complementary: #243 = a group of individuals each receiving personally; #244 = a team receiving into a shared space.

Clarification from the follow-up discussion, relevant to the "openbao/app-owned has no source → must degrade" line: That's true **only for IdP groups** (`Receivers.Groups` = Keycloak/LDAP group names). openbao/app-owned instances *do* have a native group→members source for **app-owned Spaces** (`internal/spacestore`, ADR 0001 — `{name, owner, members[]}` in the state-backend, works under every custody mode). It just isn't the same thing as an IdP group and isn't what this issue's `Receivers.Groups` references. So #243 stays scoped to IdP-group receivers (needs Keycloak/LDAP; app-owned degrades). The app-owned-friendly *and* privacy-clean path for "deliver to a team" is **#244** (Space as a peering recipient → shared Space inbox, no member disclosure, works in all modes). The two are complementary: #243 = a group of individuals each receiving personally; #244 = a team receiving into a shared space.
Author
Owner

Shipped as v0.6.55 (PR #291), live on both dogfoods. Implements the contract exactly: default never-expand; receiver opt-in settings.peeringExpandGroups (Peering → Global switch, off by default, live-read); live Manager.GroupMembers over the Keycloak read client (findGroup + /groups/{id}/members, view-users/query-groups — not Manager.Users); every expanded name still passes Recipients.Exists; never materialized; peer-recipient-list audit gains expanded; unit tests green; peering.md documents the disclosure trade-off.

Follow-up (left open on this issue): LDAP group-member resolver — LDAP instances currently degrade to free-text (nil resolver), matching the safe floor. Implement via a group-entry member lookup when an LDAP deployment needs it.

Dogfood prerequisite for the original finding (nikola-test not listed when sending enc → bao): files-bao is the receiver and needs a group→members source plus the opt-in —

  1. Keycloak (realm cordyceps): create a confidential client (e.g. cairn-bao-groups), service accounts ON, standard flow OFF; Service accounts roles → assign realm-management → view-users + query-groups (NOT manage-users); copy the secret.
  2. cairn-openbao-config Secret (untracked, manual edit): inside "auth": { … } add
    "groups": {
      "keycloakBaseUrl": "http://192.168.10.248",
      "keycloakRealm": "cordyceps",
      "keycloakClientId": "cairn-bao-groups",
      "keycloakClientSecret": "…"
    }
    
    then kubectl -n cairn rollout restart deployment/cairn-openbao.
  3. On files-bao: Administration → Peering → Global → switch on Suggest group members to peers.
  4. Verify on files.c0rdyceps.ch: Send to peer bao → picker lists nikola-test (cairn-admins member) under "Listed recipients at this peer".

Side effects of the read client on bao: live group resolution (#96) — membership changes apply within ~1 min — and the admin panel's group-only receivers warning (#129) resolves.

Shipped as **v0.6.55** (PR #291), live on both dogfoods. Implements the contract exactly: default never-expand; receiver opt-in `settings.peeringExpandGroups` (Peering → Global switch, off by default, live-read); live `Manager.GroupMembers` over the Keycloak read client (`findGroup` + `/groups/{id}/members`, view-users/query-groups — not `Manager.Users`); every expanded name still passes `Recipients.Exists`; never materialized; `peer-recipient-list` audit gains ` expanded`; unit tests green; peering.md documents the disclosure trade-off. **Follow-up (left open on this issue): LDAP group-member resolver** — LDAP instances currently degrade to free-text (nil resolver), matching the safe floor. Implement via a group-entry `member` lookup when an LDAP deployment needs it. **Dogfood prerequisite for the original finding** (nikola-test not listed when sending enc → bao): files-bao is the receiver and needs a group→members source plus the opt-in — 1. Keycloak (realm `cordyceps`): create a confidential client (e.g. `cairn-bao-groups`), service accounts ON, standard flow OFF; Service accounts roles → assign **realm-management → view-users + query-groups** (NOT manage-users); copy the secret. 2. `cairn-openbao-config` Secret (untracked, manual edit): inside `"auth": { … }` add ```json "groups": { "keycloakBaseUrl": "http://192.168.10.248", "keycloakRealm": "cordyceps", "keycloakClientId": "cairn-bao-groups", "keycloakClientSecret": "…" } ``` then `kubectl -n cairn rollout restart deployment/cairn-openbao`. 3. On files-bao: Administration → Peering → Global → switch on **Suggest group members to peers**. 4. Verify on files.c0rdyceps.ch: Send to peer `bao` → picker lists `nikola-test` (cairn-admins member) under "Listed recipients at this peer". Side effects of the read client on bao: live group resolution (#96) — membership changes apply within ~1 min — and the admin panel's group-only receivers warning (#129) resolves.
Author
Owner

Closing — verified live by Nikola 2026-08-20. Record of what fixed the original finding and what was done along the way:

Root cause. nikola-test never appeared in enc's send picker because bao's receive allow-list for the enc peering names the cairn-admins group, and ListRecipients deliberately never expanded groups (#125 degrade) — the picker fell back to "No listed recipients — type a username" while delivery itself worked.

Fix (shipped as v0.6.55, PR #291). Decision-D expansion exactly per this issue's contract: receiver opt-in settings.peeringExpandGroups (Peering → Global switch, off by default, live-read), Registry.ReceiverGroups, Service.recipientList with live Manager.GroupMembers over the Keycloak read client (one group at a time via findGroup + /groups/{id}/members; never Manager.Users; never materialized), every candidate still passing the Recipients.Exists delivery gate, audit detail expanded, unit tests, and the disclosure note in docs/handbook/peering.md.

Receiver-side enablement (files-bao, applied + verified). Keycloak client cairn-bao-groups (confidential, service accounts, realm-management view-users + query-groups only — manage off); auth.groups block added to the untracked cairn-openbao-config Secret; rollout restart; toggle on. Result: enc's picker lists nikola-test under "Listed recipients at this peer". Side effects on bao: live group resolution (#96) and the #129 group-only-receivers warning resolved.

Incidental fix (documented). The Mac had no kubeconfig — kubectl defaulted to localhost:8080 (connection refused). Admin kubeconfig pulled from Pi1 (/etc/rancher/k3s/k3s.yaml, server rewritten to 192.168.10.173), now at ~/.kube/config mode 600; procedure documented in Wiki.js homelab/k3s → "Cluster access from the Mac" (the read-only mcp-viewer kubeconfig cannot read Secrets by design).

Remaining (tracked, not blocking): LDAP group-member resolver — LDAP instances degrade to free-text; implement a group-entry member lookup when an LDAP deployment needs it. Cut a fresh issue if/when that becomes real.

Also recorded in the repo wiki working log (2026-08-20 entry).

**Closing — verified live by Nikola 2026-08-20.** Record of what fixed the original finding and what was done along the way: **Root cause.** `nikola-test` never appeared in enc's send picker because bao's receive allow-list for the enc peering names the `cairn-admins` *group*, and `ListRecipients` deliberately never expanded groups (#125 degrade) — the picker fell back to "No listed recipients — type a username" while delivery itself worked. **Fix (shipped as v0.6.55, PR #291).** Decision-D expansion exactly per this issue's contract: receiver opt-in `settings.peeringExpandGroups` (Peering → Global switch, off by default, live-read), `Registry.ReceiverGroups`, `Service.recipientList` with live `Manager.GroupMembers` over the Keycloak read client (one group at a time via `findGroup` + `/groups/{id}/members`; never `Manager.Users`; never materialized), every candidate still passing the `Recipients.Exists` delivery gate, audit detail ` expanded`, unit tests, and the disclosure note in `docs/handbook/peering.md`. **Receiver-side enablement (files-bao, applied + verified).** Keycloak client `cairn-bao-groups` (confidential, service accounts, realm-management **view-users + query-groups** only — `manage` off); `auth.groups` block added to the untracked `cairn-openbao-config` Secret; rollout restart; toggle on. Result: enc's picker lists `nikola-test` under "Listed recipients at this peer". Side effects on bao: live group resolution (#96) and the #129 group-only-receivers warning resolved. **Incidental fix (documented).** The Mac had no kubeconfig — kubectl defaulted to `localhost:8080` (`connection refused`). Admin kubeconfig pulled from Pi1 (`/etc/rancher/k3s/k3s.yaml`, server rewritten to `192.168.10.173`), now at `~/.kube/config` mode 600; procedure documented in Wiki.js `homelab/k3s` → "Cluster access from the Mac" (the read-only `mcp-viewer` kubeconfig cannot read Secrets by design). **Remaining (tracked, not blocking):** LDAP group-member resolver — LDAP instances degrade to free-text; implement a group-entry `member` lookup when an LDAP deployment needs it. Cut a fresh issue if/when that becomes real. Also recorded in the repo wiki working log (2026-08-20 entry).
Cordy closed this issue 2026-08-20 19:21:25 +00:00
Sign in to join this conversation.
No labels
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: Cordy/Cairn#243
No description provided.