Peering: how does the receiver decide a recipient "exists"? (blocks the cmd/cairnd wiring) #123
Labels
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: Cordy/Cairn#123
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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 receivinginstance verifies the recipient exists locally before any bytes flow."
PrepareTransferneeds apeering.Recipientsimplementation to call.There isn't one, and it is not an oversight in the peering package — the
information does not exist:
localmode —auth.Localholdsusers map[string]string(unexported).Answerable, but needs an exported accessor.
oidcmode — the instance holds no list of users at all. A user doesnot exist locally until they first log in. Cairn consumes identity, it does
not implement it (ARCHITECTURE §5.4), so there is nothing to enumerate.
ldapmode — same:search-then-bindauthenticates a presentedcredential; 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 authmodes, 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, whichpeering 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 transferdepend 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.perUserHomesbecomes a hardprerequisite 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 uploadURL),
maxFileBytes.grpc.Server, registerpeering.Service, wraprootwithpeering.Mux, mountBlobHandlerat/peer/v1/blob/.Deliverer— that is #104.Natural companion to #103, which needs the same config surface.
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 thepeeringblock, and a handbook peering page (#109 pipeline). That deliberately did not ship with #122: beyondpeering.statePaththose 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.perUserHomesis a hard prerequisite for enabling peering, and the admin UI should say so at peering setup rather than failing at the first inbound transfer.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", andCanSendenforces 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:Same
Sendersstruct 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.perUserHomesbecomes 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.Receiversfield, aCanReceivemirroringCanSend, both fail-closed on an unknown peer or empty list.Registry half landed (option C):
Peer.Receivers+CanReceive, 8 tests, fail-closed on unknown peer / empty username / empty list.Allowlistadded as a type alias forSenders, so stored JSON and existing callers are untouched.One thing implementing it surfaced
CanSendtakes a*auth.Userbecause 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
CanReceivecannot resolve groups itself, and takes them as a parameter:The consequence, which needs to reach the admin UI and the handbook:
receiverslists work everywhere, in all three auth modes.receiverslists only work where the IdP admin client is configured (Keycloak admin client, #96). Without it the caller passesniland 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
receiverslist 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/cairndwiring (config fields,Recipientsimplementation combining residency +CanReceive, gRPC server construction,Mux, blob route).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(orsenders) 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: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:
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.
Implemented — closing
Option A + C as decided, shipped across v0.4.1 and v0.4.2. This also unblocked the
cmd/cairndwiring that the issue title flagged.Registry: the inbound mirror
Peer.ReceiversalongsideSenders, withCanReceivemirroringCanSend— fail closed on an unknown peer, an empty username, or an empty list.Allowlistwas added as a type alias forSenders, 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)incmd/cairndANDs the two halves:CanReceive(peer, username, nil)/home/<username>exists on the backendBoth fail closed. The refusal is a single
STATUS_UNKNOWN_RECIPIENTfor 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.Existsoriginally took only a username. That cannot express A+C — the check is per-peering, so it needs to know which peering is asking. Changed toExists(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
CanReceivetakes 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 passesnil.Consequence: user-based
receiverslists 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.perUserHomesis 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), anddocs/handbook/deployment.mddocuments that changing it on a populated instance is a migration, not a toggle.