From a377f4fd78af2737e74736073aa4f13d89700ad6 Mon Sep 17 00:00:00 2001 From: mintaka Date: Mon, 5 Oct 2026 22:45:33 -0400 Subject: [PATCH 1/4] feat(store): attach channels to agents with tree or explicit membership (RIG-4186) Migration 0009 adds channels.parent_agent_id and membership_mode with their CHECKs and indexes, plus channel_subscriptions with its own RLS, tenant_isolation policy and grants. CreateChannel accepts an agent anchor and a mode, authorizes the attach against the owner set, and writes no member rows for a TREE channel. ReparentChannel runs the participant gate before every shape refusal. Co-authored-by: Matt Wilkinson --- go/internal/store/authz.go | 15 + go/internal/store/channel_tree_pgtest_test.go | 350 ++++++++++++++++++ go/internal/store/channels.go | 198 ++++++++-- go/internal/store/db/authz.sql.go | 25 ++ go/internal/store/db/channels.sql.go | 88 ++++- go/internal/store/db/models.go | 9 + go/internal/store/db/querier.go | 5 + go/internal/store/inputs.go | 8 +- .../store/migrations/0009_channel_tree.sql | 43 +++ go/internal/store/queries/authz.sql | 12 + go/internal/store/queries/channels.sql | 38 +- go/internal/store/rls_pgtest_test.go | 2 +- go/internal/store/types.go | 10 + 13 files changed, 765 insertions(+), 38 deletions(-) create mode 100644 go/internal/store/channel_tree_pgtest_test.go create mode 100644 go/internal/store/migrations/0009_channel_tree.sql diff --git a/go/internal/store/authz.go b/go/internal/store/authz.go index 7c4e259d2..6d08d2a23 100644 --- a/go/internal/store/authz.go +++ b/go/internal/store/authz.go @@ -101,6 +101,21 @@ func requireGroupCreateAuthz(ctx context.Context, q db.DBTX, actor AccountID, gr return nil } +// requireAgentAttachAuthz authorizes an actor to attach a channel under agentID. +func requireAgentAttachAuthz(ctx context.Context, q db.DBTX, actor, agentID AccountID) error { + authorized, err := db.New(q).AgentAttachAuthorized(ctx, db.AgentAttachAuthorizedParams{ + AccountID: string(agentID), + OwnerUserID: string(actor), + }) + if err != nil { + return fmt.Errorf("store: check agent attach authz: %w", err) + } + if !authorized { + return fmt.Errorf("%w: agent %q", ErrNotFound, agentID) + } + return nil +} + // IsAgentWorkspaceVisible reports whether actor may observe the agent's // workspace: it is a member of the agent's home channel (fork f — workspace // access is a projection of home-channel membership). Used by the SubscribeComms diff --git a/go/internal/store/channel_tree_pgtest_test.go b/go/internal/store/channel_tree_pgtest_test.go new file mode 100644 index 000000000..eea6230c0 --- /dev/null +++ b/go/internal/store/channel_tree_pgtest_test.go @@ -0,0 +1,350 @@ +//go:build pgtest + +package store + +import ( + "context" + "testing" +) + +func mustAttachedChannel(t *testing.T, s *Store, actor, parent AccountID, name string, mode ChannelMembershipMode, members ...AccountID) Channel { + t.Helper() + ch, err := s.CreateChannel(t.Context(), actor, NewChannel{ + Name: name, Kind: ChannelKindChannel, ParentAgentID: parent, + MembershipMode: mode, MemberAccountIDs: members, + }) + if err != nil { + t.Fatalf("CreateChannel(%q): %v", name, err) + } + return ch +} + +func channelTreeState(t *testing.T, s *Store, id ChannelID) (string, int16, int) { + t.Helper() + var parent string + var mode int16 + var members int + if err := s.pool.QueryRow(t.Context(), ` + SELECT COALESCE(parent_agent_id, ''), membership_mode, + (SELECT count(*) FROM channel_members WHERE channel_id = channels.id) + FROM channels WHERE id = $1`, string(id)).Scan(&parent, &mode, &members); err != nil { + t.Fatalf("read channel tree state: %v", err) + } + return parent, mode, members +} + +func TestChannelTreeCreateUnderAgent(t *testing.T) { + s := newTestStore(t) + owner := mustUser(t, s, "tree-owner") + anchor := mustAgent(t, s, owner.ID, "tree-anchor") + + explicit := mustAttachedChannel(t, s, owner.ID, anchor.ID, "explicit", ChannelMembershipModeExplicit, anchor.ID) + parent, mode, members := channelTreeState(t, s, explicit.ID) + if parent != string(anchor.ID) || mode != int16(ChannelMembershipModeExplicit) || members != 2 { + t.Fatalf("explicit channel state = (%q, %d, %d), want (%q, 0, 2)", parent, mode, members, anchor.ID) + } + if len(explicit.MemberAccountIDs) != 2 { + t.Fatalf("explicit returned members = %v, want owner and agent", explicit.MemberAccountIDs) + } + + tree := mustAttachedChannel(t, s, owner.ID, anchor.ID, "tree", ChannelMembershipModeTree) + parent, mode, members = channelTreeState(t, s, tree.ID) + if parent != string(anchor.ID) || mode != int16(ChannelMembershipModeTree) || members != 0 { + t.Fatalf("tree channel state = (%q, %d, %d), want (%q, 1, 0)", parent, mode, members, anchor.ID) + } + if tree.MemberAccountIDs != nil { + t.Fatalf("tree returned members = %v, want nil before derived reads", tree.MemberAccountIDs) + } +} + +func TestChannelTreeCreateAuthorizationAndInputRefusals(t *testing.T) { + s := newTestStore(t) + owner := mustUser(t, s, "create-owner") + other := mustUser(t, s, "create-other") + agent := mustAgent(t, s, owner.ID, "create-agent") + target := mustAgent(t, s, owner.ID, "create-target") + foreign := mustAgent(t, s, other.ID, "create-foreign") + + if _, err := s.CreateChannel(t.Context(), agent.ID, NewChannel{ + Name: "agent-created", ParentAgentID: target.ID, + }); err != nil { + t.Fatalf("same-owner agent attach: %v", err) + } + _, err := s.CreateChannel(t.Context(), owner.ID, NewChannel{Name: "cross-owner", ParentAgentID: foreign.ID}) + sentinelIs(t, err, ErrNotFound, "cross-owner channel attach") + _, err = s.CreateChannel(t.Context(), owner.ID, NewChannel{Name: "unknown-agent", ParentAgentID: AccountID("missing-agent")}) + sentinelIs(t, err, ErrNotFound, "unknown agent attach") + + ownerOnly := ChannelPolicy{PostPolicy: ChannelPostPolicyOwnerOnly, OwnerAccountID: owner.ID} + cases := []struct { + name string + input NewChannel + }{ + {name: "both parents", input: NewChannel{Name: "both", GroupID: ChannelGroupID("group"), ParentAgentID: agent.ID}}, + {name: "tree without anchor", input: NewChannel{Name: "no-anchor", MembershipMode: ChannelMembershipModeTree}}, + {name: "tree mandatory", input: NewChannel{Name: "mandatory", ParentAgentID: agent.ID, MembershipMode: ChannelMembershipModeTree, Policy: ChannelPolicy{MandatorySubscription: true}}}, + {name: "tree owner only", input: NewChannel{Name: "owner-only", ParentAgentID: agent.ID, MembershipMode: ChannelMembershipModeTree, Policy: ownerOnly}}, + {name: "unknown membership mode", input: NewChannel{Name: "bad-mode", ParentAgentID: agent.ID, MembershipMode: ChannelMembershipMode(7)}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := s.CreateChannel(t.Context(), owner.ID, tc.input) + sentinelIs(t, err, ErrInvalidArgument, tc.name) + }) + } +} + +func TestChannelTreeNameNamespace(t *testing.T) { + s := newTestStore(t) + owner := mustUser(t, s, "namespace-owner") + first := mustAgent(t, s, owner.ID, "namespace-first") + second := mustAgent(t, s, owner.ID, "namespace-second") + mustAttachedChannel(t, s, owner.ID, first.ID, "same", ChannelMembershipModeExplicit) + _, err := s.CreateChannel(t.Context(), owner.ID, NewChannel{Name: "same", Kind: ChannelKindChannel, ParentAgentID: first.ID}) + sentinelIs(t, err, ErrConflict, "duplicate channel name under one agent") + if _, err := s.CreateChannel(t.Context(), owner.ID, NewChannel{Name: "same", Kind: ChannelKindChannel, ParentAgentID: second.ID}); err != nil { + t.Fatalf("same channel name under different agents: %v", err) + } +} + +func TestReparentChannelShapeRefusals(t *testing.T) { + s := newTestStore(t) + owner := mustUser(t, s, "shape-owner") + outsider := mustUser(t, s, "shape-outsider") + anchor := mustAgent(t, s, owner.ID, "shape-anchor") + group, err := s.CreateChannelGroup(t.Context(), owner.ID, NewChannelGroup{Name: "shape-group"}) + if err != nil { + t.Fatalf("CreateChannelGroup: %v", err) + } + grouped, err := s.CreateChannel(t.Context(), owner.ID, NewChannel{Name: "grouped", GroupID: group.ID}) + if err != nil { + t.Fatalf("CreateChannel(grouped): %v", err) + } + dm, err := s.CreateChannel(t.Context(), owner.ID, NewChannel{Name: "dm", Kind: ChannelKindDM}) + if err != nil { + t.Fatalf("CreateChannel(DM): %v", err) + } + tree := mustAttachedChannel(t, s, owner.ID, anchor.ID, "tree", ChannelMembershipModeTree) + + cases := []struct { + name string + actor AccountID + channelID ChannelID + parent AccountID + want error + }{ + {name: "grouped channel", actor: owner.ID, channelID: grouped.ID, want: ErrInvalidArgument}, + {name: "DM kind", actor: owner.ID, channelID: dm.ID, parent: anchor.ID, want: ErrInvalidArgument}, + {name: "home channel", actor: owner.ID, channelID: anchor.Agent.HomeChannelID, parent: anchor.ID, want: ErrInvalidArgument}, + {name: "tree detach to root", actor: owner.ID, channelID: tree.ID, want: ErrInvalidArgument}, + {name: "non-participant grouped channel", actor: outsider.ID, channelID: grouped.ID, want: ErrNotFound}, + {name: "non-participant DM", actor: outsider.ID, channelID: dm.ID, want: ErrNotFound}, + {name: "non-participant home channel", actor: outsider.ID, channelID: anchor.Agent.HomeChannelID, want: ErrNotFound}, + {name: "non-participant tree channel", actor: outsider.ID, channelID: tree.ID, want: ErrNotFound}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := s.ReparentChannel(t.Context(), tc.actor, tc.channelID, tc.parent) + sentinelIs(t, err, tc.want, tc.name) + }) + } + _, err = s.ReparentChannel(t.Context(), owner.ID, ChannelID("unknown-channel"), anchor.ID) + sentinelIs(t, err, ErrNotFound, "unknown channel") +} + +func TestReparentChannelOwnerBoundaryAndDestination(t *testing.T) { + s := newTestStore(t) + owner := mustUser(t, s, "move-owner") + other := mustUser(t, s, "move-other") + anchor := mustAgent(t, s, owner.ID, "move-anchor") + foreign := mustAgent(t, s, other.ID, "move-foreign") + ch := mustAttachedChannel(t, s, owner.ID, anchor.ID, "explicit", ChannelMembershipModeExplicit) + + _, err := s.ReparentChannel(t.Context(), owner.ID, ch.ID, foreign.ID) + sentinelIs(t, err, ErrNotFound, "cross-owner destination") + _, err = s.ReparentChannel(t.Context(), owner.ID, ch.ID, AccountID("missing-agent")) + sentinelIs(t, err, ErrNotFound, "unknown destination agent") +} + +func TestReparentChannelLeafAllowsDescendantAgent(t *testing.T) { + s := newTestStore(t) + owner := mustUser(t, s, "leaf-owner") + anchor := mustAgent(t, s, owner.ID, "leaf-anchor") + descendant := mustAgentWithParent(t, s, owner.ID, anchor.ID, "leaf-descendant") + ch := mustAttachedChannel(t, s, owner.ID, anchor.ID, "tree", ChannelMembershipModeTree) + + // A channel is a leaf, so anchoring it below an agent descendant cannot cycle. + moved, err := s.ReparentChannel(t.Context(), descendant.ID, ch.ID, descendant.ID) + if err != nil { + t.Fatalf("descendant re-anchors ancestor channel: %v", err) + } + parent, mode, members := channelTreeState(t, s, ch.ID) + if parent != string(descendant.ID) || mode != int16(ChannelMembershipModeTree) || members != 0 { + t.Fatalf("reparented tree channel state = (%q, %d, %d), want (%q, 1, 0)", parent, mode, members, descendant.ID) + } + if moved.ID != ch.ID { + t.Fatalf("reparented channel id = %q, want %q", moved.ID, ch.ID) + } +} + +func TestReparentExplicitChannelToRoot(t *testing.T) { + s := newTestStore(t) + owner := mustUser(t, s, "detach-owner") + anchor := mustAgent(t, s, owner.ID, "detach-anchor") + ch := mustAttachedChannel(t, s, owner.ID, anchor.ID, "explicit", ChannelMembershipModeExplicit) + + if _, err := s.ReparentChannel(t.Context(), owner.ID, ch.ID, ""); err != nil { + t.Fatalf("detach explicit channel: %v", err) + } + parent, mode, _ := channelTreeState(t, s, ch.ID) + if parent != "" || mode != int16(ChannelMembershipModeExplicit) { + t.Fatalf("detached channel state = (%q, %d), want root explicit", parent, mode) + } +} + +func TestReparentChannelDuplicateNameAtDestination(t *testing.T) { + s := newTestStore(t) + owner := mustUser(t, s, "duplicate-owner") + first := mustAgent(t, s, owner.ID, "duplicate-first") + second := mustAgent(t, s, owner.ID, "duplicate-second") + ch := mustAttachedChannel(t, s, owner.ID, first.ID, "duplicate", ChannelMembershipModeExplicit) + mustAttachedChannel(t, s, owner.ID, second.ID, "duplicate", ChannelMembershipModeExplicit) + + _, err := s.ReparentChannel(t.Context(), owner.ID, ch.ID, second.ID) + sentinelIs(t, err, ErrConflict, "duplicate channel name at destination") + parent, _, _ := channelTreeState(t, s, ch.ID) + if parent != string(first.ID) { + t.Fatalf("failed reparent changed source parent to %q, want %q", parent, first.ID) + } +} + +func TestConvertedDMOwnersCanAttachToOwnAgents(t *testing.T) { + s := newTestStore(t) + ownerA := mustUser(t, s, "converted-owner-a") + ownerB := mustUser(t, s, "converted-owner-b") + ownerC := mustUser(t, s, "converted-owner-c") + agentA := mustAgent(t, s, ownerA.ID, "converted-a") + agentB := mustAgent(t, s, ownerB.ID, "converted-b") + agentC := mustAgent(t, s, ownerC.ID, "converted-c") + + channelID, _ := openDM(t, s, ownerA.ID, "dm--converted-a--converted-b", []AccountID{agentA.ID, agentB.ID}) + if _, _, err := s.UpdateChannelMembers(t.Context(), agentA.ID, channelID, + []MemberUpdate{{AccountID: agentC.ID}}, MemberUpdatesOptions{ConvertChannelName: "converted-room"}); err != nil { + t.Fatalf("convert DM: %v", err) + } + for _, tc := range []struct { + name string + actor AccountID + parent AccountID + }{ + {name: "owner A", actor: ownerA.ID, parent: agentA.ID}, + {name: "owner B", actor: ownerB.ID, parent: agentB.ID}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := s.ReparentChannel(t.Context(), tc.actor, channelID, tc.parent); err != nil { + t.Fatalf("attach converted DM by %s: %v", tc.name, err) + } + parent, _, _ := channelTreeState(t, s, channelID) + if parent != string(tc.parent) { + t.Fatalf("converted channel parent = %q, want %q", parent, tc.parent) + } + }) + } +} + +func TestChannelTreeMembershipModeDoesNotChangeAfterCreate(t *testing.T) { + s := newTestStore(t) + owner := mustUser(t, s, "immutable-owner") + other := mustUser(t, s, "immutable-other") + first := mustAgent(t, s, owner.ID, "immutable-first") + second := mustAgent(t, s, owner.ID, "immutable-second") + explicit := mustAttachedChannel(t, s, owner.ID, first.ID, "explicit", ChannelMembershipModeExplicit, first.ID) + tree := mustAttachedChannel(t, s, owner.ID, first.ID, "tree", ChannelMembershipModeTree) + + if _, _, err := s.UpdateChannelMembers(t.Context(), owner.ID, explicit.ID, + []MemberUpdate{{AccountID: other.ID, Subscribed: true}}, MemberUpdatesOptions{}); err != nil { + t.Fatalf("UpdateChannelMembers(explicit): %v", err) + } + if _, err := s.SetChannelPolicy(t.Context(), owner.ID, explicit.ID, ChannelPolicy{}); err != nil { + t.Fatalf("SetChannelPolicy(explicit): %v", err) + } + message, _, err := s.AppendMessage(t.Context(), Message{ + AuthorAccountID: owner.ID, Blocks: []MessageBlock{textBlock("pin target")}, + }, string(explicit.ID), TopicRef{Name: "general", Create: true}, "") + if err != nil { + t.Fatalf("AppendMessage(explicit): %v", err) + } + if _, err := s.PinMessage(t.Context(), explicit.ID, message.ID, "", owner.ID); err != nil { + t.Fatalf("PinMessage(explicit): %v", err) + } + if _, err := s.ReparentChannel(t.Context(), owner.ID, explicit.ID, second.ID); err != nil { + t.Fatalf("ReparentChannel(explicit): %v", err) + } + + // The outcome of each write does not matter here, only that none of them + // moves the mode; a refused write is as good as a committed one. + _, _, err = s.UpdateChannelMembers(t.Context(), owner.ID, tree.ID, + []MemberUpdate{{AccountID: other.ID, Subscribed: true}}, MemberUpdatesOptions{}) + t.Logf("UpdateChannelMembers(tree): %v", err) + _, err = s.SetChannelPolicy(t.Context(), owner.ID, tree.ID, ChannelPolicy{}) + t.Logf("SetChannelPolicy(tree): %v", err) + if _, err := s.ReparentChannel(t.Context(), owner.ID, tree.ID, second.ID); err != nil { + t.Fatalf("ReparentChannel(tree): %v", err) + } + + _, explicitMode, _ := channelTreeState(t, s, explicit.ID) + _, treeMode, _ := channelTreeState(t, s, tree.ID) + if explicitMode != int16(ChannelMembershipModeExplicit) || treeMode != int16(ChannelMembershipModeTree) { + t.Fatalf("membership modes changed: explicit=%d tree=%d", explicitMode, treeMode) + } +} + +func TestChannelTreeSubscriptionsTenantIsolation(t *testing.T) { + s := newTestStore(t) + tenantA := seedTenant(t, s, "channel-subscriptions-a") + tenantB := seedTenant(t, s, "channel-subscriptions-b") + ctxA := WithTenant(t.Context(), tenantA) + ctxB := WithTenant(t.Context(), tenantB) + owner, err := s.CreateUser(ctxA, NewUser{Handle: "subscription-owner", DisplayName: "subscription-owner"}) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + channel, err := s.CreateChannel(ctxA, owner.ID, NewChannel{Name: "subscription-room"}) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + + tx, err := s.beginTenantTx(ctxA) + if err != nil { + t.Fatalf("begin tenant A insert: %v", err) + } + if _, err := tx.Exec(ctxA, `INSERT INTO channel_subscriptions (channel_id, account_id, subscribed) VALUES ($1, $2, TRUE)`, string(channel.ID), string(owner.ID)); err != nil { + t.Fatalf("insert tenant A subscription: %v", err) + } + if err := tx.Commit(ctxA); err != nil { + t.Fatalf("commit tenant A subscription: %v", err) + } + + count := func(ctx context.Context) int { + t.Helper() + tx, err := s.beginTenantTx(ctx) + if err != nil { + t.Fatalf("begin scoped subscription read: %v", err) + } + defer func() { _ = tx.Rollback(ctx) }() + var rows int + if err := tx.QueryRow(ctx, `SELECT count(*) FROM channel_subscriptions WHERE channel_id = $1`, string(channel.ID)).Scan(&rows); err != nil { + t.Fatalf("read scoped subscriptions: %v", err) + } + if err := tx.Commit(ctx); err != nil { + t.Fatalf("commit scoped subscription read: %v", err) + } + return rows + } + if got := count(ctxB); got != 0 { + t.Fatalf("tenant B read %d tenant A subscriptions, want 0", got) + } + if got := count(ctxA); got != 1 { + t.Fatalf("tenant A read %d subscriptions, want 1", got) + } +} diff --git a/go/internal/store/channels.go b/go/internal/store/channels.go index 73ab5c47b..feb8bbb99 100644 --- a/go/internal/store/channels.go +++ b/go/internal/store/channels.go @@ -82,17 +82,28 @@ func (s *Store) CreateChannelGroup(ctx context.Context, ownerUserID AccountID, g }, nil } -// CreateChannel inserts a channel and its membership. Transitive -// owner-membership (design.md:231-234) is enforced here: the actor is always a -// member, and for each agent in the requested member set that agent's owning -// user(s) are added too, so a user can always read anything their agent is -// party to (an agent↔agent DM carries both owners). The caller-supplied member -// set is augmented, never trusted as complete. A channel name already taken in -// its group is ErrConflict; an unknown group is ErrInvalidArgument. Ungrouped -// channels (empty group) are not name-constrained. -func (s *Store) CreateChannel(ctx context.Context, actor AccountID, c NewChannel) (Channel, error) { +// validateNewChannel applies the input-only refusals before any tx opens, so +// none of them can leak whether a group or agent exists. +func validateNewChannel(c NewChannel) error { if c.Name == "" { - return Channel{}, fmt.Errorf("%w: channel name is required", ErrInvalidArgument) + return fmt.Errorf("%w: channel name is required", ErrInvalidArgument) + } + if c.MembershipMode != ChannelMembershipModeExplicit && c.MembershipMode != ChannelMembershipModeTree { + return fmt.Errorf("%w: unknown channel membership mode %d", ErrInvalidArgument, c.MembershipMode) + } + if c.GroupID != "" && c.ParentAgentID != "" { + return fmt.Errorf("%w: channel cannot have both group and agent parents", ErrInvalidArgument) + } + if c.MembershipMode == ChannelMembershipModeTree && c.ParentAgentID == "" { + return fmt.Errorf("%w: tree membership requires an agent parent", ErrInvalidArgument) + } + // Mandatory delivery and OWNER_ONLY coherence are both defined over stored + // member rows, which a TREE channel never has. + if c.MembershipMode == ChannelMembershipModeTree && c.Policy.MandatorySubscription { + return fmt.Errorf("%w: tree membership cannot require subscription", ErrInvalidArgument) + } + if c.MembershipMode == ChannelMembershipModeTree && c.Policy.PostPolicy == ChannelPostPolicyOwnerOnly { + return fmt.Errorf("%w: tree membership cannot use OWNER_ONLY posting", ErrInvalidArgument) } // Coherence: OWNER_ONLY with no owner account bricks the channel — the post @@ -100,7 +111,7 @@ func (s *Store) CreateChannel(ctx context.Context, actor AccountID, c NewChannel // mirroring SetChannelPolicy's guard. (0013 comment: owner-empty is the only // legal state when OPEN.) if c.Policy.PostPolicy == ChannelPostPolicyOwnerOnly && c.Policy.OwnerAccountID == "" { - return Channel{}, fmt.Errorf("%w: OWNER_ONLY requires an owner account", ErrInvalidArgument) + return fmt.Errorf("%w: OWNER_ONLY requires an owner account", ErrInvalidArgument) } // Coherence: OPEN admits every member as an author, so an owner is @@ -108,7 +119,26 @@ func (s *Store) CreateChannel(ctx context.Context, actor AccountID, c NewChannel // slot (locking future policy changes to itself). owner-empty is the only // legal OPEN state, so reject a non-empty owner. if c.Policy.PostPolicy == ChannelPostPolicyOpen && c.Policy.OwnerAccountID != "" { - return Channel{}, fmt.Errorf("%w: OPEN channel must not name an owner account", ErrInvalidArgument) + return fmt.Errorf("%w: OPEN channel must not name an owner account", ErrInvalidArgument) + } + return nil +} + +// CreateChannel inserts a channel and its membership. Transitive +// owner-membership (design.md:231-234) is enforced here: the actor is always a +// member, and for each agent in the requested member set that agent's owning +// user(s) are added too, so a user can always read anything their agent is +// party to (an agent↔agent DM carries both owners). The caller-supplied member +// set is augmented, never trusted as complete. A channel name already taken in +// its group or under its agent is ErrConflict; an unknown group is +// ErrInvalidArgument. Ungrouped root channels are not name-constrained. +// +// A ParentAgentID attaches the channel under an agent the actor's owner set +// owns (else ErrNotFound). A TREE channel writes no member rows: its +// participants derive from the anchor's subtree, and it returns no members. +func (s *Store) CreateChannel(ctx context.Context, actor AccountID, c NewChannel) (Channel, error) { + if err := validateNewChannel(c); err != nil { + return Channel{}, err } id := newID() @@ -127,6 +157,11 @@ func (s *Store) CreateChannel(ctx context.Context, actor AccountID, c NewChannel return Channel{}, err } } + if c.ParentAgentID != "" { + if err := requireAgentAttachAuthz(ctx, tx, actor, c.ParentAgentID); err != nil { + return Channel{}, err + } + } // R3 primary defense: the manual create path may not target a reserved system group, so a // reserved channel name cannot be squatted; the merged ErrNotFound keeps it unprobeable. @@ -148,60 +183,163 @@ func (s *Store) CreateChannel(ctx context.Context, actor AccountID, c NewChannel PostPolicy: int16(c.Policy.PostPolicy), Column6: string(c.Policy.OwnerAccountID), MandatorySubscription: c.Policy.MandatorySubscription, + Column8: string(c.ParentAgentID), + MembershipMode: int16(c.MembershipMode), //nolint:gosec // G115: ChannelMembershipMode is a CHECK-constrained 0/1 enum (channels.membership_mode), always within int16 }); err != nil { if pgErrIs(err, pgUniqueViolation) { + if c.ParentAgentID != "" { + return Channel{}, fmt.Errorf("%w: channel %q already exists under agent %q", ErrConflict, c.Name, c.ParentAgentID) + } return Channel{}, fmt.Errorf("%w: channel %q already exists in group %q", ErrConflict, c.Name, c.GroupID) } if pgErrIs(err, pgForeignKeyViolation) { + if pgConstraintName(err) == "channels_parent_agent_id_fkey" { + return Channel{}, fmt.Errorf("%w: unknown agent %q", ErrNotFound, c.ParentAgentID) + } return Channel{}, fmt.Errorf("%w: unknown group %q", ErrInvalidArgument, c.GroupID) } return Channel{}, fmt.Errorf("store: insert channel: %w", err) } + // TREE discards this expansion; the plan keeps it running on both modes until + // the create return becomes a post-commit re-read. members, err := expandOwnerMembership(ctx, tx, actor, c.MemberAccountIDs) if err != nil { return Channel{}, err } + if c.MembershipMode == ChannelMembershipModeExplicit { + if err := s.writeExplicitMembers(ctx, tx, ChannelID(id), c.Policy, members); err != nil { + return Channel{}, err + } + } else { + // No member rows exist, so report none: a later ListChannels reads the same. + members = nil + } + if err := tx.Commit(ctx); err != nil { + return Channel{}, fmt.Errorf("store: commit create channel: %w", err) + } + + return Channel{ + ID: ChannelID(id), + Name: c.Name, + GroupID: c.GroupID, + Kind: c.Kind, + MemberAccountIDs: members, + Policy: c.Policy, + }, nil +} + +// writeExplicitMembers stores an EXPLICIT channel's member rows inside the +// create tx and seeds delivery cursors when it is born mandatory. +func (s *Store) writeExplicitMembers(ctx context.Context, tx pgx.Tx, id ChannelID, policy ChannelPolicy, members []AccountID) error { // Coherence facet 1: an OWNER_ONLY channel's owner must be a member — the // post gate demands author be BOTH member AND owner, so a non-member owner // makes the channel unpostable from birth. `members` is the authoritative // final set, so check the resolved owner against it before the insert. - if c.Policy.OwnerAccountID != "" && !slices.Contains(members, c.Policy.OwnerAccountID) { - return Channel{}, fmt.Errorf("%w: owner account %q must be a channel member", ErrInvalidArgument, c.Policy.OwnerAccountID) + if policy.OwnerAccountID != "" && !slices.Contains(members, policy.OwnerAccountID) { + return fmt.Errorf("%w: owner account %q must be a channel member", ErrInvalidArgument, policy.OwnerAccountID) } qtx := s.q.WithTx(tx) for _, m := range members { if err := qtx.EnsureChannelMember(ctx, db.EnsureChannelMemberParams{ - ChannelID: id, + ChannelID: string(id), AccountID: string(m), }); err != nil { if pgErrIs(err, pgForeignKeyViolation) { - return Channel{}, fmt.Errorf("%w: unknown member account %q", ErrInvalidArgument, m) + return fmt.Errorf("%w: unknown member account %q", ErrInvalidArgument, m) } - return Channel{}, fmt.Errorf("store: insert channel member: %w", err) + return fmt.Errorf("store: insert channel member: %w", err) } } // Born mandatory ⇒ every member is a delivery target (D1 disjunct, // regardless of subscribed), so seed each agent member's cursor in this tx // — an un-seeded target is the fail-DANGEROUS D2 hazard. Self-guarding // (agent-only) and idempotent; non-mandatory channels seed at subscribe. - if c.Policy.MandatorySubscription { - if err := seedChannelDeliveryCursors(ctx, tx, ChannelID(id)); err != nil { - return Channel{}, err + if policy.MandatorySubscription { + return seedChannelDeliveryCursors(ctx, tx, id) + } + return nil +} + +// ReparentChannel moves an ungrouped CHANNEL within its participant owner set. +// It gates participant and destination ownership before checking shape refusals. +func (s *Store) ReparentChannel(ctx context.Context, actor AccountID, channelID ChannelID, newParentAgentID AccountID) (Channel, error) { + if actor == "" { + return Channel{}, fmt.Errorf("%w: actor is required", ErrInvalidArgument) + } + if channelID == "" { + return Channel{}, fmt.Errorf("%w: channel id is required", ErrInvalidArgument) + } + + tx, err := s.beginTenantTx(ctx) + if err != nil { + return Channel{}, fmt.Errorf("store: begin reparent channel: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + qtx := s.q.WithTx(tx) + + participant, err := qtx.ChannelParticipant(ctx, db.ChannelParticipantParams{ + ChannelID: string(channelID), + AccountID: string(actor), + }) + if err != nil { + return Channel{}, fmt.Errorf("store: check channel participation: %w", err) + } + if !participant.Valid || !participant.Bool { + return Channel{}, fmt.Errorf("%w: channel %q", ErrNotFound, channelID) + } + + if newParentAgentID != "" { + actorOwner, err := qtx.ResolveOwner(ctx, string(actor)) + if err != nil { + return Channel{}, fmt.Errorf("store: resolve actor owner: %w", err) + } + destinationOwner, err := qtx.GetAgentOwner(ctx, string(newParentAgentID)) + if err != nil { + if noRows(err) { + return Channel{}, fmt.Errorf("%w: agent %q", ErrNotFound, newParentAgentID) + } + return Channel{}, fmt.Errorf("store: resolve destination agent owner: %w", err) + } + if actorOwner != destinationOwner { + return Channel{}, fmt.Errorf("%w: agent %q", ErrNotFound, newParentAgentID) } } - if err := tx.Commit(ctx); err != nil { - return Channel{}, fmt.Errorf("store: commit create channel: %w", err) + + row, err := qtx.LockChannelForReparent(ctx, string(channelID)) + if err != nil { + if noRows(err) { + return Channel{}, fmt.Errorf("%w: channel %q", ErrNotFound, channelID) + } + return Channel{}, fmt.Errorf("store: lock channel for reparent: %w", err) + } + if row.GroupID.Valid { + return Channel{}, fmt.Errorf("%w: grouped channel cannot be attached", ErrInvalidArgument) + } + if ChannelKind(row.Kind) != ChannelKindChannel { + return Channel{}, fmt.Errorf("%w: only channels can be attached", ErrInvalidArgument) + } + if row.IsHome { + return Channel{}, fmt.Errorf("%w: home channels cannot be attached", ErrInvalidArgument) + } + if ChannelMembershipMode(row.MembershipMode) == ChannelMembershipModeTree && newParentAgentID == "" { + return Channel{}, fmt.Errorf("%w: tree membership requires an agent parent", ErrInvalidArgument) } - return Channel{ - ID: ChannelID(id), - Name: c.Name, - GroupID: c.GroupID, - Kind: c.Kind, - MemberAccountIDs: members, - Policy: c.Policy, - }, nil + // Channels are leaves, so moving a channel cannot create a tree cycle. + if err := qtx.UpdateChannelParent(ctx, db.UpdateChannelParentParams{ + ID: string(channelID), + Column2: string(newParentAgentID), + }); err != nil { + if pgErrIs(err, pgUniqueViolation) && pgConstraintName(err) == "channels_agent_name_key" { + return Channel{}, fmt.Errorf("%w: channel already exists under agent %q", ErrConflict, newParentAgentID) + } + return Channel{}, fmt.Errorf("store: update channel parent: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return Channel{}, fmt.Errorf("store: commit reparent channel: %w", err) + } + return s.getChannel(ctx, channelID) } // expandOwnerMembership computes the final member set for a new channel: the diff --git a/go/internal/store/db/authz.sql.go b/go/internal/store/db/authz.sql.go index 10e111e53..d6fa4892e 100644 --- a/go/internal/store/db/authz.sql.go +++ b/go/internal/store/db/authz.sql.go @@ -9,6 +9,31 @@ import ( "context" ) +const agentAttachAuthorized = `-- name: AgentAttachAuthorized :one +SELECT EXISTS ( + SELECT 1 FROM agent_accounts target + WHERE target.account_id = $1 AND ( + target.owner_user_id = $2 + OR target.owner_user_id = ( + SELECT owner_user_id FROM agent_accounts WHERE account_id = $2 + ) + ) +) +` + +type AgentAttachAuthorizedParams struct { + AccountID string + OwnerUserID string +} + +// Feeds requireAgentAttachAuthz: the agent's owner, or an agent of that owner. +func (q *Queries) AgentAttachAuthorized(ctx context.Context, arg AgentAttachAuthorizedParams) (bool, error) { + row := q.db.QueryRow(ctx, agentAttachAuthorized, arg.AccountID, arg.OwnerUserID) + var exists bool + err := row.Scan(&exists) + return exists, err +} + const agentWorkspaceVisible = `-- name: AgentWorkspaceVisible :one SELECT EXISTS ( SELECT 1 FROM agent_accounts ag diff --git a/go/internal/store/db/channels.sql.go b/go/internal/store/db/channels.sql.go index e208f0428..10cad6ead 100644 --- a/go/internal/store/db/channels.sql.go +++ b/go/internal/store/db/channels.sql.go @@ -7,6 +7,8 @@ package db import ( "context" + + "github.com/jackc/pgx/v5/pgtype" ) const agentOwnersByIDs = `-- name: AgentOwnersByIDs :many @@ -120,6 +122,45 @@ func (q *Queries) ChannelMembersByChannelIDs(ctx context.Context, dollar_1 []str return items, nil } +const channelParticipant = `-- name: ChannelParticipant :one +WITH RECURSIVE chain AS ( + SELECT aa.account_id, aa.parent_agent_id + FROM agent_accounts aa + WHERE aa.account_id = $2 + AND EXISTS (SELECT 1 FROM channels WHERE id = $1 AND membership_mode = 1) + UNION + SELECT a.account_id, a.parent_agent_id + FROM agent_accounts a + JOIN chain ch ON a.account_id = ch.parent_agent_id +) +SELECT EXISTS ( + SELECT 1 FROM channel_members cm + WHERE cm.channel_id = $1 AND cm.account_id = $2 +) OR ( + EXISTS (SELECT 1 FROM channels WHERE id = $1 AND membership_mode = 1) + AND EXISTS ( + SELECT 1 FROM channels c + WHERE c.id = $1 AND ( + c.parent_agent_id IN (SELECT ch.account_id FROM chain ch) + OR $2 = (SELECT aa.owner_user_id FROM agent_accounts aa + WHERE aa.account_id = c.parent_agent_id) + ) + ) +) +` + +type ChannelParticipantParams struct { + ChannelID string + AccountID string +} + +func (q *Queries) ChannelParticipant(ctx context.Context, arg ChannelParticipantParams) (pgtype.Bool, error) { + row := q.db.QueryRow(ctx, channelParticipant, arg.ChannelID, arg.AccountID) + var column_1 pgtype.Bool + err := row.Scan(&column_1) + return column_1, err +} + const channelVisibleTo = `-- name: ChannelVisibleTo :one WITH RECURSIVE ancestry AS ( SELECT id, parent_group_id, visibility AS min_vis @@ -351,8 +392,8 @@ func (q *Queries) InsertAgentWorkspaceIgnore(ctx context.Context, arg InsertAgen } const insertChannel = `-- name: InsertChannel :exec -INSERT INTO channels (id, name, group_id, kind, post_policy, owner_account_id, mandatory_subscription) -VALUES ($1, $2, NULLIF($3, ''), $4, $5, NULLIF($6, ''), $7) +INSERT INTO channels (id, name, group_id, kind, post_policy, owner_account_id, mandatory_subscription, parent_agent_id, membership_mode) +VALUES ($1, $2, NULLIF($3, ''), $4, $5, NULLIF($6, ''), $7, NULLIF($8, ''), $9) ` type InsertChannelParams struct { @@ -363,6 +404,8 @@ type InsertChannelParams struct { PostPolicy int16 Column6 interface{} MandatorySubscription bool + Column8 interface{} + MembershipMode int16 } func (q *Queries) InsertChannel(ctx context.Context, arg InsertChannelParams) error { @@ -374,6 +417,8 @@ func (q *Queries) InsertChannel(ctx context.Context, arg InsertChannelParams) er arg.PostPolicy, arg.Column6, arg.MandatorySubscription, + arg.Column8, + arg.MembershipMode, ) return err } @@ -544,6 +589,31 @@ func (q *Queries) ListChannels(ctx context.Context, accountID string) ([]ListCha return items, nil } +const lockChannelForReparent = `-- name: LockChannelForReparent :one +SELECT channels.group_id, channels.kind, channels.membership_mode, + EXISTS (SELECT 1 FROM agent_accounts WHERE home_channel_id = channels.id) AS is_home +FROM channels WHERE channels.id = $1 FOR UPDATE OF channels +` + +type LockChannelForReparentRow struct { + GroupID pgtype.Text + Kind int16 + MembershipMode int16 + IsHome bool +} + +func (q *Queries) LockChannelForReparent(ctx context.Context, id string) (LockChannelForReparentRow, error) { + row := q.db.QueryRow(ctx, lockChannelForReparent, id) + var i LockChannelForReparentRow + err := row.Scan( + &i.GroupID, + &i.Kind, + &i.MembershipMode, + &i.IsHome, + ) + return i, err +} + const lockChannelMandatoryKind = `-- name: LockChannelMandatoryKind :one SELECT mandatory_subscription, kind FROM channels WHERE id = $1 FOR UPDATE ` @@ -607,6 +677,20 @@ func (q *Queries) SubscribeConvertedDMParties(ctx context.Context, channelID str return err } +const updateChannelParent = `-- name: UpdateChannelParent :exec +UPDATE channels SET parent_agent_id = NULLIF($2, '') WHERE id = $1 +` + +type UpdateChannelParentParams struct { + ID string + Column2 interface{} +} + +func (q *Queries) UpdateChannelParent(ctx context.Context, arg UpdateChannelParentParams) error { + _, err := q.db.Exec(ctx, updateChannelParent, arg.ID, arg.Column2) + return err +} + const updateChannelPolicy = `-- name: UpdateChannelPolicy :exec UPDATE channels SET post_policy = $2, owner_account_id = NULLIF($3, ''), mandatory_subscription = $4 WHERE id = $1 ` diff --git a/go/internal/store/db/models.go b/go/internal/store/db/models.go index 354167574..d5ed72205 100644 --- a/go/internal/store/db/models.go +++ b/go/internal/store/db/models.go @@ -133,6 +133,8 @@ type Channel struct { OwnerAccountID pgtype.Text MandatorySubscription bool TenantID string + ParentAgentID pgtype.Text + MembershipMode int16 } type ChannelGroup struct { @@ -160,6 +162,13 @@ type ChannelPin struct { TenantID string } +type ChannelSubscription struct { + ChannelID string + AccountID string + Subscribed bool + TenantID string +} + type ComputeUsageEvent struct { TenantID string ID string diff --git a/go/internal/store/db/querier.go b/go/internal/store/db/querier.go index 82e92f753..bb9ae63f8 100644 --- a/go/internal/store/db/querier.go +++ b/go/internal/store/db/querier.go @@ -29,6 +29,8 @@ type Querier interface { // AdvanceTokenUsagePruneHorizon commits before the prune deletes anything, and // waits for a rebuild that holds the old horizon. It only moves forward. AdvanceTokenUsagePruneHorizon(ctx context.Context, cutoff pgtype.Timestamptz) (int64, error) + // Feeds requireAgentAttachAuthz: the agent's owner, or an agent of that owner. + AgentAttachAuthorized(ctx context.Context, arg AgentAttachAuthorizedParams) (bool, error) AgentForContainer(ctx context.Context, containerName string) (string, error) // Presence-component read queries (sqlc adoption T4, RIG-3034). These replace the // const-hoisted SQL in internal/store/presence_reads.go (it was never in the @@ -78,6 +80,7 @@ type Querier interface { ChannelMemberExists(ctx context.Context, arg ChannelMemberExistsParams) (bool, error) ChannelMemberIDs(ctx context.Context, channelID string) ([]string, error) ChannelMembersByChannelIDs(ctx context.Context, dollar_1 []string) ([]ChannelMembersByChannelIDsRow, error) + ChannelParticipant(ctx context.Context, arg ChannelParticipantParams) (pgtype.Bool, error) ChannelVisibleTo(ctx context.Context, arg ChannelVisibleToParams) (bool, error) ChannelsByNameForViewer(ctx context.Context, arg ChannelsByNameForViewerParams) ([]ChannelsByNameForViewerRow, error) // The unique key makes concurrent first-run attempts a single-winner claim. @@ -375,6 +378,7 @@ type Querier interface { // hand-written Store methods and the in-tx FOR UPDATE lock / cap-check control // flow stay exactly as they were and wrap these generated calls. LockChannelForPins(ctx context.Context, id string) (LockChannelForPinsRow, error) + LockChannelForReparent(ctx context.Context, id string) (LockChannelForReparentRow, error) LockChannelMandatoryKind(ctx context.Context, id string) (LockChannelMandatoryKindRow, error) LockChannelPolicy(ctx context.Context, id string) (LockChannelPolicyRow, error) LockLinearRouting(ctx context.Context, dollar_1 pgtype.Text) error @@ -615,6 +619,7 @@ type Querier interface { UndeliveredMessages(ctx context.Context, accountID string) ([]UndeliveredMessagesRow, error) UnroutedMentionMessages(ctx context.Context, arg UnroutedMentionMessagesParams) ([]UnroutedMentionMessagesRow, error) UpdateAgentParent(ctx context.Context, arg UpdateAgentParentParams) error + UpdateChannelParent(ctx context.Context, arg UpdateChannelParentParams) error UpdateChannelPolicy(ctx context.Context, arg UpdateChannelPolicyParams) error UpdateMessageBlocks(ctx context.Context, arg UpdateMessageBlocksParams) (int64, error) UpdateMessageBlocksAsAuthor(ctx context.Context, arg UpdateMessageBlocksAsAuthorParams) (UpdateMessageBlocksAsAuthorRow, error) diff --git a/go/internal/store/inputs.go b/go/internal/store/inputs.go index 99880d966..e9e3a4c45 100644 --- a/go/internal/store/inputs.go +++ b/go/internal/store/inputs.go @@ -45,13 +45,15 @@ type NewChannelGroup struct { } // Channel input for CreateChannel. The store enforces transitive -// owner-membership (an agent's DMs and any channel it starts always include its -// owning user(s), design.md:231-234), so the caller-supplied member set is -// augmented, never trusted as complete. +// owner-membership on explicit channels; tree membership is derived from the anchor. type NewChannel struct { Name string GroupID ChannelGroupID Kind ChannelKind + // ParentAgentID attaches the channel under an agent; empty means root. + ParentAgentID AccountID + // MembershipMode selects explicit or tree-derived channel membership. + MembershipMode ChannelMembershipMode // MemberAccountIDs are the accounts to seed the channel with; the store adds // the required owner rows. MemberAccountIDs []AccountID diff --git a/go/internal/store/migrations/0009_channel_tree.sql b/go/internal/store/migrations/0009_channel_tree.sql new file mode 100644 index 000000000..a383212aa --- /dev/null +++ b/go/internal/store/migrations/0009_channel_tree.sql @@ -0,0 +1,43 @@ +-- Agent-attached channels: the anchor edge, the membership mode, and per-account +-- subscription overrides for tree-membered channels. + +SET LOCAL lock_timeout = '5s'; + +-- channels is small and the new column is all NULL, so each validating scan is +-- brief and lock_timeout bounds the wait; NOT VALID would only defer that scan. +ALTER TABLE channels + ADD COLUMN parent_agent_id TEXT + -- squawk-ignore adding-foreign-key-constraint + REFERENCES agent_accounts (account_id) ON DELETE RESTRICT, + ADD COLUMN membership_mode SMALLINT NOT NULL DEFAULT 0 + CHECK (membership_mode IN (0, 1)), + -- squawk-ignore constraint-missing-not-valid + ADD CONSTRAINT channels_group_xor_agent + CHECK (group_id IS NULL OR parent_agent_id IS NULL), + -- squawk-ignore constraint-missing-not-valid + ADD CONSTRAINT channels_tree_mode_needs_agent + CHECK (membership_mode = 0 OR parent_agent_id IS NOT NULL); +CREATE INDEX channels_parent_agent_idx ON channels (parent_agent_id); +CREATE UNIQUE INDEX channels_agent_name_key + ON channels (parent_agent_id, name) WHERE parent_agent_id IS NOT NULL; +CREATE TABLE channel_subscriptions ( + channel_id TEXT NOT NULL REFERENCES channels (id) ON DELETE RESTRICT, + account_id TEXT NOT NULL REFERENCES accounts (id) ON DELETE RESTRICT, + subscribed BOOLEAN NOT NULL DEFAULT FALSE, + tenant_id TEXT NOT NULL + DEFAULT current_setting('compass.tenant_id', TRUE), + PRIMARY KEY (channel_id, account_id) +); +-- The account-first index serves delivery lookups keyed by account. +CREATE INDEX channel_subscriptions_account_idx + ON channel_subscriptions (account_id); +-- New tables do not inherit the bootstrap migration's RLS or grants. +ALTER TABLE channel_subscriptions ENABLE ROW LEVEL SECURITY; +ALTER TABLE channel_subscriptions FORCE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation ON channel_subscriptions + USING ((SELECT current_setting('compass.tenant_id', TRUE)) <> '' + AND tenant_id = (SELECT current_setting('compass.tenant_id', TRUE))) + WITH CHECK ((SELECT current_setting('compass.tenant_id', TRUE)) <> '' + AND tenant_id = (SELECT current_setting('compass.tenant_id', TRUE))); +GRANT SELECT, INSERT, UPDATE, DELETE ON channel_subscriptions + TO compass_app, compass_system; diff --git a/go/internal/store/queries/authz.sql b/go/internal/store/queries/authz.sql index d2f4d101c..7ee3509fb 100644 --- a/go/internal/store/queries/authz.sql +++ b/go/internal/store/queries/authz.sql @@ -28,6 +28,18 @@ SELECT EXISTS ( OR g.visibility = $3 OR g.owner_user_id = (SELECT owner_user_id FROM agent_accounts WHERE account_id = $2))); +-- name: AgentAttachAuthorized :one +-- Feeds requireAgentAttachAuthz: the agent's owner, or an agent of that owner. +SELECT EXISTS ( + SELECT 1 FROM agent_accounts target + WHERE target.account_id = $1 AND ( + target.owner_user_id = $2 + OR target.owner_user_id = ( + SELECT owner_user_id FROM agent_accounts WHERE account_id = $2 + ) + ) +); + -- name: AgentWorkspaceVisible :one -- Feeds isAgentWorkspaceVisible: membership on the agent's home channel. SELECT EXISTS ( diff --git a/go/internal/store/queries/channels.sql b/go/internal/store/queries/channels.sql index 971ef9da1..0e9c351ff 100644 --- a/go/internal/store/queries/channels.sql +++ b/go/internal/store/queries/channels.sql @@ -19,8 +19,42 @@ VALUES ($1, $2, NULLIF($3, ''), $4, $5); SELECT visibility FROM channel_groups WHERE id = $1; -- name: InsertChannel :exec -INSERT INTO channels (id, name, group_id, kind, post_policy, owner_account_id, mandatory_subscription) -VALUES ($1, $2, NULLIF($3, ''), $4, $5, NULLIF($6, ''), $7); +INSERT INTO channels (id, name, group_id, kind, post_policy, owner_account_id, mandatory_subscription, parent_agent_id, membership_mode) +VALUES ($1, $2, NULLIF($3, ''), $4, $5, NULLIF($6, ''), $7, NULLIF($8, ''), $9); + +-- name: ChannelParticipant :one +WITH RECURSIVE chain AS ( + SELECT aa.account_id, aa.parent_agent_id + FROM agent_accounts aa + WHERE aa.account_id = $2 + AND EXISTS (SELECT 1 FROM channels WHERE id = $1 AND membership_mode = 1) + UNION + SELECT a.account_id, a.parent_agent_id + FROM agent_accounts a + JOIN chain ch ON a.account_id = ch.parent_agent_id +) +SELECT EXISTS ( + SELECT 1 FROM channel_members cm + WHERE cm.channel_id = $1 AND cm.account_id = $2 +) OR ( + EXISTS (SELECT 1 FROM channels WHERE id = $1 AND membership_mode = 1) + AND EXISTS ( + SELECT 1 FROM channels c + WHERE c.id = $1 AND ( + c.parent_agent_id IN (SELECT ch.account_id FROM chain ch) + OR $2 = (SELECT aa.owner_user_id FROM agent_accounts aa + WHERE aa.account_id = c.parent_agent_id) + ) + ) +); + +-- name: LockChannelForReparent :one +SELECT channels.group_id, channels.kind, channels.membership_mode, + EXISTS (SELECT 1 FROM agent_accounts WHERE home_channel_id = channels.id) AS is_home +FROM channels WHERE channels.id = $1 FOR UPDATE OF channels; + +-- name: UpdateChannelParent :exec +UPDATE channels SET parent_agent_id = NULLIF($2, '') WHERE id = $1; -- name: UpsertChannelMember :exec INSERT INTO channel_members (channel_id, account_id, subscribed) diff --git a/go/internal/store/rls_pgtest_test.go b/go/internal/store/rls_pgtest_test.go index 154795750..1a86a68fb 100644 --- a/go/internal/store/rls_pgtest_test.go +++ b/go/internal/store/rls_pgtest_test.go @@ -596,7 +596,7 @@ func TestRLSCatalogEnabledAndForced(t *testing.T) { tenantOwned := []string{ "accounts", "user_accounts", "agent_accounts", "system_accounts", "account_handles", - "channel_groups", "channels", "channel_members", "agent_workspaces", + "channel_groups", "channels", "channel_members", "channel_subscriptions", "agent_workspaces", "topics", "messages", "channel_pins", "secrets", "agent_sessions", "agent_placements", "session_bindings", "agent_session_transcript_entries", "agent_session_archive_segments", diff --git a/go/internal/store/types.go b/go/internal/store/types.go index c73eb6643..623b0f870 100644 --- a/go/internal/store/types.go +++ b/go/internal/store/types.go @@ -252,6 +252,16 @@ const ( ChannelPostPolicyOwnerOnly ChannelPostPolicy = 1 ) +// ChannelMembershipMode selects stored membership or agent-tree-derived membership. +type ChannelMembershipMode int32 + +const ( + // ChannelMembershipModeExplicit preserves the stored channel member list. + ChannelMembershipModeExplicit ChannelMembershipMode = 0 + // ChannelMembershipModeTree derives participants from the anchored agent subtree. + ChannelMembershipModeTree ChannelMembershipMode = 1 +) + // AgentWorkspace is an agent's observation pane (comms.proto:213-221, narrowed // by superseded decision 4): it renders the live execution trace and the // terminal/file panes, never persisted messages. participant_user_ids and the From 3134f03b25f708891f6662c5232dd31899392419 Mon Sep 17 00:00:00 2001 From: mintaka Date: Mon, 5 Oct 2026 23:35:57 -0400 Subject: [PATCH 2/4] fix(store): lock before the reparent authz; refuse non-channel attach (RIG-4186) ReparentChannel now takes the channel row lock before the participant and destination checks, so a concurrent member removal is seen. Create refuses an agent anchor on a DM kind and names the owner-account FK. Co-authored-by: Matt Wilkinson --- go/internal/store/channel_tree_pgtest_test.go | 82 +++++++++++++++++++ go/internal/store/channels.go | 52 +++++++----- go/internal/store/db/channels.sql.go | 3 + go/internal/store/db/querier.go | 3 + go/internal/store/queries/channels.sql | 3 + 5 files changed, 123 insertions(+), 20 deletions(-) diff --git a/go/internal/store/channel_tree_pgtest_test.go b/go/internal/store/channel_tree_pgtest_test.go index eea6230c0..c7d059043 100644 --- a/go/internal/store/channel_tree_pgtest_test.go +++ b/go/internal/store/channel_tree_pgtest_test.go @@ -4,7 +4,10 @@ package store import ( "context" + "errors" + "strings" "testing" + "time" ) func mustAttachedChannel(t *testing.T, s *Store, actor, parent AccountID, name string, mode ChannelMembershipMode, members ...AccountID) Channel { @@ -74,6 +77,14 @@ func TestChannelTreeCreateAuthorizationAndInputRefusals(t *testing.T) { sentinelIs(t, err, ErrNotFound, "cross-owner channel attach") _, err = s.CreateChannel(t.Context(), owner.ID, NewChannel{Name: "unknown-agent", ParentAgentID: AccountID("missing-agent")}) sentinelIs(t, err, ErrNotFound, "unknown agent attach") + _, err = s.CreateChannel(t.Context(), agent.ID, NewChannel{Name: "agent-cross-owner", ParentAgentID: foreign.ID}) + sentinelIs(t, err, ErrNotFound, "cross-owner attach by an agent") + _, err = s.CreateChannel(t.Context(), owner.ID, NewChannel{ + Name: "unknown-owner", Policy: ChannelPolicy{PostPolicy: ChannelPostPolicyOwnerOnly, OwnerAccountID: AccountID("missing-owner")}, + }) + if !errors.Is(err, ErrInvalidArgument) || !strings.Contains(err.Error(), "unknown owner account") { + t.Fatalf("unknown owner account create = %v, want InvalidArgument naming the owner", err) + } ownerOnly := ChannelPolicy{PostPolicy: ChannelPostPolicyOwnerOnly, OwnerAccountID: owner.ID} cases := []struct { @@ -85,6 +96,8 @@ func TestChannelTreeCreateAuthorizationAndInputRefusals(t *testing.T) { {name: "tree mandatory", input: NewChannel{Name: "mandatory", ParentAgentID: agent.ID, MembershipMode: ChannelMembershipModeTree, Policy: ChannelPolicy{MandatorySubscription: true}}}, {name: "tree owner only", input: NewChannel{Name: "owner-only", ParentAgentID: agent.ID, MembershipMode: ChannelMembershipModeTree, Policy: ownerOnly}}, {name: "unknown membership mode", input: NewChannel{Name: "bad-mode", ParentAgentID: agent.ID, MembershipMode: ChannelMembershipMode(7)}}, + {name: "explicit DM under agent", input: NewChannel{Name: "dm-explicit", Kind: ChannelKindDM, ParentAgentID: agent.ID}}, + {name: "tree DM under agent", input: NewChannel{Name: "dm-tree", Kind: ChannelKindDM, ParentAgentID: agent.ID, MembershipMode: ChannelMembershipModeTree}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -112,6 +125,7 @@ func TestReparentChannelShapeRefusals(t *testing.T) { owner := mustUser(t, s, "shape-owner") outsider := mustUser(t, s, "shape-outsider") anchor := mustAgent(t, s, owner.ID, "shape-anchor") + foreign := mustAgent(t, s, outsider.ID, "shape-foreign") group, err := s.CreateChannelGroup(t.Context(), owner.ID, NewChannelGroup{Name: "shape-group"}) if err != nil { t.Fatalf("CreateChannelGroup: %v", err) @@ -141,6 +155,8 @@ func TestReparentChannelShapeRefusals(t *testing.T) { {name: "non-participant DM", actor: outsider.ID, channelID: dm.ID, want: ErrNotFound}, {name: "non-participant home channel", actor: outsider.ID, channelID: anchor.Agent.HomeChannelID, want: ErrNotFound}, {name: "non-participant tree channel", actor: outsider.ID, channelID: tree.ID, want: ErrNotFound}, + {name: "foreign destination on grouped channel", actor: owner.ID, channelID: grouped.ID, parent: foreign.ID, want: ErrNotFound}, + {name: "unknown destination on home channel", actor: owner.ID, channelID: anchor.Agent.HomeChannelID, parent: AccountID("missing-agent"), want: ErrNotFound}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -348,3 +364,69 @@ func TestChannelTreeSubscriptionsTenantIsolation(t *testing.T) { t.Fatalf("tenant A read %d subscriptions, want 1", got) } } + +// A member removed by a concurrent writer that holds the channel row lock must +// not reparent the channel once that writer commits: the participant probe has +// to run after the lock, against committed membership. +func TestReparentChannelWaitsForConcurrentMemberRemoval(t *testing.T) { + ctx := context.Background() // test root + s := newTestStore(t) + owner := mustUser(t, s, "race-owner") + leaver := mustUser(t, s, "race-leaver") + anchor := mustAgent(t, s, leaver.ID, "race-anchor") + ch, err := s.CreateChannel(ctx, owner.ID, NewChannel{Name: "race-room", MemberAccountIDs: []AccountID{leaver.ID}}) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + + txB, err := s.pool.Begin(ctx) + if err != nil { + t.Fatalf("begin tx B: %v", err) + } + defer func() { _ = txB.Rollback(ctx) }() + var bpid int + if err := txB.QueryRow(ctx, "SELECT pg_backend_pid()").Scan(&bpid); err != nil { + t.Fatalf("read tx B backend pid: %v", err) + } + if _, err := txB.Exec(ctx, "SELECT 1 FROM channels WHERE id = $1 FOR UPDATE", string(ch.ID)); err != nil { + t.Fatalf("tx B lock channel: %v", err) + } + if _, err := txB.Exec(ctx, "DELETE FROM channel_members WHERE channel_id = $1 AND account_id = $2", string(ch.ID), string(leaver.ID)); err != nil { + t.Fatalf("tx B remove member: %v", err) + } + + done := make(chan error, 1) + go func() { + _, err := s.ReparentChannel(ctx, leaver.ID, ch.ID, anchor.ID) + done <- err + }() + deadline := time.After(10 * time.Second) + tick := time.NewTicker(5 * time.Millisecond) + defer tick.Stop() + var reparentErr error + finished := false +gate: + for { + if backendsBlockedBy(t, s, bpid) >= 1 { + break gate + } + select { + case reparentErr = <-done: + finished = true + break gate + case <-deadline: + t.Fatalf("ReparentChannel neither blocked on tx B nor returned") + case <-tick.C: + } + } + if err := txB.Commit(ctx); err != nil { + t.Fatalf("commit tx B: %v", err) + } + if !finished { + reparentErr = <-done + } + sentinelIs(t, reparentErr, ErrNotFound, "reparent by a member removed concurrently") + if parent, _, _ := channelTreeState(t, s, ch.ID); parent != "" { + t.Fatalf("channel parent = %q after a refused reparent, want root", parent) + } +} diff --git a/go/internal/store/channels.go b/go/internal/store/channels.go index feb8bbb99..42785c75f 100644 --- a/go/internal/store/channels.go +++ b/go/internal/store/channels.go @@ -94,6 +94,10 @@ func validateNewChannel(c NewChannel) error { if c.GroupID != "" && c.ParentAgentID != "" { return fmt.Errorf("%w: channel cannot have both group and agent parents", ErrInvalidArgument) } + // DM code assumes a DM never hangs under an agent; ReparentChannel refuses the same. + if c.ParentAgentID != "" && c.Kind != ChannelKindChannel { + return fmt.Errorf("%w: only channels can be attached to an agent", ErrInvalidArgument) + } if c.MembershipMode == ChannelMembershipModeTree && c.ParentAgentID == "" { return fmt.Errorf("%w: tree membership requires an agent parent", ErrInvalidArgument) } @@ -124,18 +128,19 @@ func validateNewChannel(c NewChannel) error { return nil } -// CreateChannel inserts a channel and its membership. Transitive -// owner-membership (design.md:231-234) is enforced here: the actor is always a -// member, and for each agent in the requested member set that agent's owning -// user(s) are added too, so a user can always read anything their agent is -// party to (an agent↔agent DM carries both owners). The caller-supplied member -// set is augmented, never trusted as complete. A channel name already taken in -// its group or under its agent is ErrConflict; an unknown group is +// CreateChannel inserts a channel and its membership. For an EXPLICIT channel, +// transitive owner-membership (design.md:231-234) is enforced here: the actor is +// always a member, and for each agent in the requested member set that agent's +// owning user(s) are added too, so a user can always read anything their agent +// is party to (an agent↔agent DM carries both owners). The caller-supplied +// member set is augmented, never trusted as complete. A channel name already +// taken in its group or under its agent is ErrConflict; an unknown group is // ErrInvalidArgument. Ungrouped root channels are not name-constrained. // // A ParentAgentID attaches the channel under an agent the actor's owner set // owns (else ErrNotFound). A TREE channel writes no member rows: its -// participants derive from the anchor's subtree, and it returns no members. +// participants derive from the anchor's subtree, which need not include the +// actor, and it returns no members until reads derive them. func (s *Store) CreateChannel(ctx context.Context, actor AccountID, c NewChannel) (Channel, error) { if err := validateNewChannel(c); err != nil { return Channel{}, err @@ -193,16 +198,20 @@ func (s *Store) CreateChannel(ctx context.Context, actor AccountID, c NewChannel return Channel{}, fmt.Errorf("%w: channel %q already exists in group %q", ErrConflict, c.Name, c.GroupID) } if pgErrIs(err, pgForeignKeyViolation) { - if pgConstraintName(err) == "channels_parent_agent_id_fkey" { + switch pgConstraintName(err) { + case "channels_parent_agent_id_fkey": return Channel{}, fmt.Errorf("%w: unknown agent %q", ErrNotFound, c.ParentAgentID) + case "channels_owner_account_id_fkey": + return Channel{}, fmt.Errorf("%w: unknown owner account %q", ErrInvalidArgument, c.Policy.OwnerAccountID) + case "channels_group_id_fkey": + return Channel{}, fmt.Errorf("%w: unknown group %q", ErrInvalidArgument, c.GroupID) } - return Channel{}, fmt.Errorf("%w: unknown group %q", ErrInvalidArgument, c.GroupID) } return Channel{}, fmt.Errorf("store: insert channel: %w", err) } - // TREE discards this expansion; the plan keeps it running on both modes until - // the create return becomes a post-commit re-read. + // TREE discards this expansion; it still runs so both modes resolve the + // requested accounts the same way. members, err := expandOwnerMembership(ctx, tx, actor, c.MemberAccountIDs) if err != nil { return Channel{}, err @@ -262,7 +271,9 @@ func (s *Store) writeExplicitMembers(ctx context.Context, tx pgx.Tx, id ChannelI } // ReparentChannel moves an ungrouped CHANNEL within its participant owner set. -// It gates participant and destination ownership before checking shape refusals. +// The row lock comes first so the authz reads see committed membership and +// anchor; both gates then run before any shape refusal, so an InvalidArgument +// never tells a non-participant that the channel exists. func (s *Store) ReparentChannel(ctx context.Context, actor AccountID, channelID ChannelID, newParentAgentID AccountID) (Channel, error) { if actor == "" { return Channel{}, fmt.Errorf("%w: actor is required", ErrInvalidArgument) @@ -278,6 +289,14 @@ func (s *Store) ReparentChannel(ctx context.Context, actor AccountID, channelID defer func() { _ = tx.Rollback(ctx) }() qtx := s.q.WithTx(tx) + row, err := qtx.LockChannelForReparent(ctx, string(channelID)) + if err != nil { + if noRows(err) { + return Channel{}, fmt.Errorf("%w: channel %q", ErrNotFound, channelID) + } + return Channel{}, fmt.Errorf("store: lock channel for reparent: %w", err) + } + participant, err := qtx.ChannelParticipant(ctx, db.ChannelParticipantParams{ ChannelID: string(channelID), AccountID: string(actor), @@ -306,13 +325,6 @@ func (s *Store) ReparentChannel(ctx context.Context, actor AccountID, channelID } } - row, err := qtx.LockChannelForReparent(ctx, string(channelID)) - if err != nil { - if noRows(err) { - return Channel{}, fmt.Errorf("%w: channel %q", ErrNotFound, channelID) - } - return Channel{}, fmt.Errorf("store: lock channel for reparent: %w", err) - } if row.GroupID.Valid { return Channel{}, fmt.Errorf("%w: grouped channel cannot be attached", ErrInvalidArgument) } diff --git a/go/internal/store/db/channels.sql.go b/go/internal/store/db/channels.sql.go index 10cad6ead..a6154449e 100644 --- a/go/internal/store/db/channels.sql.go +++ b/go/internal/store/db/channels.sql.go @@ -154,6 +154,9 @@ type ChannelParticipantParams struct { AccountID string } +// Explicit member row, or (TREE only) the actor is in the anchor's subtree or +// owns the anchor. UNION, not UNION ALL: agent parent rows are not guaranteed +// acyclic, and deduplication is what stops the walk on a cycle. func (q *Queries) ChannelParticipant(ctx context.Context, arg ChannelParticipantParams) (pgtype.Bool, error) { row := q.db.QueryRow(ctx, channelParticipant, arg.ChannelID, arg.AccountID) var column_1 pgtype.Bool diff --git a/go/internal/store/db/querier.go b/go/internal/store/db/querier.go index bb9ae63f8..2cfe8142b 100644 --- a/go/internal/store/db/querier.go +++ b/go/internal/store/db/querier.go @@ -80,6 +80,9 @@ type Querier interface { ChannelMemberExists(ctx context.Context, arg ChannelMemberExistsParams) (bool, error) ChannelMemberIDs(ctx context.Context, channelID string) ([]string, error) ChannelMembersByChannelIDs(ctx context.Context, dollar_1 []string) ([]ChannelMembersByChannelIDsRow, error) + // Explicit member row, or (TREE only) the actor is in the anchor's subtree or + // owns the anchor. UNION, not UNION ALL: agent parent rows are not guaranteed + // acyclic, and deduplication is what stops the walk on a cycle. ChannelParticipant(ctx context.Context, arg ChannelParticipantParams) (pgtype.Bool, error) ChannelVisibleTo(ctx context.Context, arg ChannelVisibleToParams) (bool, error) ChannelsByNameForViewer(ctx context.Context, arg ChannelsByNameForViewerParams) ([]ChannelsByNameForViewerRow, error) diff --git a/go/internal/store/queries/channels.sql b/go/internal/store/queries/channels.sql index 0e9c351ff..42d80a04a 100644 --- a/go/internal/store/queries/channels.sql +++ b/go/internal/store/queries/channels.sql @@ -23,6 +23,9 @@ INSERT INTO channels (id, name, group_id, kind, post_policy, owner_account_id, m VALUES ($1, $2, NULLIF($3, ''), $4, $5, NULLIF($6, ''), $7, NULLIF($8, ''), $9); -- name: ChannelParticipant :one +-- Explicit member row, or (TREE only) the actor is in the anchor's subtree or +-- owns the anchor. UNION, not UNION ALL: agent parent rows are not guaranteed +-- acyclic, and deduplication is what stops the walk on a cycle. WITH RECURSIVE chain AS ( SELECT aa.account_id, aa.parent_agent_id FROM agent_accounts aa From da341f498a46ca1da57f1fabe2e98b3aacdbbd99 Mon Sep 17 00:00:00 2001 From: mintaka Date: Mon, 5 Oct 2026 23:41:44 -0400 Subject: [PATCH 3/4] fix(store): gate reparent participation before and after the row lock (RIG-4186) Co-authored-by: Matt Wilkinson --- go/internal/store/channels.go | 37 +++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/go/internal/store/channels.go b/go/internal/store/channels.go index 42785c75f..164c091a8 100644 --- a/go/internal/store/channels.go +++ b/go/internal/store/channels.go @@ -271,9 +271,9 @@ func (s *Store) writeExplicitMembers(ctx context.Context, tx pgx.Tx, id ChannelI } // ReparentChannel moves an ungrouped CHANNEL within its participant owner set. -// The row lock comes first so the authz reads see committed membership and -// anchor; both gates then run before any shape refusal, so an InvalidArgument -// never tells a non-participant that the channel exists. +// Participation is checked before the row lock, so a non-participant never +// holds it, and again after, against committed membership. Both gates run +// before any shape refusal, so an InvalidArgument never reveals the channel. func (s *Store) ReparentChannel(ctx context.Context, actor AccountID, channelID ChannelID, newParentAgentID AccountID) (Channel, error) { if actor == "" { return Channel{}, fmt.Errorf("%w: actor is required", ErrInvalidArgument) @@ -289,6 +289,9 @@ func (s *Store) ReparentChannel(ctx context.Context, actor AccountID, channelID defer func() { _ = tx.Rollback(ctx) }() qtx := s.q.WithTx(tx) + if err := requireChannelParticipant(ctx, qtx, actor, channelID); err != nil { + return Channel{}, err + } row, err := qtx.LockChannelForReparent(ctx, string(channelID)) if err != nil { if noRows(err) { @@ -296,16 +299,8 @@ func (s *Store) ReparentChannel(ctx context.Context, actor AccountID, channelID } return Channel{}, fmt.Errorf("store: lock channel for reparent: %w", err) } - - participant, err := qtx.ChannelParticipant(ctx, db.ChannelParticipantParams{ - ChannelID: string(channelID), - AccountID: string(actor), - }) - if err != nil { - return Channel{}, fmt.Errorf("store: check channel participation: %w", err) - } - if !participant.Valid || !participant.Bool { - return Channel{}, fmt.Errorf("%w: channel %q", ErrNotFound, channelID) + if err := requireChannelParticipant(ctx, qtx, actor, channelID); err != nil { + return Channel{}, err } if newParentAgentID != "" { @@ -354,6 +349,22 @@ func (s *Store) ReparentChannel(ctx context.Context, actor AccountID, channelID return s.getChannel(ctx, channelID) } +// requireChannelParticipant is the ReparentChannel gate: non-participant and +// unknown channel both merge to ErrNotFound. +func requireChannelParticipant(ctx context.Context, q *db.Queries, actor AccountID, channelID ChannelID) error { + participant, err := q.ChannelParticipant(ctx, db.ChannelParticipantParams{ + ChannelID: string(channelID), + AccountID: string(actor), + }) + if err != nil { + return fmt.Errorf("store: check channel participation: %w", err) + } + if !participant.Valid || !participant.Bool { + return fmt.Errorf("%w: channel %q", ErrNotFound, channelID) + } + return nil +} + // expandOwnerMembership computes the final member set for a new channel: the // requested members, plus the actor, plus the owning user of every agent in the // set — the transitive owner-membership invariant (design.md:231-234), From 1a144e8bfe9523c55ed4d32970c46fe3a93c44d0 Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 6 Oct 2026 01:56:16 -0400 Subject: [PATCH 4/4] chore(store): renumber the channel-tree migration to 0002 after the 0001 fold (RIG-4186) Co-authored-by: Matt Wilkinson --- .../migrations/{0009_channel_tree.sql => 0002_channel_tree.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename go/internal/store/migrations/{0009_channel_tree.sql => 0002_channel_tree.sql} (100%) diff --git a/go/internal/store/migrations/0009_channel_tree.sql b/go/internal/store/migrations/0002_channel_tree.sql similarity index 100% rename from go/internal/store/migrations/0009_channel_tree.sql rename to go/internal/store/migrations/0002_channel_tree.sql