Peering: how does the receiver decide a recipient "exists"? (blocks the cmd/cairnd wiring) #123

Closed
opened 2026-08-05 17:37:08 +00:00 by Cordy · 5 comments
Owner

Surfaced while wiring #102 into cmd/cairnd. The transport is merged (PR #122)
but nothing constructs it yet, because this question has no answer in the code.

The problem

IMPLEMENTED-PEERING.md §4.3 is the core of Manuel's design: "the receiving
instance verifies the recipient exists locally before any bytes flow."

PrepareTransfer needs a peering.Recipients implementation to call.

There isn't one, and it is not an oversight in the peering package — the
information does not exist:

  • local modeauth.Local holds users map[string]string (unexported).
    Answerable, but needs an exported accessor.
  • oidc mode — the instance holds no list of users at all. A user does
    not exist locally until they first log in. Cairn consumes identity, it does
    not implement it (ARCHITECTURE §5.4), so there is nothing to enumerate.
  • ldap mode — same: search-then-bind authenticates a presented
    credential; it is not a roster.

So in two of the three auth modes there is currently nothing to ask.

Options

A. Home directory on the storage backend (recommended).
Define existence as "/home/<recipient> exists". Uniform across all three auth
modes, needs no new auth surface, and fails safe: a user who has never logged in
has no home, so the transfer is refused — which is correct, because delivery
would have had nowhere to put the file. Requires auth.perUserHomes, which
peering delivery already assumes (§5: delivery lands in
/home/<recipient>/Inbox/<peer>/).
Cost: a peer can probe which users have ever logged in. §4.3 already accepts
that enumeration for authenticated, allow-listed peers.

B. Ask the IdP. Reuse #96's admin client to query the directory. Most
"correct" answer, but only works when the admin client is configured, adds a
network round trip inside PrepareTransfer, and makes an inbound transfer
depend on IdP availability.

C. Admin-maintained inbound allow-list per peering. Symmetric with the
outbound sender allow-list (§4.2), leaks nothing, and is fully deterministic —
but it is manual work per recipient and will be forgotten in practice.

D. Accept anything, decide at delivery. Rejected: it discards the property
§4.3 exists for and would let a peer upload gigabytes before discovering the
recipient is unknown.

Recommendation

A, with C available later as an optional narrowing for admins who do not
want the probe surface. If A is chosen, auth.perUserHomes becomes a hard
prerequisite for enabling peering, and that belongs in the docs handbook and in
the admin UI at peering setup.

Also needed once this is decided

  • peering.* config: staging dir, external base URL (for the absolute upload
    URL), maxFileBytes.
  • Construct grpc.Server, register peering.Service, wrap root with
    peering.Mux, mount BlobHandler at /peer/v1/blob/.
  • A Deliverer — that is #104.

Natural companion to #103, which needs the same config surface.

Surfaced while wiring #102 into `cmd/cairnd`. The transport is merged (PR #122) but nothing constructs it yet, because this question has no answer in the code. ## The problem `IMPLEMENTED-PEERING.md` §4.3 is the core of Manuel's design: *"the receiving instance verifies the recipient exists locally before any bytes flow."* `PrepareTransfer` needs a `peering.Recipients` implementation to call. There isn't one, and it is not an oversight in the peering package — the information does not exist: - **`local` mode** — `auth.Local` holds `users map[string]string` (unexported). Answerable, but needs an exported accessor. - **`oidc` mode** — the instance holds **no list of users at all**. A user does not exist locally until they first log in. Cairn consumes identity, it does not implement it (ARCHITECTURE §5.4), so there is nothing to enumerate. - **`ldap` mode** — same: `search-then-bind` authenticates a presented credential; it is not a roster. So in two of the three auth modes there is currently nothing to ask. ## Options **A. Home directory on the storage backend (recommended).** Define existence as "`/home/<recipient>` exists". Uniform across all three auth modes, needs no new auth surface, and fails safe: a user who has never logged in has no home, so the transfer is refused — which is *correct*, because delivery would have had nowhere to put the file. Requires `auth.perUserHomes`, which peering delivery already assumes (§5: delivery lands in `/home/<recipient>/Inbox/<peer>/`). Cost: a peer can probe which users have ever logged in. §4.3 already accepts that enumeration for authenticated, allow-listed peers. **B. Ask the IdP.** Reuse #96's admin client to query the directory. Most "correct" answer, but only works when the admin client is configured, adds a network round trip inside `PrepareTransfer`, and makes an inbound transfer depend on IdP availability. **C. Admin-maintained inbound allow-list per peering.** Symmetric with the outbound sender allow-list (§4.2), leaks nothing, and is fully deterministic — but it is manual work per recipient and will be forgotten in practice. **D. Accept anything, decide at delivery.** Rejected: it discards the property §4.3 exists for and would let a peer upload gigabytes before discovering the recipient is unknown. ## Recommendation **A**, with **C** available later as an optional narrowing for admins who do not want the probe surface. If A is chosen, `auth.perUserHomes` becomes a hard prerequisite for enabling peering, and that belongs in the docs handbook and in the admin UI at peering setup. ## Also needed once this is decided - `peering.*` config: staging dir, external base URL (for the absolute upload URL), `maxFileBytes`. - Construct `grpc.Server`, register `peering.Service`, wrap `root` with `peering.Mux`, mount `BlobHandler` at `/peer/v1/blob/`. - A `Deliverer` — that is #104. Natural companion to #103, which needs the same config surface.
Author
Owner

Carried over from #118, which is closed for the part it was titled for (the dependency claims in README + ARCHITECTURE §1.4, settled in PR #122).

Its final paragraph also asked to fold in example.config.json + the deployment handbook page gaining the peering block, and a handbook peering page (#109 pipeline). That deliberately did not ship with #122: beyond peering.statePath those config fields do not exist yet — they are blocked on this issue — and documenting them now would describe software that does not exist, which is the exact failure mode #118 was created to prevent.

So it belongs here: whichever option is chosen, the config surface, example.config.json, the handbook deployment page and the handbook peering page all land with the wiring.

If option A is chosen, the handbook must also state plainly that auth.perUserHomes is a hard prerequisite for enabling peering, and the admin UI should say so at peering setup rather than failing at the first inbound transfer.

Carried over from #118, which is closed for the part it was titled for (the dependency claims in README + ARCHITECTURE §1.4, settled in PR #122). Its final paragraph also asked to fold in `example.config.json` + the deployment handbook page gaining the `peering` block, and a handbook peering page (#109 pipeline). That deliberately did **not** ship with #122: beyond `peering.statePath` those config fields do not exist yet — they are blocked on this issue — and documenting them now would describe software that does not exist, which is the exact failure mode #118 was created to prevent. So it belongs here: whichever option is chosen, the config surface, `example.config.json`, the handbook deployment page and the handbook peering page all land with the wiring. If option A is chosen, the handbook must also state plainly that `auth.perUserHomes` is a hard prerequisite for enabling peering, and the admin UI should say so at peering setup rather than failing at the first inbound transfer.
Author
Owner

Decided (Nikola, 2026-08-05): A + C together.

Rationale: peering was defined from the start as admins choosing which users and groups may communicate with which peer. The per-recipient allow-list is manual by design, not a burden to be avoided — so there is no reason to ship A alone and treat C as a later narrowing.

A structural point this surfaces

The registry today has only the outbound direction. Peer.Senders{Users,Groups} (§4.2) answers "who on THIS instance may send to that peer", and CanSend enforces it.

C is the inbound mirror: "which of MY users may receive from that peer". That field does not exist. So A+C means extending Peer:

{
  "name": "omega",
  "url": "https://files.omega.example",
  "key": "CAIRN-PEER1....",
  "senders":   { "users": ["zeus321"], "groups": ["legal"] },   // outbound, exists
  "receivers": { "users": ["hades123"], "groups": ["records-unit"] }  // inbound, NEW
}

Same Senders struct type, same fail-closed semantics, same live-group evaluation (#96) so revoking a group membership revokes the ability to receive within the cache TTL. The admin UI grows one more picker beside the existing one, which matches how an admin already thinks about a peering: these people, that peer, both directions.

How A and C compose

AND, not OR. A recipient must be allow-listed for that peering and have a home. Fail closed, consistent with CanSend.

Corollary worth deciding deliberately: the refusal for "not allow-listed" and "no such user" should be the same STATUS_UNKNOWN_RECIPIENT. §4.3 accepts enumeration for authenticated peers, but distinguishing the two would additionally leak "this user exists here, you just aren't cleared for them" — which is a fact about our org chart, not about the transfer. Losing that distinction costs a peer admin very little; they can ask.

Consequences to honour when wiring

  • auth.perUserHomes becomes a hard prerequisite. Peering setup must refuse at configuration time with a clear reason, not accept a transfer and fail at delivery. Note the dogfood instance may not currently have it enabled — verify before #106.
  • IMPLEMENTED-PEERING.md §4.3 needs rewriting: it currently describes the existence check alone. It should describe A+C, the AND, and the single refusal code.
  • §4.2 needs a companion paragraph for the inbound list, and the JSON example above.
  • Registry: new Receivers field, a CanReceive mirroring CanSend, both fail-closed on an unknown peer or empty list.
**Decided (Nikola, 2026-08-05): A + C together.** Rationale: peering was defined from the start as admins choosing which users and groups may communicate with which peer. The per-recipient allow-list is manual **by design**, not a burden to be avoided — so there is no reason to ship A alone and treat C as a later narrowing. ## A structural point this surfaces The registry today has only the **outbound** direction. `Peer.Senders{Users,Groups}` (§4.2) answers *"who on THIS instance may send to that peer"*, and `CanSend` enforces it. C is the **inbound** mirror: *"which of MY users may receive from that peer"*. That field does not exist. So A+C means extending `Peer`: ```jsonc { "name": "omega", "url": "https://files.omega.example", "key": "CAIRN-PEER1....", "senders": { "users": ["zeus321"], "groups": ["legal"] }, // outbound, exists "receivers": { "users": ["hades123"], "groups": ["records-unit"] } // inbound, NEW } ``` Same `Senders` struct type, same fail-closed semantics, same live-group evaluation (#96) so revoking a group membership revokes the ability to receive within the cache TTL. The admin UI grows one more picker beside the existing one, which matches how an admin already thinks about a peering: *these people, that peer, both directions*. ## How A and C compose **AND, not OR.** A recipient must be allow-listed for that peering *and* have a home. Fail closed, consistent with `CanSend`. Corollary worth deciding deliberately: the refusal for "not allow-listed" and "no such user" should be the **same** `STATUS_UNKNOWN_RECIPIENT`. §4.3 accepts enumeration for authenticated peers, but distinguishing the two would additionally leak *"this user exists here, you just aren't cleared for them"* — which is a fact about our org chart, not about the transfer. Losing that distinction costs a peer admin very little; they can ask. ## Consequences to honour when wiring - `auth.perUserHomes` becomes a hard prerequisite. Peering setup must refuse **at configuration time** with a clear reason, not accept a transfer and fail at delivery. Note the dogfood instance may not currently have it enabled — verify before #106. - `IMPLEMENTED-PEERING.md` §4.3 needs rewriting: it currently describes the existence check alone. It should describe A+C, the AND, and the single refusal code. - §4.2 needs a companion paragraph for the inbound list, and the JSON example above. - Registry: new `Receivers` field, a `CanReceive` mirroring `CanSend`, both fail-closed on an unknown peer or empty list.
Author
Owner

Registry half landed (option C): Peer.Receivers + CanReceive, 8 tests, fail-closed on unknown peer / empty username / empty list. Allowlist added as a type alias for Senders, so stored JSON and existing callers are untouched.

One thing implementing it surfaced

CanSend takes a *auth.User because the sender is authenticated locally — we have their session, so we have their live groups (#96).

The recipient is not authenticated. They are a username on the wire, with no session to read groups from. So CanReceive cannot resolve groups itself, and takes them as a parameter:

func (r *Registry) CanReceive(peer, username string, groups []string) bool

The consequence, which needs to reach the admin UI and the handbook:

  • User-based receivers lists work everywhere, in all three auth modes.
  • Group-based receivers lists only work where the IdP admin client is configured (Keycloak admin client, #96). Without it the caller passes nil and a group-only list matches nobody — correctly fail-closed, but silently useless.

So an admin who sets a group-only inbound allow-list on an instance without the admin client has built something that refuses every transfer and gives no clue why. The admin UI should either refuse to save a group-only receivers list in that configuration, or warn plainly at save time. Cheaper to decide now than to debug later from a peer's side.

Still open on this issue: the cmd/cairnd wiring (config fields, Recipients implementation combining residency + CanReceive, gRPC server construction, Mux, blob route).

**Registry half landed** (option C): `Peer.Receivers` + `CanReceive`, 8 tests, fail-closed on unknown peer / empty username / empty list. `Allowlist` added as a type *alias* for `Senders`, so stored JSON and existing callers are untouched. ## One thing implementing it surfaced `CanSend` takes a `*auth.User` because the sender is authenticated locally — we have their session, so we have their live groups (#96). **The recipient is not authenticated.** They are a username on the wire, with no session to read groups from. So `CanReceive` cannot resolve groups itself, and takes them as a parameter: ```go func (r *Registry) CanReceive(peer, username string, groups []string) bool ``` The consequence, which needs to reach the admin UI and the handbook: - **User-based `receivers` lists work everywhere**, in all three auth modes. - **Group-based `receivers` lists only work where the IdP admin client is configured** (Keycloak admin client, #96). Without it the caller passes `nil` and a group-only list matches nobody — correctly fail-closed, but silently useless. So an admin who sets a group-only inbound allow-list on an instance without the admin client has built something that refuses every transfer and gives no clue why. The admin UI should either refuse to save a group-only `receivers` list in that configuration, or warn plainly at save time. Cheaper to decide now than to debug later from a peer's side. Still open on this issue: the `cmd/cairnd` wiring (config fields, `Recipients` implementation combining residency + `CanReceive`, gRPC server construction, `Mux`, blob route).
Author
Owner

Decided (Nikola, 2026-08-05): live group evaluation, no materialization. Revisitable in v0.6 beta.

The question raised

Should assigning a group to a receivers (or senders) list cause Cairn to read that group's members from the IdP and store them as users — tracking whether each user was added via group or directly, so that:

  • removing a group revokes the users it granted, but not directly-granted users;
  • a user in two listed groups keeps access when only one group is removed?

That provenance model is correct — set union with source attribution is exactly what a materialized design needs, and it names the three bugs a naive implementation would ship. Preserved here in full, because if we ever materialize, this is the design.

Why we are not doing it now

All three bugs are artefacts of materializing. Under live evaluation none exist: two groups is just "is any listed group in their current set", removing a group is deleting a string, and direct grants live in a separate list that group removal never touches. The bookkeeping only becomes necessary once membership has been copied out of the IdP.

It conflicts with a stated principle. §4.2 of this document says live evaluation "keeps identity consumed, never implemented — consistent with §5.4 of ARCHITECTURE.md", and the README leads with the same line. A stored, Cairn-maintained membership list is a small user directory, and keeping it true needs a sync loop — background job, state, failure modes — in a product that advertises no cron sidecar.

It reopens the revocation window. #96 shrank revocation from session lifetime to a 60s cache TTL; that was the point of the work. A materialized list moves it back to "until someone re-syncs", i.e. until an admin notices. Concretely: continuing to receive files after HR removed you from records-unit.

It makes the config illegible. The admin types records-unit; materializing writes 40 usernames that will be wrong next week — and the provenance flags are then needed to reconstruct what the admin meant, which storing the intent never loses. The config should record the decision, not the computation.

What we do instead

The pattern #96 already established — live resolution with a last-known-good fallback, availability over freshness:

  • Store the group name (intent), never the expansion.
  • Resolve live at transfer time, cached by username with a TTL.
  • On IdP outage, fall back to the last successful resolution.
  • Never resolved → fail closed.

This does not fix group-based lists on an instance with no IdP admin client (see the comment above). The honest position is that group lists are a feature of instances that gave Cairn a way to ask about groups; user-based lists work everywhere. The admin UI should say so rather than us building a directory to hide it.

The condition for changing our mind

If peering ever needs to work with the IdP offline for long stretches, materialization becomes the pragmatic answer and the provenance design above is the right one. Not a v0.5 requirement.

Flagged for v0.6 beta review — real usage between instances is the test of whether live resolution is fast enough and whether the no-admin-client limitation bites in practice.

**Decided (Nikola, 2026-08-05): live group evaluation, no materialization. Revisitable in v0.6 beta.** ## The question raised Should assigning a group to a `receivers` (or `senders`) list cause Cairn to read that group's members from the IdP and store them as users — tracking whether each user was added via group or directly, so that: - removing a group revokes the users it granted, but not directly-granted users; - a user in two listed groups keeps access when only one group is removed? **That provenance model is correct** — set union with source attribution is exactly what a materialized design needs, and it names the three bugs a naive implementation would ship. Preserved here in full, because if we ever materialize, this is the design. ## Why we are not doing it now **All three bugs are artefacts of materializing.** Under live evaluation none exist: two groups is just "is any listed group in their current set", removing a group is deleting a string, and direct grants live in a separate list that group removal never touches. The bookkeeping only becomes necessary once membership has been copied out of the IdP. **It conflicts with a stated principle.** §4.2 of this document says live evaluation *"keeps identity consumed, never implemented — consistent with §5.4 of ARCHITECTURE.md"*, and the README leads with the same line. A stored, Cairn-maintained membership list is a small user directory, and keeping it true needs a sync loop — background job, state, failure modes — in a product that advertises no cron sidecar. **It reopens the revocation window.** #96 shrank revocation from session lifetime to a 60s cache TTL; that was the point of the work. A materialized list moves it back to "until someone re-syncs", i.e. until an admin notices. Concretely: continuing to receive files after HR removed you from `records-unit`. **It makes the config illegible.** The admin types `records-unit`; materializing writes 40 usernames that will be wrong next week — and the provenance flags are then needed to reconstruct what the admin *meant*, which storing the intent never loses. The config should record the decision, not the computation. ## What we do instead The pattern #96 already established — live resolution with a last-known-good fallback, availability over freshness: - Store the **group name** (intent), never the expansion. - Resolve live at transfer time, cached by username with a TTL. - On IdP outage, fall back to the last successful resolution. - Never resolved → fail closed. This does **not** fix group-based lists on an instance with no IdP admin client (see the comment above). The honest position is that group lists are a feature of instances that gave Cairn a way to ask about groups; user-based lists work everywhere. The admin UI should say so rather than us building a directory to hide it. ## The condition for changing our mind If peering ever needs to work with the IdP offline for long stretches, materialization becomes the pragmatic answer and the provenance design above is the right one. Not a v0.5 requirement. **Flagged for v0.6 beta review** — real usage between instances is the test of whether live resolution is fast enough and whether the no-admin-client limitation bites in practice.
Author
Owner

Implemented — closing

Option A + C as decided, shipped across v0.4.1 and v0.4.2. This also unblocked the cmd/cairnd wiring that the issue title flagged.

Registry: the inbound mirror

Peer.Receivers alongside Senders, with CanReceive mirroring CanSend — fail closed on an unknown peer, an empty username, or an empty list. Allowlist was added as a type alias for Senders, so stored JSON and every existing caller are untouched.

The test that matters is TestSendPermissionDoesNotGrantReceivePermission: being cleared to send to a peer must not imply being cleared to receive from it. Those are different privileges and an admin granting one has not decided the other.

The A+C composition

peerRecipients.Exists(peer, username) in cmd/cairnd ANDs the two halves:

  1. Allow-listedCanReceive(peer, username, nil)
  2. Resident/home/<username> exists on the backend

Both fail closed. The refusal is a single STATUS_UNKNOWN_RECIPIENT for either cause: Q4 already accepts that an authenticated peer can enumerate recipients, but distinguishing "no such user" from "not cleared for you" would additionally leak a fact about our org chart rather than about the transfer.

The signature change this forced

Recipients.Exists originally took only a username. That cannot express A+C — the check is per-peering, so it needs to know which peering is asking. Changed to Exists(peer, username string), with a test asserting the authenticated peer actually reaches the check so the rule cannot be silently reduced back to a global existence test.

Known limitation, worth carrying forward

CanReceive takes groups as a parameter rather than resolving them, because unlike a sender the recipient is not authenticated — they are a name on the wire with no session to read live groups from. The wiring passes nil.

Consequence: user-based receivers lists work in every auth mode; group-based lists need the IdP admin client. Without it a group-only list matches nobody — correctly fail-closed, but silently useless. The admin UI should refuse to save that combination, or warn plainly at save time, rather than letting an admin discover it from the peer's side. Carrying that to #105.

Also settled here

auth.perUserHomes is a hard prerequisite for peering, since residency is the existence test and §5 delivers to /home/<recipient>/Inbox/<peer>/. It now defaults ON (v0.4.1), and docs/handbook/deployment.md documents that changing it on a populated instance is a migration, not a toggle.

## Implemented — closing Option **A + C** as decided, shipped across v0.4.1 and v0.4.2. This also unblocked the `cmd/cairnd` wiring that the issue title flagged. ### Registry: the inbound mirror `Peer.Receivers` alongside `Senders`, with `CanReceive` mirroring `CanSend` — fail closed on an unknown peer, an empty username, or an empty list. `Allowlist` was added as a **type alias** for `Senders`, so stored JSON and every existing caller are untouched. The test that matters is `TestSendPermissionDoesNotGrantReceivePermission`: being cleared to send to a peer must not imply being cleared to receive from it. Those are different privileges and an admin granting one has not decided the other. ### The A+C composition `peerRecipients.Exists(peer, username)` in `cmd/cairnd` ANDs the two halves: 1. **Allow-listed** — `CanReceive(peer, username, nil)` 2. **Resident** — `/home/<username>` exists on the backend Both fail closed. The refusal is a single `STATUS_UNKNOWN_RECIPIENT` for either cause: Q4 already accepts that an authenticated peer can enumerate recipients, but distinguishing "no such user" from "not cleared for you" would additionally leak a fact about our org chart rather than about the transfer. ### The signature change this forced `Recipients.Exists` originally took only a username. That cannot express A+C — the check is *per-peering*, so it needs to know which peering is asking. Changed to `Exists(peer, username string)`, with a test asserting the authenticated peer actually reaches the check so the rule cannot be silently reduced back to a global existence test. ### Known limitation, worth carrying forward `CanReceive` takes groups as a **parameter** rather than resolving them, because unlike a sender the recipient is not authenticated — they are a name on the wire with no session to read live groups from. The wiring passes `nil`. Consequence: **user-based `receivers` lists work in every auth mode; group-based lists need the IdP admin client.** Without it a group-only list matches nobody — correctly fail-closed, but silently useless. The admin UI should refuse to save that combination, or warn plainly at save time, rather than letting an admin discover it from the peer's side. Carrying that to #105. ### Also settled here `auth.perUserHomes` is a **hard prerequisite** for peering, since residency is the existence test and §5 delivers to `/home/<recipient>/Inbox/<peer>/`. It now defaults ON (v0.4.1), and `docs/handbook/deployment.md` documents that changing it on a populated instance is a migration, not a toggle.
Cordy closed this issue 2026-08-06 02:30:17 +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#123
No description provided.