mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-28 22:17:48 +02:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"`
|
||||
|
||||
222
server/internal/handler/squad_creator_scope_test.go
Normal file
222
server/internal/handler/squad_creator_scope_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user