feat(squad): allow members to create and manage their own squads (MUL-4223) (#5071)

Squad create/manage was gated behind workspace owner/admin, inconsistent with
agents and projects which any member can create. Move squads to a creator-scoped
model: any member can create a squad and becomes its creator, and manages only
the squads they created; owner/admin continue to manage every squad.

Backend (server/internal/handler/squad.go):
- Add canManageSquad (admin/owner OR creator) and gate UpdateSquad, DeleteSquad,
  AddSquadMember, RemoveSquadMember, UpdateSquadMemberRole on it (member load +
  squad load + per-squad check, replacing requireWorkspaceRole).
- CreateSquad is now member-creatable.
- Add memberCanWireAgent: a non-admin may only wire agents they can @-trigger
  (canInvokeAgent as themselves) as squad leader (create/update) or worker
  (add member); admins may wire any workspace agent. Prevents a creator from
  smuggling an agent they cannot invoke into a squad.

Frontend:
- squad-detail-page: compute per-squad canManage (admin || creator) and render
  the inspector, members tab, instructions and archive read-only otherwise,
  mirroring the agent detail canEdit pattern.
- squads-page: per-row actions and the actions column now key off per-squad
  canManage instead of workspace-admin.

Squads stay visible workspace-wide (ListSquads unfiltered); creator transfer is
out of scope for this iteration.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Bohan Jiang
2026-07-08 14:51:54 +08:00
committed by GitHub
parent 4ed582e3bb
commit 405d88c1dc
4 changed files with 480 additions and 75 deletions

View File

@@ -103,19 +103,26 @@ export function SquadDetailPage() {
const { data: wsMembers = [] } = useQuery(memberListOptions(wsId));
// Runtimes are only fetched when the Create Agent dialog might open;
// gating on isWorkspaceAdmin below means non-admins never trigger the
// request. The runtime list mirrors the agents page so the picker
// (and the "only my runtimes" filter) behaves identically here.
// gating on canManage below means users who can't manage this squad never
// trigger the request. The runtime list mirrors the agents page so the
// picker (and the "only my runtimes" filter) behaves identically here.
const currentUser = useAuthStore((s) => s.user);
const myRole = useMemo(() => {
if (!currentUser) return null;
return wsMembers.find((m) => m.user_id === currentUser.id)?.role ?? null;
}, [wsMembers, currentUser]);
const isWorkspaceAdmin = myRole === "owner" || myRole === "admin";
// Per-squad management gate: workspace owner/admin manage every squad; the
// creator manages the squads they created. Mirrors canManageSquad in
// server/internal/handler/squad.go so editable controls appear exactly when
// the API will accept the write, and everyone else gets a read-only view
// instead of controls that 403 (MUL-4223).
const canManage =
isWorkspaceAdmin || (!!currentUser && squad?.creator_id === currentUser.id);
const { data: runtimes = [], isLoading: runtimesLoading } = useQuery({
...runtimeListOptions(wsId),
enabled: !!wsId && isWorkspaceAdmin,
enabled: !!wsId && canManage,
});
const [showAddMember, setShowAddMember] = useState(false);
@@ -233,10 +240,12 @@ export function SquadDetailPage() {
</>
}
actions={
<Button size="sm" variant="ghost" className="text-destructive hover:text-destructive" onClick={() => setConfirmArchive(true)}>
<Trash2 className="size-3.5 mr-1" />
{t(($) => $.inspector.archive_button)}
</Button>
canManage ? (
<Button size="sm" variant="ghost" className="text-destructive hover:text-destructive" onClick={() => setConfirmArchive(true)}>
<Trash2 className="size-3.5 mr-1" />
{t(($) => $.inspector.archive_button)}
</Button>
) : null
}
/>
@@ -249,6 +258,7 @@ export function SquadDetailPage() {
memberCount={members.length}
leaderName={getEntityName("agent", squad.leader_id)}
creatorName={getEntityName("member", squad.creator_id)}
canManage={canManage}
uploadingAvatar={updateSquadMut.isPending}
onUploadAvatar={(url) => updateSquadMut.mutateAsync({ avatar_url: url })}
onRename={async (next) => { await updateSquadMut.mutateAsync({ name: next.trim() }); }}
@@ -259,11 +269,12 @@ export function SquadDetailPage() {
squad={squad}
members={members}
memberStatusById={memberStatusById}
canManage={canManage}
isLeader={isLeader}
isArchived={isArchived}
getEntityName={getEntityName}
onAddMemberClick={() => setShowAddMember(true)}
onCreateAgentClick={isWorkspaceAdmin ? () => setShowCreateAgent(true) : undefined}
onCreateAgentClick={canManage ? () => setShowCreateAgent(true) : undefined}
onSetLeader={(id) => setLeaderMut.mutate(id)}
onRemoveMember={(m) => removeMemberMut.mutate(m)}
onUpdateRole={async (m, role) => { await updateRoleMut.mutateAsync({ member: m, role }); }}
@@ -284,10 +295,11 @@ export function SquadDetailPage() {
{/* Squad-scoped create flow: same dialog as the Agents page but
with squadId set, so the dialog runs api.addSquadMember after
api.createAgent and skips the agent-detail navigation. Only
mounted for workspace owner/admin since AddSquadMember is
owner/admin-gated server-side; for everyone else the trigger
never renders. */}
{showCreateAgent && isWorkspaceAdmin && (
mounted for users who can manage this squad (workspace owner/admin
or the creator); for everyone else the trigger never renders. The
newly created agent is owned by the creator, so it is always one
they can invoke and add to the squad. */}
{showCreateAgent && canManage && (
<CreateAgentDialog
runtimes={runtimes}
runtimesLoading={runtimesLoading}
@@ -457,6 +469,28 @@ function SquadAvatarEditor({
);
}
// Read-only 64px avatar for viewers who can't manage the squad — same visual
// as SquadAvatarEditor's resting state but without the click/upload affordance.
function SquadStaticAvatar({ squad, initials }: { squad: Squad; initials: string }) {
return (
<div className="h-16 w-16 shrink-0 overflow-hidden rounded-lg bg-muted">
{squad.avatar_url ? (
<ActorAvatarBase
name={squad.name}
initials={initials}
avatarUrl={resolvePublicFileUrl(squad.avatar_url)}
size={64}
className="rounded-none"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-muted-foreground">
<Users className="h-7 w-7" />
</div>
)}
</div>
);
}
// Inline name editor — reveals a Pencil affordance on hover, opens a small
// popover with a single-line input. Mirrors the NameAndDescription editor
// in the agent inspector.
@@ -802,6 +836,7 @@ function SquadDetailInspector({
memberCount,
leaderName,
creatorName,
canManage,
uploadingAvatar,
onUploadAvatar,
onRename,
@@ -811,6 +846,10 @@ function SquadDetailInspector({
memberCount: number;
leaderName: string;
creatorName: string;
// When false the identity block renders as static text (no avatar upload,
// no rename/description popovers) — the viewer can read the squad but not
// edit it. Mirrors the agent inspector's `canEdit` read-only treatment.
canManage: boolean;
uploadingAvatar: boolean;
onUploadAvatar: (url: string) => Promise<unknown>;
onRename: (next: string) => Promise<void>;
@@ -829,19 +868,39 @@ function SquadDetailInspector({
<aside className="flex w-full flex-col rounded-lg border bg-background md:h-full md:min-h-0 md:overflow-y-auto">
{/* Identity */}
<div className="flex flex-col gap-3 border-b px-5 pb-5 pt-5">
<SquadAvatarEditor
squad={squad}
initials={initials}
uploading={uploadingAvatar}
onUpload={onUploadAvatar}
/>
<div className="flex flex-col gap-1">
<SquadNameEditor value={squad.name} onSave={onRename} />
<SquadDescriptionEditor
value={squad.description ?? ""}
onSave={onUpdateDescription}
/>
</div>
{canManage ? (
<>
<SquadAvatarEditor
squad={squad}
initials={initials}
uploading={uploadingAvatar}
onUpload={onUploadAvatar}
/>
<div className="flex flex-col gap-1">
<SquadNameEditor value={squad.name} onSave={onRename} />
<SquadDescriptionEditor
value={squad.description ?? ""}
onSave={onUpdateDescription}
/>
</div>
</>
) : (
<>
<SquadStaticAvatar squad={squad} initials={initials} />
<div className="flex flex-col gap-1">
<span className="text-lg font-semibold leading-tight">{squad.name}</span>
{squad.description ? (
<span className="text-xs leading-relaxed text-muted-foreground">
{squad.description}
</span>
) : (
<span className="text-xs italic leading-relaxed text-muted-foreground/50">
{t(($) => $.description_dialog.placeholder_empty)}
</span>
)}
</div>
</>
)}
</div>
{/* Details — read-only */}
@@ -1003,6 +1062,7 @@ function SquadOverviewPane({
squad,
members,
memberStatusById,
canManage,
isLeader,
isArchived,
getEntityName,
@@ -1017,13 +1077,17 @@ function SquadOverviewPane({
squad: Squad;
members: SquadMember[];
memberStatusById: Map<string, SquadMemberStatus>;
// Gates every mutating control in the Members and Instructions tabs. When
// false the tabs render read-only (no add/remove/leader/role edits, no
// Save). See canManageSquad in server/internal/handler/squad.go.
canManage: boolean;
isLeader: (m: SquadMember) => boolean;
isArchived: (m: SquadMember) => boolean;
getEntityName: (type: string, id: string) => string;
onAddMemberClick: () => void;
// Optional — only passed when the current user can manage the squad
// (workspace owner/admin). Hidden otherwise so plain members don't
// see a button they can't action.
// (workspace owner/admin or the creator). Hidden otherwise so viewers
// don't see a button they can't action.
onCreateAgentClick?: () => void;
onSetLeader: (agentId: string) => void;
onRemoveMember: (m: SquadMember) => void;
@@ -1076,6 +1140,7 @@ function SquadOverviewPane({
<SquadMembersTab
members={members}
memberStatusById={memberStatusById}
canManage={canManage}
isLeader={isLeader}
isArchived={isArchived}
getEntityName={getEntityName}
@@ -1092,6 +1157,7 @@ function SquadOverviewPane({
<div className="flex h-full flex-col p-4 md:p-6">
<SquadInstructionsTab
squad={squad}
canManage={canManage}
onSave={onSaveInstructions}
onDirtyChange={setActiveDirty}
/>
@@ -1139,6 +1205,7 @@ const SQUAD_STATUS_DOT_CLASS: Record<SquadMemberStatusValue, string> = {
function SquadMembersTab({
members,
memberStatusById,
canManage,
isLeader,
isArchived,
getEntityName,
@@ -1151,11 +1218,14 @@ function SquadMembersTab({
}: {
members: SquadMember[];
memberStatusById: Map<string, SquadMemberStatus>;
// When false, add/create/leader/remove controls and role editing are hidden;
// the roster stays visible and read-only.
canManage: boolean;
isLeader: (m: SquadMember) => boolean;
isArchived: (m: SquadMember) => boolean;
getEntityName: (type: string, id: string) => string;
onAddMemberClick: () => void;
// Hidden for non-admins — see SquadOverviewPane.
// Hidden for viewers who can't manage — see SquadOverviewPane.
onCreateAgentClick?: () => void;
onSetLeader: (agentId: string) => void;
onRemoveMember: (m: SquadMember) => void;
@@ -1174,18 +1244,20 @@ function SquadMembersTab({
{t(($) => $.members_tab.section_count, { count: members.length })}
</p>
</div>
<div className="flex items-center gap-2">
{onCreateAgentClick && (
<Button size="sm" variant="outline" onClick={onCreateAgentClick}>
{canManage && (
<div className="flex items-center gap-2">
{onCreateAgentClick && (
<Button size="sm" variant="outline" onClick={onCreateAgentClick}>
<Plus className="size-3.5 mr-1.5" />
{t(($) => $.members_tab.create_agent_button)}
</Button>
)}
<Button size="sm" variant="outline" onClick={onAddMemberClick}>
<Plus className="size-3.5 mr-1.5" />
{t(($) => $.members_tab.create_agent_button)}
{t(($) => $.members_tab.add_member_button)}
</Button>
)}
<Button size="sm" variant="outline" onClick={onAddMemberClick}>
<Plus className="size-3.5 mr-1.5" />
{t(($) => $.members_tab.add_member_button)}
</Button>
</div>
</div>
)}
</div>
<div className="space-y-2">
@@ -1238,10 +1310,14 @@ function SquadMembersTab({
</span>
)}
</div>
<RoleEditor
value={m.role ?? ""}
onSave={async (next) => { await onUpdateRole(m, next); }}
/>
{canManage ? (
<RoleEditor
value={m.role ?? ""}
onSave={async (next) => { await onUpdateRole(m, next); }}
/>
) : m.role ? (
<div className="mt-0.5 text-xs text-muted-foreground">{m.role}</div>
) : null}
{primaryIssue && (
<div className="mt-1 flex items-center gap-1 text-xs text-muted-foreground min-w-0">
<AppLink
@@ -1290,7 +1366,7 @@ function SquadMembersTab({
</TooltipContent>
</Tooltip>
)}
{m.member_type === "agent" && !isLeader(m) && !isArchived(m) && (
{canManage && m.member_type === "agent" && !isLeader(m) && !isArchived(m) && (
<Tooltip>
<TooltipTrigger
render={
@@ -1311,7 +1387,7 @@ function SquadMembersTab({
</TooltipContent>
</Tooltip>
)}
{!isLeader(m) && (
{canManage && !isLeader(m) && (
<Tooltip>
<TooltipTrigger
render={
@@ -1345,10 +1421,12 @@ function SquadMembersTab({
// (server/internal/handler/daemon.go).
function SquadInstructionsTab({
squad,
canManage,
onSave,
onDirtyChange,
}: {
squad: Squad;
canManage: boolean;
onSave: (instructions: string) => Promise<void>;
onDirtyChange?: (dirty: boolean) => void;
}) {
@@ -1362,8 +1440,9 @@ function SquadInstructionsTab({
}, [squad.id, squad.instructions]);
useEffect(() => {
onDirtyChange?.(isDirty);
}, [isDirty, onDirtyChange]);
// A read-only viewer never has unsaved changes to guard on tab-switch.
onDirtyChange?.(canManage && isDirty);
}, [canManage, isDirty, onDirtyChange]);
const handleSave = async () => {
setSaving(true);
@@ -1382,31 +1461,46 @@ function SquadInstructionsTab({
{t(($) => $.instructions_tab.description)}
</p>
<div className="flex-1 min-h-0 overflow-y-auto rounded-md border bg-background px-4 py-3 transition-colors focus-within:border-input">
{/* When the viewer can't manage the squad, the editor is wrapped in a
pointer-events-none / aria-disabled shell — ContentEditor reads
`editable` at mount and can't be toggled, so this is the documented
way to present it read-only (see editor/content-editor.tsx). */}
<div
aria-disabled={!canManage}
className={`flex-1 min-h-0 overflow-y-auto rounded-md border bg-background px-4 py-3 transition-colors ${
canManage ? "focus-within:border-input" : "pointer-events-none"
}`}
>
<ContentEditor
key={squad.id}
defaultValue={value}
onUpdate={setValue}
placeholder="e.g. Always start by writing a failing test. Prefer small, atomic commits."
onUpdate={canManage ? setValue : () => {}}
placeholder={
canManage
? "e.g. Always start by writing a failing test. Prefer small, atomic commits."
: ""
}
debounceMs={150}
disableMentions
className="min-h-full"
/>
</div>
<div className="flex items-center justify-end gap-3">
{isDirty && (
<span className="text-xs text-muted-foreground">{t(($) => $.instructions_tab.unsaved_changes)}</span>
)}
<Button size="sm" onClick={handleSave} disabled={!isDirty || saving}>
{saving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Save className="h-3.5 w-3.5" />
{canManage && (
<div className="flex items-center justify-end gap-3">
{isDirty && (
<span className="text-xs text-muted-foreground">{t(($) => $.instructions_tab.unsaved_changes)}</span>
)}
{t(($) => $.instructions_tab.save_button)}
</Button>
</div>
<Button size="sm" onClick={handleSave} disabled={!isDirty || saving}>
{saving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Save className="h-3.5 w-3.5" />
)}
{t(($) => $.instructions_tab.save_button)}
</Button>
</div>
)}
</div>
);
}

View File

@@ -874,6 +874,16 @@ export function SquadsPage() {
return sorted;
}, [scopeRows, filters, sortField, sortDirection]);
// Reserve the row-actions (kebab) track when the current user can manage at
// least one visible squad. Workspace admins manage all squads; a regular
// member manages the squads they created (MUL-4223).
const canManageAnyRow = useMemo(
() =>
isWorkspaceAdmin ||
(!!currentUser && rows.some((s) => s.creator_id === currentUser.id)),
[isWorkspaceAdmin, rows, currentUser],
);
return (
<div className="flex flex-1 min-h-0 flex-col">
<PageHeader className="justify-between px-5">
@@ -940,7 +950,7 @@ export function SquadsPage() {
<ListGrid
className={`${GRID_COLS} @2xl:min-w-[var(--sqc-minw)]`}
style={{
...columnTrackVars(isColVisible, isWorkspaceAdmin),
...columnTrackVars(isColVisible, canManageAnyRow),
paddingBottom: LIST_GRID_BOTTOM_CLEARANCE,
}}
>
@@ -994,7 +1004,8 @@ export function SquadsPage() {
<ListGridCell className="hidden px-0 @2xl:flex" />
)}
<ListGridCell className="justify-end px-0">
{isWorkspaceAdmin ? (
{isWorkspaceAdmin ||
(!!currentUser && squad.creator_id === currentUser.id) ? (
<SquadRowActions squad={squad} />
) : null}
</ListGridCell>

View File

@@ -109,6 +109,36 @@ func applySquadMemberSummary(resp *SquadResponse, summary *squadMemberSummary) {
// ── Helpers ─────────────────────────────────────────────────────────────────
// canManageSquad reports whether the member may mutate the squad. Workspace
// owner/admin manage every squad; a regular member manages only the squads
// they created. Squads stay creator-scoped for management while remaining
// visible workspace-wide (ListSquads is unfiltered). Mirrors the front-end
// per-squad `canManage` gate so the UI and API agree on who can rename / add
// members / archive (MUL-4223).
func canManageSquad(member db.Member, squad db.Squad) bool {
if roleAllowed(member.Role, "owner", "admin") {
return true
}
return uuidToString(squad.CreatorID) == uuidToString(member.UserID)
}
// memberCanWireAgent reports whether the acting member may attach the given
// agent to a squad (as leader or worker). Workspace owner/admin may wire any
// workspace agent — their management surface is unchanged. A regular member
// (a creator managing their own squad) may only wire agents they can
// @-trigger: canInvokeAgent judged as the member themselves, so public_to
// agents on their allow-list and their own private agents pass, while other
// members' private / non-allow-listed agents are rejected. This stops a
// creator from smuggling an agent they cannot invoke into a squad and reaching
// it through squad routing (MUL-4223).
func (h *Handler) memberCanWireAgent(ctx context.Context, member db.Member, agent db.Agent, workspaceID string) bool {
if roleAllowed(member.Role, "owner", "admin") {
return true
}
uid := uuidToString(member.UserID)
return h.canInvokeAgent(ctx, agent, "member", uid, uid, workspaceID)
}
// loadSquadInWorkspace loads a squad scoped to the current workspace.
func (h *Handler) loadSquadInWorkspace(w http.ResponseWriter, r *http.Request) (db.Squad, string, bool) {
workspaceID := workspaceIDFromURL(r, "workspaceId")
@@ -194,7 +224,10 @@ func (h *Handler) ListSquads(w http.ResponseWriter, r *http.Request) {
func (h *Handler) CreateSquad(w http.ResponseWriter, r *http.Request) {
workspaceID := workspaceIDFromURL(r, "workspaceId")
member, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin")
// Any workspace member can create a squad and becomes its creator
// (CreatorID below). This aligns squads with agents/projects, which are
// also member-creatable; management stays creator-scoped (MUL-4223).
member, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found")
if !ok {
return
}
@@ -228,7 +261,7 @@ func (h *Handler) CreateSquad(w http.ResponseWriter, r *http.Request) {
}
// Validate leader is an agent in this workspace.
_, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{
leaderAgent, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{
ID: leaderUUID,
WorkspaceID: wsUUID,
})
@@ -236,6 +269,12 @@ func (h *Handler) CreateSquad(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "leader must be a valid agent in this workspace")
return
}
// A non-admin creator may only lead their squad with an agent they can
// @-trigger; admins may wire any workspace agent (MUL-4223).
if !h.memberCanWireAgent(r.Context(), member, leaderAgent, workspaceID) {
writeError(w, http.StatusForbidden, "you can only use an agent you have access to as leader")
return
}
avatarURL := pgtype.Text{}
if req.AvatarURL != nil {
@@ -293,7 +332,8 @@ func (h *Handler) GetSquad(w http.ResponseWriter, r *http.Request) {
func (h *Handler) UpdateSquad(w http.ResponseWriter, r *http.Request) {
workspaceID := workspaceIDFromURL(r, "workspaceId")
if _, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin"); !ok {
member, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found")
if !ok {
return
}
@@ -301,6 +341,10 @@ func (h *Handler) UpdateSquad(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
if !canManageSquad(member, squad) {
writeError(w, http.StatusForbidden, "insufficient permissions")
return
}
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
if !ok {
return
@@ -337,12 +381,18 @@ func (h *Handler) UpdateSquad(w http.ResponseWriter, r *http.Request) {
return
}
// Validate new leader is an agent in workspace.
if _, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{
newLeader, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{
ID: lid, WorkspaceID: wsUUID,
}); err != nil {
})
if err != nil {
writeError(w, http.StatusBadRequest, "leader must be a valid agent in this workspace")
return
}
// A non-admin creator may only promote an agent they can @-trigger.
if !h.memberCanWireAgent(r.Context(), member, newLeader, workspaceID) {
writeError(w, http.StatusForbidden, "you can only use an agent you have access to as leader")
return
}
// Ensure new leader is a squad member; auto-add if not.
isMember, _ := h.Queries.IsSquadMember(r.Context(), db.IsSquadMemberParams{
SquadID: squad.ID, MemberType: "agent", MemberID: lid,
@@ -372,7 +422,8 @@ func (h *Handler) UpdateSquad(w http.ResponseWriter, r *http.Request) {
func (h *Handler) DeleteSquad(w http.ResponseWriter, r *http.Request) {
workspaceID := workspaceIDFromURL(r, "workspaceId")
if _, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin"); !ok {
member, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found")
if !ok {
return
}
@@ -380,6 +431,10 @@ func (h *Handler) DeleteSquad(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
if !canManageSquad(member, squad) {
writeError(w, http.StatusForbidden, "insufficient permissions")
return
}
if squad.ArchivedAt.Valid {
writeError(w, http.StatusBadRequest, "squad is already archived")
@@ -634,7 +689,8 @@ func (h *Handler) ListSquadMemberStatus(w http.ResponseWriter, r *http.Request)
func (h *Handler) AddSquadMember(w http.ResponseWriter, r *http.Request) {
workspaceID := workspaceIDFromURL(r, "workspaceId")
if _, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin"); !ok {
member, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found")
if !ok {
return
}
@@ -642,6 +698,10 @@ func (h *Handler) AddSquadMember(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
if !canManageSquad(member, squad) {
writeError(w, http.StatusForbidden, "insufficient permissions")
return
}
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
if !ok {
return
@@ -672,12 +732,20 @@ func (h *Handler) AddSquadMember(w http.ResponseWriter, r *http.Request) {
// Validate the member belongs to this workspace.
if req.MemberType == "agent" {
if _, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{
agent, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{
ID: memberUUID, WorkspaceID: wsUUID,
}); err != nil {
})
if err != nil {
writeError(w, http.StatusBadRequest, "agent not found in this workspace")
return
}
// A non-admin creator may only add agents they can @-trigger (public
// or their own / allow-listed agents); admins may add any workspace
// agent (MUL-4223).
if !h.memberCanWireAgent(r.Context(), member, agent, workspaceID) {
writeError(w, http.StatusForbidden, "you can only add an agent you have access to")
return
}
} else {
if _, err := h.Queries.GetMemberByUserAndWorkspace(r.Context(), db.GetMemberByUserAndWorkspaceParams{
UserID: memberUUID, WorkspaceID: wsUUID,
@@ -710,7 +778,8 @@ func (h *Handler) AddSquadMember(w http.ResponseWriter, r *http.Request) {
func (h *Handler) RemoveSquadMember(w http.ResponseWriter, r *http.Request) {
workspaceID := workspaceIDFromURL(r, "workspaceId")
if _, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin"); !ok {
member, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found")
if !ok {
return
}
@@ -718,6 +787,10 @@ func (h *Handler) RemoveSquadMember(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
if !canManageSquad(member, squad) {
writeError(w, http.StatusForbidden, "insufficient permissions")
return
}
var req struct {
MemberType string `json:"member_type"`
@@ -761,7 +834,8 @@ func (h *Handler) RemoveSquadMember(w http.ResponseWriter, r *http.Request) {
func (h *Handler) UpdateSquadMemberRole(w http.ResponseWriter, r *http.Request) {
workspaceID := workspaceIDFromURL(r, "workspaceId")
if _, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin"); !ok {
member, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found")
if !ok {
return
}
@@ -769,6 +843,10 @@ func (h *Handler) UpdateSquadMemberRole(w http.ResponseWriter, r *http.Request)
if !ok {
return
}
if !canManageSquad(member, squad) {
writeError(w, http.StatusForbidden, "insufficient permissions")
return
}
var req struct {
MemberType string `json:"member_type"`

View File

@@ -0,0 +1,222 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
)
// squadScopeReq builds a request as the given user (empty = workspace owner)
// with the chi URL params the squad handlers read (workspaceId + optional id).
// The squad handlers resolve the workspace from workspaceIDFromURL, which reads
// the chi route context, not the query string — so tests must inject the params
// here rather than on the path.
func squadScopeReq(userID, method, path string, body any, params map[string]string) *http.Request {
var req *http.Request
if userID == "" {
req = newRequest(method, path, body)
} else {
req = newRequestAs(userID, method, path, body)
}
rctx := chi.NewRouteContext()
rctx.URLParams.Add("workspaceId", testWorkspaceID)
for k, v := range params {
rctx.URLParams.Add(k, v)
}
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
}
// createSquadAs creates a squad through the handler as the given user and
// returns the decoded response. Registers cleanup for the squad + its members.
func createSquadAs(t *testing.T, userID, name, leaderID string) SquadResponse {
t.Helper()
w := httptest.NewRecorder()
r := squadScopeReq(userID, "POST", "/api/squads", map[string]any{
"name": name,
"leader_id": leaderID,
}, nil)
testHandler.CreateSquad(w, r)
if w.Code != http.StatusCreated {
t.Fatalf("CreateSquad(%s): expected 201, got %d: %s", name, w.Code, w.Body.String())
}
var resp SquadResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode squad: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM squad_member WHERE squad_id = $1`, resp.ID)
testPool.Exec(context.Background(), `DELETE FROM squad WHERE id = $1`, resp.ID)
})
return resp
}
// TestCreateSquad_PlainMemberBecomesCreator verifies the gate change: a plain
// workspace member (not owner/admin) can create a squad and is recorded as its
// creator.
func TestCreateSquad_PlainMemberBecomesCreator(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
memberID := createPlainMember(t, "squad-creator@multica.test")
leaderID := createHandlerTestAgent(t, "squad-creator-leader", nil)
squad := createSquadAs(t, memberID, "Member Owned Squad", leaderID)
if squad.CreatorID != memberID {
t.Fatalf("expected creator_id=%s, got %s", memberID, squad.CreatorID)
}
}
// TestManageSquad_CreatorCanManageOwn verifies a creator can update, add a
// member to, and archive their own squad.
func TestManageSquad_CreatorCanManageOwn(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
memberID := createPlainMember(t, "squad-owner-manage@multica.test")
leaderID := createHandlerTestAgent(t, "squad-owner-manage-leader", nil)
worker := createHandlerTestAgent(t, "squad-owner-manage-worker", nil)
squad := createSquadAs(t, memberID, "Manage Own Squad", leaderID)
// Update name.
w := httptest.NewRecorder()
testHandler.UpdateSquad(w, squadScopeReq(memberID, "PATCH", "/api/squads", map[string]any{
"name": "Renamed By Creator",
}, map[string]string{"id": squad.ID}))
if w.Code != http.StatusOK {
t.Fatalf("UpdateSquad as creator: expected 200, got %d: %s", w.Code, w.Body.String())
}
// Add a public agent worker.
w = httptest.NewRecorder()
testHandler.AddSquadMember(w, squadScopeReq(memberID, "POST", "/api/squads/members", map[string]any{
"member_type": "agent",
"member_id": worker,
}, map[string]string{"id": squad.ID}))
if w.Code != http.StatusCreated {
t.Fatalf("AddSquadMember as creator: expected 201, got %d: %s", w.Code, w.Body.String())
}
// Archive.
w = httptest.NewRecorder()
testHandler.DeleteSquad(w, squadScopeReq(memberID, "DELETE", "/api/squads", nil,
map[string]string{"id": squad.ID}))
if w.Code != http.StatusNoContent {
t.Fatalf("DeleteSquad as creator: expected 204, got %d: %s", w.Code, w.Body.String())
}
}
// TestManageSquad_StrangerMemberForbidden verifies a plain member who did not
// create the squad cannot manage it, while a workspace admin/owner still can.
func TestManageSquad_StrangerMemberForbidden(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
creatorID := createPlainMember(t, "squad-stranger-creator@multica.test")
strangerID := createPlainMember(t, "squad-stranger-other@multica.test")
leaderID := createHandlerTestAgent(t, "squad-stranger-leader", nil)
squad := createSquadAs(t, creatorID, "Stranger Test Squad", leaderID)
// Stranger member: update denied.
w := httptest.NewRecorder()
testHandler.UpdateSquad(w, squadScopeReq(strangerID, "PATCH", "/api/squads", map[string]any{
"name": "Hijacked",
}, map[string]string{"id": squad.ID}))
if w.Code != http.StatusForbidden {
t.Fatalf("UpdateSquad as stranger: expected 403, got %d: %s", w.Code, w.Body.String())
}
// Stranger member: archive denied.
w = httptest.NewRecorder()
testHandler.DeleteSquad(w, squadScopeReq(strangerID, "DELETE", "/api/squads", nil,
map[string]string{"id": squad.ID}))
if w.Code != http.StatusForbidden {
t.Fatalf("DeleteSquad as stranger: expected 403, got %d: %s", w.Code, w.Body.String())
}
// Workspace owner (testUserID): update allowed — admin management unchanged.
w = httptest.NewRecorder()
testHandler.UpdateSquad(w, squadScopeReq("", "PATCH", "/api/squads", map[string]any{
"name": "Renamed By Admin",
}, map[string]string{"id": squad.ID}))
if w.Code != http.StatusOK {
t.Fatalf("UpdateSquad as workspace owner: expected 200, got %d: %s", w.Code, w.Body.String())
}
}
// TestAddSquadMember_CreatorAgentAccessGate verifies the comment-#2 rule: a
// non-admin creator may add a public agent (invocable) but not a private agent
// they cannot @-trigger. The workspace owner may add the same private agent —
// admin wiring is unrestricted.
func TestAddSquadMember_CreatorAgentAccessGate(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
privateAgentID, _, memberID := privateAgentTestFixture(t)
publicLeaderID := createHandlerTestAgent(t, "squad-gate-leader", nil)
publicWorkerID := createHandlerTestAgent(t, "squad-gate-worker", nil)
squad := createSquadAs(t, memberID, "Agent Gate Squad", publicLeaderID)
// Creator adds a public (invocable) worker — allowed.
w := httptest.NewRecorder()
testHandler.AddSquadMember(w, squadScopeReq(memberID, "POST", "/api/squads/members", map[string]any{
"member_type": "agent",
"member_id": publicWorkerID,
}, map[string]string{"id": squad.ID}))
if w.Code != http.StatusCreated {
t.Fatalf("AddSquadMember public agent: expected 201, got %d: %s", w.Code, w.Body.String())
}
// Creator adds a private agent they cannot invoke — denied.
w = httptest.NewRecorder()
testHandler.AddSquadMember(w, squadScopeReq(memberID, "POST", "/api/squads/members", map[string]any{
"member_type": "agent",
"member_id": privateAgentID,
}, map[string]string{"id": squad.ID}))
if w.Code != http.StatusForbidden {
t.Fatalf("AddSquadMember private agent as creator: expected 403, got %d: %s", w.Code, w.Body.String())
}
// Workspace owner adds the same private agent — allowed (admin unchanged).
w = httptest.NewRecorder()
testHandler.AddSquadMember(w, squadScopeReq("", "POST", "/api/squads/members", map[string]any{
"member_type": "agent",
"member_id": privateAgentID,
}, map[string]string{"id": squad.ID}))
if w.Code != http.StatusCreated {
t.Fatalf("AddSquadMember private agent as owner: expected 201, got %d: %s", w.Code, w.Body.String())
}
}
// TestCreateSquad_CreatorPrivateLeaderForbidden verifies a non-admin cannot
// create a squad led by a private agent they cannot @-trigger.
func TestCreateSquad_CreatorPrivateLeaderForbidden(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
privateAgentID, _, memberID := privateAgentTestFixture(t)
w := httptest.NewRecorder()
r := squadScopeReq(memberID, "POST", "/api/squads", map[string]any{
"name": "Private Leader Squad",
"leader_id": privateAgentID,
}, nil)
testHandler.CreateSquad(w, r)
if w.Code != http.StatusForbidden {
// Nothing should have been created; if it slipped through, clean up.
if w.Code == http.StatusCreated {
var resp SquadResponse
if json.NewDecoder(w.Body).Decode(&resp) == nil {
testPool.Exec(context.Background(), `DELETE FROM squad_member WHERE squad_id = $1`, resp.ID)
testPool.Exec(context.Background(), `DELETE FROM squad WHERE id = $1`, resp.ID)
}
}
t.Fatalf("CreateSquad with private leader: expected 403, got %d: %s", w.Code, w.Body.String())
}
}