Peering recipient picker: should group receivers expand to member names? (deep-dive) #234

Closed
opened 2026-08-14 17:17:38 +00:00 by Cordy · 2 comments
Owner

Deep-research / deep-dive request. Evaluate permanent, sound approaches and give a ranked recommendation. Weight stability, security, long-term maintainability, and UX. Explicitly do not down-weight an option for being harder to implement — implementation cost is not an argument against the correct design.

What surfaced

During the 2026-08-14 A↔B peering dogfood (files/enc ↔ files-bao/openbao), the receivers allow-list on a peer was set to the cairn-admins group only (the explicit user was deliberately removed to test the group path). Result: the recipient picker (#125) rendered empty and degraded to free-text — even on files/enc, which has the Keycloak admin client.

Root cause is by-design in the current build: ListRecipientsRegistry.ReceiverUsers(peer) returns only the explicit Receivers.Users, and never expands Receivers.Groups into member usernames. So a group-only receivers list yields no suggestions anywhere.

The divergence

This contradicts the wording of the recipient-discovery decision memo, Option 1: "Receivers allow-list ∩ resident users (group receivers resolved via #96; empty without the admin client)." The memo implied group receivers would be expanded where an enumeration source exists; the shipped implementation never expands them. The build-time note ("degrade silently, groups not expanded") quietly overrode the memo without recording a decision. That gap is what this issue exists to resolve properly.

Important scoping — authorization is NOT affected

To be explicit: real transfers to a group member already work. On PrepareTransfer, Recipients.Exists resolves the recipient's groups live (#96) and CanReceive honours Receivers.Groups. So a send to a cairn-admins member is authorized and delivered even with a group-only list. This issue is purely about the discovery / autocomplete UX: whether the picker should reflect group membership, not whether group-based authorization works.

The question

Should the picker expand group receivers into member usernames when the instance can enumerate them — and if so, how, permanently and soundly?

Dimensions to evaluate

  1. Privacy / leakage. Expanding a group over the peering control plane reveals member usernames to the sending instance. Counterweights: the sender's admin configured that group; the members are resident users an authenticated peer can already confirm one-by-one via size-0 PrepareTransfer probes (the §4.3 oracle #125 already formalizes); #125 already returns explicit resident users. Does group expansion cross a genuinely new line, or is it the same set by another path? Decide with the threat model, not vibes.
  2. Enumeration source. #96 (livegroups) is user→groups, not group→members — it cannot expand a group. Group→members needs either the Keycloak admin "get group members" API or the #226 candidates enumeration (Manager.Users / view-users). Which source; is it correct (not just "users ever seen"); and what is its availability across custody modes?
  3. Cross-custody consistency. keycloak-profile (has admin client) vs LDAP vs openbao vs app-owned (no IdP admin client). The picker behaviour must be predictable and documented per mode. openbao/app-owned may have no group→members source at all → they must degrade. Define the contract.
  4. Live vs materialized (again). Live per-compose expansion stays fresh but costs an IdP round-trip on every send-dialog open; a cached/materialized member list risks staleness and re-opens the revocation window #123/#125 closed for authorization. Which side of that line does discovery sit on, and is it consistent with the live decision?
  5. Degrade contract. With no enumeration source, keep empty + free-text. Should we instead surface the group name as a hint ("anyone in cairn-admins") rather than member names? The memo leaned no (leaks group names, and a name isn't a valid recipient to send to). Re-weigh.
  6. Blast radius. Does pulling group→member enumeration into the peering-facing path complicate the minimal-blast-radius thesis, or is it contained (it runs on the receiver, over its own IdP, returning only allow-listed residents)?

Deliverable

Ranked options (e.g. never-expand / live-expand-when-admin-client / hint-only / hybrid) with a clear recommendation and the decision recorded, in the style of the recipient-discovery memo. Then a follow-up implementation issue if a change is chosen.

Refs: #125, #123, #96, #226; decision memo cairn-peering-recipient-discovery-decision.

**Deep-research / deep-dive request.** Evaluate permanent, sound approaches and give a ranked recommendation. Weight **stability, security, long-term maintainability, and UX**. Explicitly do **not** down-weight an option for being harder to implement — implementation cost is not an argument against the correct design. ## What surfaced During the 2026-08-14 A↔B peering dogfood (files/enc ↔ files-bao/openbao), the receivers allow-list on a peer was set to the **`cairn-admins` group only** (the explicit user was deliberately removed to test the group path). Result: the recipient picker (#125) rendered **empty** and degraded to free-text — even on **files/enc, which has the Keycloak admin client**. Root cause is by-design in the current build: `ListRecipients` → `Registry.ReceiverUsers(peer)` returns only the explicit `Receivers.Users`, and **never expands `Receivers.Groups`** into member usernames. So a group-only receivers list yields no suggestions anywhere. ## The divergence This contradicts the wording of the recipient-discovery decision memo, Option 1: *"Receivers allow-list ∩ resident users (**group receivers resolved via #96**; empty without the admin client)."* The memo implied group receivers **would** be expanded where an enumeration source exists; the shipped implementation never expands them. The build-time note ("degrade silently, groups not expanded") quietly overrode the memo without recording a decision. That gap is what this issue exists to resolve properly. ## Important scoping — authorization is NOT affected To be explicit: **real transfers to a group member already work.** On `PrepareTransfer`, `Recipients.Exists` resolves the *recipient's* groups live (#96) and `CanReceive` honours `Receivers.Groups`. So a send to a `cairn-admins` member is authorized and delivered even with a group-only list. This issue is **purely about the discovery / autocomplete UX**: whether the picker should reflect group membership, not whether group-based authorization works. ## The question Should the picker expand group receivers into member usernames when the instance can enumerate them — and if so, how, permanently and soundly? ## Dimensions to evaluate 1. **Privacy / leakage.** Expanding a group over the peering control plane reveals member usernames to the *sending* instance. Counterweights: the sender's admin configured that group; the members are resident users an authenticated peer can already confirm one-by-one via size-0 `PrepareTransfer` probes (the §4.3 oracle #125 already formalizes); #125 already returns explicit resident users. Does group expansion cross a genuinely new line, or is it the same set by another path? Decide with the threat model, not vibes. 2. **Enumeration source.** #96 (`livegroups`) is **user→groups**, not **group→members** — it *cannot* expand a group. Group→members needs either the Keycloak admin "get group members" API or the #226 candidates enumeration (`Manager.Users` / `view-users`). Which source; is it correct (not just "users ever seen"); and what is its availability across custody modes? 3. **Cross-custody consistency.** keycloak-profile (has admin client) vs LDAP vs openbao vs app-owned (no IdP admin client). The picker behaviour must be predictable and documented per mode. openbao/app-owned may have no group→members source at all → they *must* degrade. Define the contract. 4. **Live vs materialized (again).** Live per-compose expansion stays fresh but costs an IdP round-trip on every send-dialog open; a cached/materialized member list risks staleness and re-opens the revocation window #123/#125 closed for authorization. Which side of that line does discovery sit on, and is it consistent with the live decision? 5. **Degrade contract.** With no enumeration source, keep empty + free-text. Should we instead surface the **group name** as a hint ("anyone in cairn-admins") rather than member names? The memo leaned no (leaks group names, and a name isn't a valid recipient to send to). Re-weigh. 6. **Blast radius.** Does pulling group→member enumeration into the peering-facing path complicate the minimal-blast-radius thesis, or is it contained (it runs on the receiver, over its own IdP, returning only allow-listed residents)? ## Deliverable Ranked options (e.g. never-expand / live-expand-when-admin-client / hint-only / hybrid) with a clear recommendation and the decision recorded, in the style of the recipient-discovery memo. Then a follow-up implementation issue if a change is chosen. Refs: #125, #123, #96, #226; decision memo `cairn-peering-recipient-discovery-decision`.
Author
Owner

Deep-dive: should the peering recipient picker expand group receivers?

Decision memo, style of cairn-peering-recipient-discovery-decision. Weighting per the request: security, stability, long-term maintainability, UX. Implementation cost is not a factor.

1. The truth in the code (verified against main)

  • Service.ListRecipients iterates Registry.ReceiverUsers(peer) (explicit Receivers.Users only) and keeps each u where Recipients.Exists(peer, u) (allow-listed and resident, the #123 delivery gate). Groups are never touched. One peer-recipient-list audit event per call.
  • Authorization is a separate path and already correct: PrepareTransfer → Recipients.Exists → CanReceive(peer, username, groups) resolves the recipient's groups live (#96, user→groups) and honours Receivers.Groups. A send to a cairn-admins member is authorized and delivered today with a group-only list. This issue changes no authorization behaviour.
  • So the gap is exactly: discovery shows nothing for a group-only receivers list.

2. The enumeration-source constraint (this decides most of it)

Expanding a group needs group→members. The sources:

Source Direction Can expand a group? Availability
#96 livegroups user→groups No keycloak-profile, LDAP
#226 candidates (Manager.Users / view-users) all realm users No — wrong set keycloak-profile, LDAP
Keycloak group members admin API (GET …/groups/{id}/members) group→members Yes keycloak-profile (same admin client as #226, may need query-groups/view-users)
LDAP group member/memberOf group→members Yes LDAP
openbao / app-owned: no source at all

Correctness landmine to record: #226's Manager.Users is the whole realm, not a group's members. Using it to "expand" a group would suggest non-members and users who'd be refused at delivery. Expansion must use a real group→members query, and must still be ∩ residents ∩ CanReceive, so the picker never suggests someone the delivery gate would reject.

3. The privacy line — does expansion cross a new one? (threat model, not vibes)

The issue's three counterweights, weighed:

  1. "The sender's admin configured that group."No. The Receivers allow-list lives on the receiver's peer entry; the group names are the receiver's taxonomy. The sender does not necessarily know them. Expansion discloses receiver-side group membership and, for the hint variant, receiver-side group names, to the sender.
  2. "Members are confirmable one-by-one via the §4.3 size-0 PrepareTransfer oracle." — True but materially weaker than a dump. The oracle is guess-and-confirm: it validates a username the sender already suspected. Expansion is bulk disclosure: it hands over the membership list with no prior knowledge. Converting a bounded confirm-what-you-know oracle into an unbounded enumerate-from-nothing list is a real escalation, not the same set by another path.
  3. "#125 already returns explicit resident users." — Those were named individually by the receiver admin. Naming a group is not the same act as consenting to surface each member's identity to a peer; membership of cairn-admins may itself be sensitive.

Conclusion: expansion is a genuine, if modest, escalation in disclosure. Industry posture agrees — group→member enumeration to an external party is a recognized directory-enumeration risk, gated behind trust + audit (which the peering plane already has: mutual key auth, admin-established peering, per-call audit). It is a legitimate policy choice, but because it discloses the receiver's data to a third party, the choice belongs to the receiver admin, and the safe default is off.

4. Industry comparison

  • Nextcloud Federated Cloud Sharing supports sharing to a remote group by addressing it as group@domainthe sender never expands it; the group is an opaque handle the remote server resolves internally at delivery. Enumeration is sidestepped entirely because the group is the address. Cairn can't copy this directly: delivery is per-username into /home/<recipient>/Inbox/<peer>/, so a group is not an addressable target today. Making it one (send-to-group fan-out) is a larger architectural change and is out of scope here — but it is the cleanest long-term answer to "how do I send to a team," and should be its own parked issue.
  • Trusted-server user directory auto-complete (Nextcloud) is the analogue of #125's explicit-user picker: gated on an established trust relationship. Nextcloud does not bulk-expand remote groups into member names on the sender side. That is a strong external signal for not expanding by default.

5. Live vs materialized

Discovery must stay live (a group→members query at ListRecipients time), never a stored member list. A cached materialization reintroduces exactly the staleness/revocation window that the live CanReceive decision (#123/#125) closed. Non-negotiable: if we expand, we expand live, per send-dialog open.

6. Ranked options

A — Never expand (status quo, made a recorded decision). Picker = explicit resident users only; group-only → empty → free-text; authz still live. Zero new disclosure, one contract everywhere, matches Nextcloud's no-sender-expansion posture. Costs the group-only UX.

B — Always live-expand where a source exists. keycloak-profile/LDAP expand via the real group→members query ∩ residents ∩ CanReceive; openbao/app-owned degrade. Best UX where possible, stays live, contained to the receiver. But makes bulk group-membership disclosure automatic and non-consensual for the receiver admin.

C — Group-name hint only. No members; where Receivers.Groups is non-empty, show a non-selectable hint ("also: members of cairn-admins — ask them for their username"). Some guidance, no member disclosure — but leaks receiver group names, and the hint isn't a valid send target.

D — Opt-in live expansion (recommended). Default = A (never expand; safe floor). A receiver-admin toggle ("let peers see who's in receiver groups when discovering recipients") enables B where a real group→members source exists; otherwise free-text, optionally with C's hint. Live only; ∩ residents ∩ CanReceive; one audit event per call (already present).

7. Recommendation — D, defaulting to A

  • Security: bulk membership disclosure is a real escalation over the guess-and-confirm oracle, so it must not be automatic (rules out B as default). The decision discloses the receiver's data, so it belongs to the receiver admin, default-closed. D puts the choice at the correct locus; A is the floor.
  • Stability/consistency: live-only (consistent with #123/#125); the per-mode degrade is explicit and mirrors #226's already-documented availability, so the contract is predictable and documentable per custody mode.
  • Long-term: "expansion is receiver-owned policy" scales to LDAP and future modes and keeps the minimal-blast-radius thesis intact (runs on the receiver, over its own IdP, returns only allow-listed residents).
  • UX: opt-in instances get a working picker for group receivers; everyone else gets predictable free-text, optionally a hint.

Records the decision: discovery of group members is off by default, enabled per-instance by the receiver admin, live and source-gated, never materialized, never via Manager.Users. Authorization is unchanged. Send-to-group fan-out (the Nextcloud model) is noted as a separate, larger future direction.

Deliverable / next step

If D is ratified I'll cut the implementation issue: (i) Registry.ReceiverGroups(peer) + a GroupMembers(group) resolver interface implemented for keycloak-profile (group-members API) and LDAP, nil elsewhere; (ii) a peeringExpandGroups receiver setting (default false); (iii) ListRecipients unions explicit users with live-expanded members ∩ residents ∩ CanReceive when enabled; (iv) per-mode docs + the §4.3 threat-model note. Or say which of A / B / C you'd rather record instead.

Refs: #125, #123, #96, #226; Nextcloud Federated Cloud Sharing (group-as-address model).

## Deep-dive: should the peering recipient picker expand group receivers? Decision memo, style of `cairn-peering-recipient-discovery-decision`. Weighting per the request: security, stability, long-term maintainability, UX. Implementation cost is **not** a factor. ### 1. The truth in the code (verified against `main`) - `Service.ListRecipients` iterates `Registry.ReceiverUsers(peer)` (**explicit `Receivers.Users` only**) and keeps each `u` where `Recipients.Exists(peer, u)` (allow-listed **and** resident, the #123 delivery gate). Groups are never touched. One `peer-recipient-list` audit event per call. - Authorization is a **separate path** and already correct: `PrepareTransfer → Recipients.Exists → CanReceive(peer, username, groups)` resolves the *recipient's* groups **live** (#96, user→groups) and honours `Receivers.Groups`. A send to a `cairn-admins` member is authorized and delivered today with a group-only list. **This issue changes no authorization behaviour.** - So the gap is exactly: discovery shows nothing for a group-only receivers list. ### 2. The enumeration-source constraint (this decides most of it) Expanding a group needs **group→members**. The sources: | Source | Direction | Can expand a group? | Availability | |---|---|---|---| | #96 `livegroups` | user→groups | **No** | keycloak-profile, LDAP | | #226 candidates (`Manager.Users` / view-users) | all realm users | **No — wrong set** | keycloak-profile, LDAP | | Keycloak *group members* admin API (`GET …/groups/{id}/members`) | group→members | **Yes** | keycloak-profile (same admin client as #226, may need `query-groups`/`view-users`) | | LDAP group `member`/`memberOf` | group→members | **Yes** | LDAP | | — | — | — | **openbao / app-owned: no source at all** | **Correctness landmine to record:** #226's `Manager.Users` is the *whole realm*, not a group's members. Using it to "expand" a group would suggest non-members and users who'd be **refused at delivery**. Expansion must use a real group→members query, and must still be ∩ residents ∩ `CanReceive`, so the picker never suggests someone the delivery gate would reject. ### 3. The privacy line — does expansion cross a new one? (threat model, not vibes) The issue's three counterweights, weighed: 1. *"The sender's admin configured that group."* — **No.** The `Receivers` allow-list lives on the **receiver's** peer entry; the group names are the **receiver's** taxonomy. The sender does not necessarily know them. Expansion discloses receiver-side group membership *and*, for the hint variant, receiver-side group **names**, to the sender. 2. *"Members are confirmable one-by-one via the §4.3 size-0 `PrepareTransfer` oracle."* — True but **materially weaker than a dump**. The oracle is *guess-and-confirm*: it validates a username the sender already suspected. Expansion is *bulk disclosure*: it hands over the membership list with no prior knowledge. Converting a bounded confirm-what-you-know oracle into an unbounded enumerate-from-nothing list is a real escalation, not the same set by another path. 3. *"#125 already returns explicit resident users."* — Those were named **individually** by the receiver admin. Naming a group is not the same act as consenting to surface each member's identity to a peer; membership of `cairn-admins` may itself be sensitive. **Conclusion:** expansion is a genuine, if modest, escalation in disclosure. Industry posture agrees — group→member enumeration to an external party is a recognized directory-enumeration risk, gated behind trust + audit (which the peering plane already has: mutual key auth, admin-established peering, per-call audit). It is a legitimate **policy choice**, but because it discloses the receiver's data to a third party, the choice belongs to the **receiver admin**, and the safe default is **off**. ### 4. Industry comparison - **Nextcloud Federated Cloud Sharing** supports sharing to a remote group by addressing it as `group@domain` — **the sender never expands it; the group is an opaque handle the remote server resolves internally at delivery.** Enumeration is sidestepped entirely because the group *is* the address. Cairn can't copy this directly: delivery is per-username into `/home/<recipient>/Inbox/<peer>/`, so a group is **not** an addressable target today. Making it one (send-to-group fan-out) is a larger architectural change and is out of scope here — but it is the cleanest long-term answer to "how do I send to a team," and should be its own parked issue. - Trusted-server **user** directory auto-complete (Nextcloud) is the analogue of #125's explicit-user picker: gated on an established trust relationship. Nextcloud does **not** bulk-expand remote *groups* into member names on the sender side. That is a strong external signal for **not expanding by default**. ### 5. Live vs materialized Discovery must stay **live** (a group→members query at `ListRecipients` time), never a stored member list. A cached materialization reintroduces exactly the staleness/revocation window that the live `CanReceive` decision (#123/#125) closed. Non-negotiable: if we expand, we expand live, per send-dialog open. ### 6. Ranked options **A — Never expand (status quo, made a recorded decision).** Picker = explicit resident users only; group-only → empty → free-text; authz still live. *Zero new disclosure, one contract everywhere, matches Nextcloud's no-sender-expansion posture. Costs the group-only UX.* **B — Always live-expand where a source exists.** keycloak-profile/LDAP expand via the real group→members query ∩ residents ∩ `CanReceive`; openbao/app-owned degrade. *Best UX where possible, stays live, contained to the receiver. But makes bulk group-membership disclosure automatic and non-consensual for the receiver admin.* **C — Group-name hint only.** No members; where `Receivers.Groups` is non-empty, show a non-selectable hint ("also: members of `cairn-admins` — ask them for their username"). *Some guidance, no member disclosure — but leaks receiver group **names**, and the hint isn't a valid send target.* **D — Opt-in live expansion (recommended).** Default = A (never expand; safe floor). A **receiver-admin toggle** ("let peers see who's in receiver groups when discovering recipients") enables B **where a real group→members source exists**; otherwise free-text, optionally with C's hint. Live only; ∩ residents ∩ `CanReceive`; one audit event per call (already present). ### 7. Recommendation — **D**, defaulting to A - **Security:** bulk membership disclosure is a real escalation over the guess-and-confirm oracle, so it must not be automatic (rules out B as default). The decision discloses the *receiver's* data, so it belongs to the *receiver admin*, default-closed. D puts the choice at the correct locus; A is the floor. - **Stability/consistency:** live-only (consistent with #123/#125); the per-mode degrade is explicit and mirrors #226's already-documented availability, so the contract is predictable and documentable per custody mode. - **Long-term:** "expansion is receiver-owned policy" scales to LDAP and future modes and keeps the minimal-blast-radius thesis intact (runs on the receiver, over its own IdP, returns only allow-listed residents). - **UX:** opt-in instances get a working picker for group receivers; everyone else gets predictable free-text, optionally a hint. **Records the decision:** discovery of group members is **off by default**, enabled per-instance by the receiver admin, **live** and **source-gated**, never materialized, never via `Manager.Users`. Authorization is unchanged. Send-to-group fan-out (the Nextcloud model) is noted as a separate, larger future direction. ### Deliverable / next step If **D** is ratified I'll cut the implementation issue: (i) `Registry.ReceiverGroups(peer)` + a `GroupMembers(group)` resolver interface implemented for keycloak-profile (group-members API) and LDAP, nil elsewhere; (ii) a `peeringExpandGroups` receiver setting (default false); (iii) `ListRecipients` unions explicit users with live-expanded members ∩ residents ∩ `CanReceive` when enabled; (iv) per-mode docs + the §4.3 threat-model note. Or say which of A / B / C you'd rather record instead. Refs: #125, #123, #96, #226; Nextcloud Federated Cloud Sharing (group-as-address model).
Author
Owner

Decision recorded — Option D (ratified).

Group-receiver discovery is off by default, enabled per-instance by the receiver admin, resolved live and source-gated (real group→members query — Keycloak group-members API / LDAP; never Manager.Users), never materialized, always ∩ residents ∩ CanReceive. openbao / app-owned have no source and degrade to free-text. Authorization behaviour is unchanged. Send-to-group fan-out (the Nextcloud group-as-address model) is noted as a separate, larger future direction, not part of this.

Implementation tracked in #243. Closing this deep-dive.

**Decision recorded — Option D (ratified).** Group-receiver discovery is **off by default**, enabled per-instance by the **receiver admin**, resolved **live** and **source-gated** (real group→members query — Keycloak group-members API / LDAP; never `Manager.Users`), never materialized, always ∩ residents ∩ `CanReceive`. openbao / app-owned have no source and degrade to free-text. Authorization behaviour is unchanged. Send-to-group fan-out (the Nextcloud group-as-address model) is noted as a separate, larger future direction, not part of this. Implementation tracked in #243. Closing this deep-dive.
Cordy closed this issue 2026-08-15 15:21:52 +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#234
No description provided.