Skip to content

feat(#575): bind an attachment handle to the room that minted it - #738

Merged
Weegy merged 1 commit into
mainfrom
feat/575-handle-scope-binding
Aug 18, 2026
Merged

feat(#575): bind an attachment handle to the room that minted it#738
Weegy merged 1 commit into
mainfrom
feat/575-handle-scope-binding

Conversation

@Weegy

@Weegy Weegy commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

The last open piece of #575, and the one audienceFloorGuard's own header has been advertising:

It checks the floor at redemption, not the floor at minting. So it stops a room from redeeming a handle that room may not read — but it cannot yet stop a handle minted in a narrow room from being redeemed in a room that happens to hold the capability.

A storage key is just a string, and a string can be pasted into a group chat.

Each key is now pinned, on first sighting, to the ScopeId it was resolved in. Every later resolution has to come from the same room.

The room is a ScopeId, never the raw scope string

This is the decision the whole change turns on. turnContext.sessionScope carries its own warning:

NOT safe as a key on its own: resolveScope returns the literal 'http-default' for unscoped HTTP turns, so every such caller shares this value — that was the live cross-user hole in #445.

And unsharedConversationScope's header records the same hole in a second place: a Teams activity without a conversation id yields teams-unknown, shared by every unrelated caller who hits that gap.

Keying a security binding on those strings would not merely fail to restrict — it would declare every unrelated caller to be the same room, which is worse than no binding at all, because it reads as enforcement. So the scope is parsed into ScopeId and only addressable scopes are bound. A non-addressable scope switches the check off rather than approximating it, and two tests pin that.

Three smaller decisions, each with a failure mode that looks fine

The check rides on the reader, not the call sites. Same argument that put the floor check there in #733: a storage key outlives the turn that minted it, so a check at one resolution site holds exactly until somebody adds the next site and forgets.

The floor is evaluated first. A room that may not read attachments at all is refused before this touches the database — so no row is ever written that would bind a handle to a room that was never allowed to see it.

bindIfAbsent is ON CONFLICT DO NOTHING, and that clause is the security property. An UPSERT would let a room that was just refused re-bind the handle to itself and succeed on the next attempt — the leak reintroduced through the fix, by one word. It is also what makes a concurrent first sighting safe rather than a 23505; a pg test races three writers to prove it.

readByUrl is deliberately untouched: a URL is not a storage key, carries no binding, and its reachability is the channel's business.

Blast radius

Surface Effect
Deployments with AUDIENCE_FLOOR_ENABLED unset none — no store is published, the check stands down
Deployments with it set a handle redeems only in the room that minted it
Handles first resolved from a non-addressable scope not bound, not enforced (see above)
Published plugin contract none

Verification

Mutation-checked — all four died:

Mutation Result
ON CONFLICT DO UPDATE instead of DO NOTHING kills the no-overwrite test
bind non-addressable scopes too kills both stand-down tests
a failing binding store reads as "unbound" kills the outage test
compare scope_ref without scope_kind kills the kind test

One test in this file asserts less than it first appears to, and I would rather say so than let it read as coverage: the cross-kind collision (personal:u1 as a conversation id) is not reachable from today's producers, because parseSessionScope classifies that string as personal. The kind comparison is therefore defensive, and the test asserts bindingsEqual directly instead of dressing an unreachable case up as end-to-end.

Where #575 stands

Piece State
ScopeId (phase 1) merged
Floor + grants (phase 2) merged #729
Guards 1–3 merged #731, #732, #733
Switching it on merged #734
Durable grant store + admin surface merged #737
Handle bound at minting this PR

Refs #575


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Guard 3 checked the floor at REDEMPTION — may this room redeem a storage
handle at all — and its own header said what that leaves open: a key issued
in a private chat is just a string, and a string can be pasted into a group
chat that happens to hold `attachment:read`.

Each key is now pinned on first sighting to the ScopeId it was resolved in
(migration 0036), and every later resolution must come from the same room.

Four decisions worth stating:

1. The room is a ScopeId, never the raw scope string. `turnContext.sessionScope`
   carries its own warning that it is "NOT safe as a key on its own" —
   `resolveScope` hands every unscoped HTTP turn the literal 'http-default',
   which was the live cross-user hole in #445, and `teams-unknown` was the same
   hole in a second place. Keying a security binding on that would declare every
   unrelated caller to be the same room: enforcement in appearance, universal
   access in fact. `isAddressableScope` is the gate, and a non-addressable scope
   disables the check rather than approximating it.

2. The check rides on the reader, not on the call sites — same argument that put
   the floor check there. A storage key outlives its turn.

3. The floor is evaluated FIRST, so a room that may not read attachments at all
   is refused without a row ever being written that would bind the handle to a
   room that was never allowed to see it.

4. `bindIfAbsent` is ON CONFLICT DO NOTHING, and that clause is the security
   property: an UPSERT would let a room that was just refused re-bind the handle
   to itself and read it on the next attempt. It is also what makes a concurrent
   first sighting safe rather than a 23505.

`readByUrl` is untouched — a URL is not a storage key and carries no binding.

Verification: middleware suite 6687 tests, 0 fail; the pg suite against a real
Postgres in a throwaway container. typecheck, lint, #470 (3294) and #573 (406,
unchanged) all green.

Mutation-checked, all four died:
- ON CONFLICT DO UPDATE instead of DO NOTHING -> kills the no-overwrite test
- binding non-addressable scopes -> kills both stand-down tests
- a failing binding store reading as "unbound" -> kills the outage test
- comparing scope_ref without scope_kind -> kills the kind test
@Weegy
Weegy merged commit 09f026b into main Aug 18, 2026
9 checks passed
@Weegy
Weegy deleted the feat/575-handle-scope-binding branch August 19, 2026 06:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant