diff --git a/.changeset/restrict-dispatch-org-access.md b/.changeset/restrict-dispatch-org-access.md index 5513ad037ce..7fbab936fa3 100644 --- a/.changeset/restrict-dispatch-org-access.md +++ b/.changeset/restrict-dispatch-org-access.md @@ -3,4 +3,4 @@ "@agent-native/dispatch": patch --- -Restrict organization-scoped Dispatch access to owners and admins. +Restore Dispatch access for all authenticated organization members. diff --git a/packages/core/src/client/org/OrgSwitcher.spec.tsx b/packages/core/src/client/org/OrgSwitcher.spec.tsx index 9fb559922e4..3603c5b9c12 100644 --- a/packages/core/src/client/org/OrgSwitcher.spec.tsx +++ b/packages/core/src/client/org/OrgSwitcher.spec.tsx @@ -599,7 +599,7 @@ describe("OrgSwitcher", () => { ).not.toBeNull(); }); - it.each(["owner", "admin"] as const)( + it.each(["owner", "admin", "member"] as const)( "shows Dispatch to organization %s members", (role) => { mocks.appLinks.mockReturnValue({ @@ -658,7 +658,57 @@ describe("OrgSwitcher", () => { }, ); - it("hides Dispatch and its all-apps link from organization members", () => { + it("does not synthesize Dispatch after an org registry revokes access", () => { + mocks.appLinks.mockReturnValue({ + apps: [ + { + id: "analytics", + name: "Analytics", + href: "/analytics", + isDispatch: false, + status: "ready", + }, + ], + dispatchAllAppsHref: "/dispatch/apps", + dispatchHref: "/dispatch/overview", + isLoading: false, + isWorkspace: true, + }); + mocks.useOrg.mockReturnValue({ + data: { + email: "member@example.com", + orgId: "org-1", + orgName: "Acme", + role: "member", + orgs: [{ orgId: "org-1", orgName: "Acme", role: "member" }], + pendingInvitations: [], + domainMatches: [], + }, + isLoading: false, + }); + + render(); + act(() => { + container.querySelector("button")!.click(); + }); + const appsButton = Array.from( + document.body.querySelectorAll("button"), + ).find((button) => button.textContent?.trim().startsWith("Apps")); + expect(appsButton).not.toBeNull(); + + act(() => { + appsButton!.click(); + }); + + expect( + document.body.querySelector( + 'a[href="/dispatch/overview"]', + ), + ).toBeNull(); + expect(document.body.textContent).not.toContain("more in Dispatch"); + }); + + it("shows Dispatch and its all-apps link to organization members", () => { mocks.appLinks.mockReturnValue({ apps: [ { @@ -718,10 +768,10 @@ describe("OrgSwitcher", () => { document.body.querySelector( 'a[href="/dispatch/overview"]', ), - ).toBeNull(); + ).not.toBeNull(); expect( document.body.querySelector('a[href="/analytics"]'), ).not.toBeNull(); - expect(document.body.textContent).not.toContain("more in Dispatch"); + expect(document.body.textContent).toContain("more in Dispatch"); }); }); diff --git a/packages/core/src/client/org/OrgSwitcher.tsx b/packages/core/src/client/org/OrgSwitcher.tsx index d6bc5668e7f..58c936cf9e9 100644 --- a/packages/core/src/client/org/OrgSwitcher.tsx +++ b/packages/core/src/client/org/OrgSwitcher.tsx @@ -44,7 +44,6 @@ import { useState, type ReactNode } from "react"; import { Link, useNavigate } from "react-router"; import { setBrowserDemoModeEnabled } from "../../demo/browser-state.js"; -import { canManageOrg } from "../../org/permissions.js"; import { shouldOfferWorkspace } from "../../org/workspace-url.js"; import { useT } from "../i18n.js"; import { signOut } from "../sign-out.js"; @@ -221,46 +220,47 @@ function AppMenuLink({ function AppsSubmenu({ apps, isLoading, + isWorkspace, dispatchHref, dispatchAllAppsHref, - canAccessDispatch, currentAppId, onNavigate, }: { apps: OrgSwitcherAppLink[]; isLoading: boolean; + isWorkspace: boolean; dispatchHref: string; dispatchAllAppsHref: string; - canAccessDispatch: boolean; currentAppId?: string; onNavigate: () => void; }) { const appsForMenu = currentAppId ? apps.filter((app) => app.id !== currentAppId) : apps; - const accessibleAppsForMenu = canAccessDispatch - ? appsForMenu - : appsForMenu.filter((app) => !app.isDispatch); - const { links, overflowCount } = visibleOrgAppLinks(accessibleAppsForMenu); + const { links, overflowCount } = visibleOrgAppLinks(appsForMenu); const visibleDispatchApp = links.find((app) => app.isDispatch); - const dispatchApp = - !canAccessDispatch || currentAppId === "dispatch" - ? null - : (visibleDispatchApp ?? - ({ + const fallbackDispatchApp = + !isWorkspace || isLoading + ? { id: "dispatch", name: "Dispatch", href: dispatchHref, isDispatch: true, - status: "ready", - } satisfies OrgSwitcherAppLink)); + status: "ready" as const, + } + : null; + const dispatchApp = + currentAppId === "dispatch" + ? null + : (visibleDispatchApp ?? fallbackDispatchApp); const visibleNonDispatch = links .filter((app) => !app.isDispatch) .slice(0, dispatchApp ? undefined : ORG_SWITCHER_MAX_APP_LINKS); const shownCount = (dispatchApp ? 1 : 0) + visibleNonDispatch.length; - const remainingCount = canAccessDispatch - ? Math.max(overflowCount, accessibleAppsForMenu.length - shownCount) - : 0; + const remainingCount = Math.max( + overflowCount, + appsForMenu.length - shownCount, + ); return ( @@ -272,7 +272,7 @@ function AppsSubmenu({ {isLoading ? ( ) : ( - accessibleAppsForMenu.length + appsForMenu.length )} @@ -418,7 +418,6 @@ export function OrgSwitcher({ const canInvite = !!org.orgId && (org.role === "owner" || org.role === "admin"); - const canAccessDispatch = !org.orgId || canManageOrg(org.role); const personalLabel = session?.name || personalLabelFromEmail(org.email); const inOrg = !!org.orgId; @@ -716,9 +715,9 @@ export function OrgSwitcher({ setOpen(false)} /> diff --git a/packages/core/src/org/federation.spec.ts b/packages/core/src/org/federation.spec.ts index 89a9dacce4f..9c605c63c72 100644 --- a/packages/core/src/org/federation.spec.ts +++ b/packages/core/src/org/federation.spec.ts @@ -45,6 +45,7 @@ const { syncOrganizationToIdentityHub, updateFederatedOrganizationMemberRole, validateFederatedOrganizationMembership, + validateFederatedOrganizationMembershipForCurrentRequest, } = await import("./federation.js"); const identity = { @@ -516,6 +517,35 @@ describe("cross-app organization federation", () => { ); }); + it("validates a local organization for CLI callers without a request origin", async () => { + executeMock.mockImplementation(async (input) => { + const sql = (typeof input === "string" ? input : input.sql).trim(); + if (/SELECT name, identity_authority/i.test(sql)) { + return { + rows: [ + { + name: "Example Org", + identity_authority: null, + identity_id: null, + }, + ], + }; + } + if (/SELECT role, federation_removal_pending_at/i.test(sql)) { + return { rows: [{ role: "admin" }] }; + } + throw new Error(`unexpected SQL in test: ${sql}`); + }); + + await expect( + validateFederatedOrganizationMembershipForCurrentRequest({ + orgId: "local-org-1", + email: "admin@example.test", + }), + ).resolves.toEqual({ active: true, role: "admin" }); + expect(getOriginMock).not.toHaveBeenCalled(); + }); + it("refreshes a satellite membership role from the authority", async () => { executeMock.mockImplementation(async (input) => { const sql = (typeof input === "string" ? input : input.sql).trim(); diff --git a/packages/core/src/org/federation.ts b/packages/core/src/org/federation.ts index e114bf3bfa9..05b1ebafcb3 100644 --- a/packages/core/src/org/federation.ts +++ b/packages/core/src/org/federation.ts @@ -58,13 +58,9 @@ export type FederatedMembershipValidation = | { active: true; role: OrgRole } | { active: false; role: null }; -function requestEventFromContext(): H3Event { +function requestEventFromContext(): H3Event | undefined { const requestOrigin = getRequestContext()?.requestOrigin; - if (!requestOrigin) { - throw new Error( - "Federated membership validation requires a request origin.", - ); - } + if (!requestOrigin) return undefined; let url: URL; try { url = new URL(requestOrigin); @@ -422,7 +418,7 @@ export async function revokeFederatedOrganizationMember( * them while an unavailable authority fails closed. */ export async function validateFederatedOrganizationMembership( - event: H3Event, + event: H3Event | undefined, input: { orgId: string; email: string }, ): Promise { const email = input.email.trim().toLowerCase(); @@ -458,7 +454,7 @@ export async function validateFederatedOrganizationMembership( throw new Error("Organization has an invalid identity mapping."); } - const currentOrigin = normalizeAuthority(getOrigin(event)); + const currentOrigin = event ? normalizeAuthority(getOrigin(event)) : null; if (currentOrigin === identityAuthority) { return { active: true, role: localRole }; } @@ -471,6 +467,11 @@ export async function validateFederatedOrganizationMembership( "Cross-app organization federation rollout is unavailable.", ); } + if (!event) { + throw new Error( + "Federated membership validation requires a request origin.", + ); + } const hub = resolveIdentityHubUrl(event); const authority = normalizeAuthority(hub ?? ""); if (!authority || authority !== identityAuthority) { diff --git a/packages/core/src/org/index.ts b/packages/core/src/org/index.ts index 9df59cc0a94..09c1a4ca5a7 100644 --- a/packages/core/src/org/index.ts +++ b/packages/core/src/org/index.ts @@ -44,7 +44,11 @@ export { autoJoinDomainMatchingOrgs } from "./auto-join-domain.js"; export type { AutoJoinDomainResult } from "./auto-join-domain.js"; export { setActiveOrgId } from "./active-org.js"; export { invalidateMemberOrgCaches } from "./request-org-cache.js"; -export { isWorkspaceAppAccessAllowed } from "./workspace-app-access.js"; +export { isMissingOrganizationTableError } from "./membership.js"; +export { + isStandaloneDispatchRuntime, + isWorkspaceAppAccessAllowed, +} from "./workspace-app-access.js"; export { defineAppRoles, diff --git a/packages/core/src/org/membership.spec.ts b/packages/core/src/org/membership.spec.ts index 96d8a8a2f30..6f29f205011 100644 --- a/packages/core/src/org/membership.spec.ts +++ b/packages/core/src/org/membership.spec.ts @@ -97,4 +97,12 @@ describe("isMissingOrganizationTableError", () => { expect(isMissingOrganizationTableError(error)).toBe(true); }); + + it("recognizes a missing org-members relation", () => { + expect( + isMissingOrganizationTableError( + new Error('relation "org_members" does not exist'), + ), + ).toBe(true); + }); }); diff --git a/packages/core/src/org/membership.ts b/packages/core/src/org/membership.ts index d31b605199a..ca28101b395 100644 --- a/packages/core/src/org/membership.ts +++ b/packages/core/src/org/membership.ts @@ -18,7 +18,7 @@ export function isMissingOrganizationTableError(error: unknown): boolean { }; const message = String(candidate.message ?? ""); if ( - /no such table:\s*["'`]?organizations["'`]?|relation\s+["'`]?organizations["'`]?\s+does not exist/i.test( + /no such table:\s*["'`]?(?:organizations|org_members)["'`]?|relation\s+["'`]?(?:organizations|org_members)["'`]?\s+does not exist/i.test( message, ) ) { diff --git a/packages/core/src/org/workspace-app-access.spec.ts b/packages/core/src/org/workspace-app-access.spec.ts index acf915543ec..9eb16c1508c 100644 --- a/packages/core/src/org/workspace-app-access.spec.ts +++ b/packages/core/src/org/workspace-app-access.spec.ts @@ -1,8 +1,11 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { resetAppConfigForTests } from "../app-config/index.js"; + const mocks = vi.hoisted(() => ({ execute: vi.fn(), includeUser: vi.fn(), + validateFederatedOrganizationMembershipForCurrentRequest: vi.fn(), })); vi.mock("../db/client.js", () => ({ @@ -14,14 +17,21 @@ vi.mock("../workspace-connections/groups.js", () => ({ mocks.includeUser(...args), })); +vi.mock("./federation.js", () => ({ + validateFederatedOrganizationMembershipForCurrentRequest: + mocks.validateFederatedOrganizationMembershipForCurrentRequest, +})); + import { isWorkspaceAppAccessAllowed } from "./workspace-app-access.js"; describe("isWorkspaceAppAccessAllowed", () => { afterEach(() => { vi.unstubAllEnvs(); + resetAppConfigForTests(); vi.unstubAllGlobals(); mocks.execute.mockReset(); mocks.includeUser.mockReset(); + mocks.validateFederatedOrganizationMembershipForCurrentRequest.mockReset(); }); it("allows the recorded owner in the app organization", async () => { @@ -43,22 +53,69 @@ describe("isWorkspaceAppAccessAllowed", () => { ).resolves.toBe(true); }); - it.each(["owner", "admin"] as const)( - "allows organization %s members to access Dispatch", - async (role) => { - mocks.execute.mockResolvedValueOnce({ rows: [{ role }] }); + it("allows active organization members to access Dispatch", async () => { + mocks.execute.mockResolvedValueOnce({ rows: [{ role: "member" }] }); - await expect( - isWorkspaceAppAccessAllowed("dispatch", { - email: `${role}@example.com`, - orgId: "org-1", - }), - ).resolves.toBe(true); - }, - ); + await expect( + isWorkspaceAppAccessAllowed("dispatch", { + email: "member@example.com", + orgId: "org-1", + }), + ).resolves.toBe(true); + }); - it("denies organization members access to Dispatch", async () => { - mocks.execute.mockResolvedValueOnce({ rows: [{ role: "member" }] }); + it("denies Dispatch access when a linked member was removed upstream", async () => { + mocks.execute.mockResolvedValueOnce({ + rows: [ + { + role: "admin", + identityAuthority: "https://identity.example.test", + identityId: "org-1", + }, + ], + }); + mocks.validateFederatedOrganizationMembershipForCurrentRequest.mockResolvedValue( + { active: false, role: null }, + ); + + await expect( + isWorkspaceAppAccessAllowed("dispatch", { + email: "admin@example.com", + orgId: "org-1", + }), + ).resolves.toBe(false); + expect( + mocks.validateFederatedOrganizationMembershipForCurrentRequest, + ).toHaveBeenCalledWith({ + orgId: "org-1", + email: "admin@example.com", + }); + }); + + it("keeps standalone Dispatch available when its org schema is absent", async () => { + vi.stubEnv("AGENT_NATIVE_APP_ID", "dispatch"); + resetAppConfigForTests(); + mocks.execute + .mockRejectedValueOnce(new Error('relation "org_members" does not exist')) + .mockRejectedValueOnce( + new Error('relation "org_members" does not exist'), + ); + + await expect( + isWorkspaceAppAccessAllowed("dispatch", { + email: "member@example.com", + orgId: "org-1", + }), + ).resolves.toBe(true); + }); + + it("fails closed for hosted Dispatch when its org schema is absent", async () => { + vi.stubEnv("AGENT_NATIVE_APP_ID", "dispatch"); + vi.stubEnv("AGENT_NATIVE_WORKSPACE", "1"); + resetAppConfigForTests(); + mocks.execute.mockRejectedValueOnce( + new Error('relation "org_members" does not exist'), + ); await expect( isWorkspaceAppAccessAllowed("dispatch", { diff --git a/packages/core/src/org/workspace-app-access.ts b/packages/core/src/org/workspace-app-access.ts index 18351d2663d..1d3a9587690 100644 --- a/packages/core/src/org/workspace-app-access.ts +++ b/packages/core/src/org/workspace-app-access.ts @@ -1,7 +1,10 @@ import { signA2AToken } from "../a2a/client.js"; import { getAppConfig } from "../app-config/index.js"; import { getDbExec, type DbExec } from "../db/client.js"; -import { resolveVercelDeploymentProtectionHeaders } from "../server/credential-provider.js"; +import { + isHostedWorkspaceRuntime, + resolveVercelDeploymentProtectionHeaders, +} from "../server/credential-provider.js"; import { workspaceUserGroupsIncludeUser } from "../workspace-connections/groups.js"; import { getOrgA2ASecret, getOrgDomain } from "./context.js"; import { isMissingOrganizationTableError } from "./membership.js"; @@ -24,6 +27,18 @@ function normalizedEmail(email: string): string { return email.trim().toLowerCase(); } +export function isStandaloneDispatchRuntime(): boolean { + const app = getAppConfig().app; + const isDispatch = [ + app.id, + app.legacyId, + app.template, + app.slug, + app.packageName, + ].some((value) => value?.trim().toLowerCase() === "dispatch"); + return isDispatch && !isHostedWorkspaceRuntime(); +} + function configuredWorkspaceDirectory(): string | null { const workspace = getAppConfig().workspace; return ( @@ -155,6 +170,7 @@ async function loadWorkspaceOrgMember( }); } catch (error) { if (!isMissingOrganizationTableError(error)) throw error; + if (!isStandaloneDispatchRuntime()) throw error; memberResult = await db.execute({ sql: `SELECT role FROM org_members WHERE org_id = ? AND LOWER(email) = ? @@ -196,17 +212,22 @@ async function isDispatchWorkspaceAppAccessAllowed( email: string, ): Promise { const orgId = context.orgId?.trim() || null; - // Dispatch remains available in personal/no-org mode. Organization-scoped - // Dispatch is a private control plane for owners and admins. if (!orgId) return true; try { const member = await loadWorkspaceOrgMember(getDbExec(), orgId, email); - if (!member || !(await isActiveWorkspaceOrgMember(member, orgId, email))) { - return false; - } - return member.role === "owner" || member.role === "admin"; + // Standalone Dispatch hosts can carry an org id before enabling the org + // schema. Preserve their authenticated-only access until that schema exists. + return Boolean( + member && (await isActiveWorkspaceOrgMember(member, orgId, email)), + ); } catch (error) { + if ( + isMissingOrganizationTableError(error) && + isStandaloneDispatchRuntime() + ) { + return true; + } console.error("[workspace-app-access] Dispatch access check failed", error); return false; } diff --git a/packages/core/src/server/auth.spec.ts b/packages/core/src/server/auth.spec.ts index d17eef21a62..f01b4a71b68 100644 --- a/packages/core/src/server/auth.spec.ts +++ b/packages/core/src/server/auth.spec.ts @@ -2293,19 +2293,9 @@ describe("server/auth", () => { expect(actionResult).toEqual({ error: "Unauthorized" }); }); - it("protects standalone Dispatch APIs for organization members", async () => { + it("allows standalone Dispatch APIs for organization members", async () => { vi.stubEnv("NODE_ENV", "production"); vi.stubEnv("AGENT_NATIVE_APP_ID", "dispatch"); - vi.doMock("../db/client.js", () => ({ - getDbExec: () => ({ - execute: vi.fn(async (statement: unknown) => { - const sql = String((statement as { sql?: unknown })?.sql ?? ""); - return sql.includes("FROM org_members") - ? { rows: [{ role: "member" }] } - : { rows: [] }; - }), - }), - })); const { autoMountAuth } = await import("./auth.js"); const app = createMockApp(); @@ -2323,10 +2313,8 @@ describe("server/auth", () => { path: "/_agent-native/actions/list", }); - await expect(guard(event)).resolves.toEqual({ - error: "You do not have access to this workspace app.", - }); - expect(event.res.status).toBe(403); + await expect(guard(event)).resolves.toBeUndefined(); + expect(event.res.status).not.toBe(403); }); it("does not apply Dispatch access to a renamed Dispatch scaffold", async () => { diff --git a/packages/dispatch/src/actions/apply-dream-proposal.ts b/packages/dispatch/src/actions/apply-dream-proposal.ts index 3ab1d26be2b..9ac2dbf7fe0 100644 --- a/packages/dispatch/src/actions/apply-dream-proposal.ts +++ b/packages/dispatch/src/actions/apply-dream-proposal.ts @@ -1,11 +1,15 @@ import { defineAction } from "@agent-native/core/action"; import { z } from "zod"; -import { applyDreamProposal } from "../server/lib/dreams-store.js"; +import { + applyDreamProposal, + authorizeDreamProposalMutation, +} from "../server/lib/dreams-store.js"; export default defineAction({ description: "Apply one pending Dispatch dream proposal. Personal memory applies directly; shared LEARNINGS.md and workspace resource proposals queue an approval request when approval policy is enabled.", + authorize: authorizeDreamProposalMutation, schema: z.object({ id: z.string().min(1).describe("Dream proposal id."), }), diff --git a/packages/dispatch/src/actions/approve-dispatch-change.ts b/packages/dispatch/src/actions/approve-dispatch-change.ts index 7876da68321..99622cd5c69 100644 --- a/packages/dispatch/src/actions/approve-dispatch-change.ts +++ b/packages/dispatch/src/actions/approve-dispatch-change.ts @@ -1,10 +1,12 @@ import { defineAction } from "@agent-native/core/action"; import { z } from "zod"; +import { authorizeDispatchAdmin } from "../server/lib/app-roles.js"; import { approveRequest } from "../server/lib/dispatch-store.js"; export default defineAction({ description: "Approve a pending dispatch change request and apply it.", + authorize: authorizeDispatchAdmin, schema: z.object({ id: z.string().describe("Approval request id"), }), diff --git a/packages/dispatch/src/actions/create-pylon-ticket.ts b/packages/dispatch/src/actions/create-pylon-ticket.ts index 2720d82dedf..134bcbf6adf 100644 --- a/packages/dispatch/src/actions/create-pylon-ticket.ts +++ b/packages/dispatch/src/actions/create-pylon-ticket.ts @@ -2,6 +2,7 @@ import { defineAction } from "@agent-native/core/action"; import { resolveSecret } from "@agent-native/core/server"; import { z } from "zod"; +import { authorizeDispatchAdmin } from "../server/lib/app-roles.js"; import { recordAudit } from "../server/lib/dispatch-store.js"; const PYLON_API_BASE = @@ -10,6 +11,7 @@ const PYLON_API_BASE = export default defineAction({ description: "Create a Pylon ticket. Use to escalate blockers from client meetings, route unmatched #customer-* posts that have no Slack channel, or open a follow-up that needs tracking. Requires PYLON_API_KEY in the Vault.", + authorize: authorizeDispatchAdmin, schema: z.object({ title: z.string().min(1).describe("Short ticket title"), bodyHtml: z diff --git a/packages/dispatch/src/actions/create-workspace-resource-grant.ts b/packages/dispatch/src/actions/create-workspace-resource-grant.ts index c4680dd3b13..2443f826798 100644 --- a/packages/dispatch/src/actions/create-workspace-resource-grant.ts +++ b/packages/dispatch/src/actions/create-workspace-resource-grant.ts @@ -1,11 +1,13 @@ import { defineAction } from "@agent-native/core/action"; import { z } from "zod"; +import { authorizeDispatchAdmin } from "../server/lib/app-roles.js"; import { createResourceGrant } from "../server/lib/workspace-resources-store.js"; export default defineAction({ description: "Grant an app access to a workspace resource (skill, instruction, agent, knowledge pack, or MCP server). Admin only.", + authorize: authorizeDispatchAdmin, schema: z.object({ resourceId: z.string().describe("Workspace resource ID"), appId: z diff --git a/packages/dispatch/src/actions/create-workspace-resource.ts b/packages/dispatch/src/actions/create-workspace-resource.ts index 09ab117ac9d..cfe0571884b 100644 --- a/packages/dispatch/src/actions/create-workspace-resource.ts +++ b/packages/dispatch/src/actions/create-workspace-resource.ts @@ -1,11 +1,13 @@ import { defineAction } from "@agent-native/core/action"; import { z } from "zod"; +import { authorizeDispatchAdmin } from "../server/lib/app-roles.js"; import { createWorkspaceResource } from "../server/lib/workspace-resources-store.js"; export default defineAction({ description: 'Create a workspace-wide skill, instruction, agent profile, reference resource, or MCP server. Set scope to "all" for runtime inheritance by every app, or "selected" to grant per-app. When Dispatch approval policy is enabled, All-app creates queue an approval request before taking effect.', + authorize: authorizeDispatchAdmin, schema: z.object({ kind: z .enum([ diff --git a/packages/dispatch/src/actions/delete-destination.ts b/packages/dispatch/src/actions/delete-destination.ts index 1806c943878..26a40f56796 100644 --- a/packages/dispatch/src/actions/delete-destination.ts +++ b/packages/dispatch/src/actions/delete-destination.ts @@ -1,10 +1,12 @@ import { defineAction } from "@agent-native/core/action"; import { z } from "zod"; +import { authorizeDispatchAdmin } from "../server/lib/app-roles.js"; import { deleteDestination } from "../server/lib/dispatch-store.js"; export default defineAction({ description: "Delete a saved dispatch destination.", + authorize: authorizeDispatchAdmin, schema: z.object({ id: z.string().describe("Destination id"), }), diff --git a/packages/dispatch/src/actions/delete-workspace-resource.ts b/packages/dispatch/src/actions/delete-workspace-resource.ts index 753ed232cde..ecb1fc3f0b0 100644 --- a/packages/dispatch/src/actions/delete-workspace-resource.ts +++ b/packages/dispatch/src/actions/delete-workspace-resource.ts @@ -1,11 +1,13 @@ import { defineAction } from "@agent-native/core/action"; import { z } from "zod"; +import { authorizeDispatchAdmin } from "../server/lib/app-roles.js"; import { deleteWorkspaceResource } from "../server/lib/workspace-resources-store.js"; export default defineAction({ description: "Delete a workspace resource and revoke all its grants. When Dispatch approval policy is enabled, deleting an All-app resource queues an approval request before taking effect.", + authorize: authorizeDispatchAdmin, schema: z.object({ id: z.string().describe("Resource ID to delete"), }), diff --git a/packages/dispatch/src/actions/dispatch-admin-authorization.spec.ts b/packages/dispatch/src/actions/dispatch-admin-authorization.spec.ts new file mode 100644 index 00000000000..aee1db3afdf --- /dev/null +++ b/packages/dispatch/src/actions/dispatch-admin-authorization.spec.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + assertAny: vi.fn(), + deleteDestination: vi.fn(), + getDestinationById: vi.fn(), + resolveSecret: vi.fn(), + upsertDestination: vi.fn(), + validateFederatedOrganizationMembershipForCurrentRequest: vi.fn(), +})); + +vi.mock("@agent-native/core/org", () => ({ + defineAppRoles: () => ({ assertAny: mocks.assertAny }), + validateFederatedOrganizationMembershipForCurrentRequest: + mocks.validateFederatedOrganizationMembershipForCurrentRequest, +})); + +vi.mock("@agent-native/core/server", () => ({ + getRequestOrgId: () => "org-a", + getRequestUserEmail: () => "member@example.com", + resolveSecret: mocks.resolveSecret, +})); + +vi.mock("@agent-native/core/integrations", () => ({ + listIntegrationInstallations: vi.fn(), + resolveIntegrationTokenBundle: vi.fn(), +})); + +vi.mock("../server/lib/dispatch-store.js", () => ({ + deleteDestination: mocks.deleteDestination, + getDestinationById: mocks.getDestinationById, + recordAudit: vi.fn(), + upsertDestination: mocks.upsertDestination, +})); + +import { ForbiddenError } from "@agent-native/core/sharing"; + +const sendPlatformMessage = (await import("./send-platform-message.js")) + .default; +const createPylonTicket = (await import("./create-pylon-ticket.js")).default; +const deleteDestination = (await import("./delete-destination.js")).default; +const upsertDestination = (await import("./upsert-destination.js")).default; + +const memberContext = { + caller: "http" as const, + orgId: "org-a", + userEmail: "member@example.com", +}; + +describe("Dispatch admin authorization", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.validateFederatedOrganizationMembershipForCurrentRequest.mockResolvedValue( + { active: true, role: "member" }, + ); + mocks.assertAny.mockRejectedValue( + new ForbiddenError("Requires dispatch role admin"), + ); + }); + + it("denies organization members before sending a platform message", async () => { + await expect( + sendPlatformMessage.run( + { + platform: "slack", + destination: "C1", + text: "hello", + }, + memberContext, + ), + ).rejects.toThrow("Requires dispatch role admin"); + + expect(mocks.getDestinationById).not.toHaveBeenCalled(); + expect(mocks.resolveSecret).not.toHaveBeenCalled(); + }); + + it("denies organization members before creating a Pylon ticket", async () => { + await expect( + createPylonTicket.run( + { + title: "Follow-up", + bodyHtml: "

hello

", + requesterEmail: "requester@example.com", + }, + memberContext, + ), + ).rejects.toThrow("Requires dispatch role admin"); + + expect(mocks.resolveSecret).not.toHaveBeenCalled(); + }); + + it("denies organization members before changing a destination", async () => { + await expect( + upsertDestination.run( + { + name: "Support", + platform: "slack", + destination: "C1", + }, + memberContext, + ), + ).rejects.toThrow("Requires dispatch role admin"); + + expect(mocks.upsertDestination).not.toHaveBeenCalled(); + }); + + it("denies organization members before deleting a destination", async () => { + await expect( + deleteDestination.run({ id: "destination-1" }, memberContext), + ).rejects.toThrow("Requires dispatch role admin"); + + expect(mocks.deleteDestination).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/dispatch/src/actions/ensure-dream-job.ts b/packages/dispatch/src/actions/ensure-dream-job.ts index e66eb15fb94..bc63ff0c05e 100644 --- a/packages/dispatch/src/actions/ensure-dream-job.ts +++ b/packages/dispatch/src/actions/ensure-dream-job.ts @@ -1,11 +1,13 @@ import { defineAction } from "@agent-native/core/action"; import { z } from "zod"; +import { authorizeDispatchAdmin } from "../server/lib/app-roles.js"; import { ensureDreamJob } from "../server/lib/dreams-store.js"; export default defineAction({ description: "Create or update the personal recurring Dispatch dream job resource at jobs/dispatch-dream.md.", + authorize: authorizeDispatchAdmin, schema: z.object({ schedule: z .string() diff --git a/packages/dispatch/src/actions/grant-workspace-resources-to-app.ts b/packages/dispatch/src/actions/grant-workspace-resources-to-app.ts index 2d6979bc384..7629d6aad70 100644 --- a/packages/dispatch/src/actions/grant-workspace-resources-to-app.ts +++ b/packages/dispatch/src/actions/grant-workspace-resources-to-app.ts @@ -1,11 +1,13 @@ import { defineAction } from "@agent-native/core/action"; import { z } from "zod"; +import { authorizeDispatchAdmin } from "../server/lib/app-roles.js"; import { grantWorkspaceResourcesToApp } from "../server/lib/workspace-resources-store.js"; export default defineAction({ description: "Grant several selected workspace resources, knowledge packs, or MCP servers to an app, skipping existing active grants.", + authorize: authorizeDispatchAdmin, schema: z.object({ appId: z.string().describe("App ID receiving the resources"), resourceIds: z diff --git a/packages/dispatch/src/actions/import-agent-pack.ts b/packages/dispatch/src/actions/import-agent-pack.ts index ee47d258716..58a7b5e575f 100644 --- a/packages/dispatch/src/actions/import-agent-pack.ts +++ b/packages/dispatch/src/actions/import-agent-pack.ts @@ -13,6 +13,7 @@ import { } from "../lib/agent-pack.js"; import { validateImportedAgentTools } from "../lib/simple-agent-profile.js"; import { applyAgentPackCreate } from "../server/lib/agent-pack-store.js"; +import { authorizeDispatchAdmin } from "../server/lib/app-roles.js"; import { createApprovalRequest, getApprovalPolicy, @@ -60,6 +61,7 @@ function availableToolNames(dispatchToolNames: string[]): Set { export default defineAction({ description: "Import a folder-backed agent pack from Claude, Cowork, or another agent tool. The pack can include a Markdown/JSON profile, context, references, and skills. Credentials, hooks, shell commands, and local environment settings are never imported.", + authorize: authorizeDispatchAdmin, schema, run: async ({ files, scope }) => { const normalized = normalizeAgentPack(files as AgentPackFileInput[]); diff --git a/packages/dispatch/src/actions/import-agent.ts b/packages/dispatch/src/actions/import-agent.ts index 9c16110939f..d4a46d79dcf 100644 --- a/packages/dispatch/src/actions/import-agent.ts +++ b/packages/dispatch/src/actions/import-agent.ts @@ -8,6 +8,7 @@ import { normalizeImportedAgent, validateImportedAgentTools, } from "../lib/simple-agent-profile.js"; +import { authorizeDispatchAdmin } from "../server/lib/app-roles.js"; import { createWorkspaceResource, listWorkspaceResources, @@ -16,6 +17,7 @@ import { export default defineAction({ description: "Import a Claude-style Markdown agent or a generic JSON agent definition into a reusable Dispatch agent profile. Credentials, shell commands, hooks, and local environment settings are never imported. Use connect-external-agent for an HTTP/A2A endpoint.", + authorize: authorizeDispatchAdmin, schema: z.object({ source: z .string() diff --git a/packages/dispatch/src/actions/reject-dispatch-change.ts b/packages/dispatch/src/actions/reject-dispatch-change.ts index 4a78f0a57a5..a32be038016 100644 --- a/packages/dispatch/src/actions/reject-dispatch-change.ts +++ b/packages/dispatch/src/actions/reject-dispatch-change.ts @@ -1,10 +1,12 @@ import { defineAction } from "@agent-native/core/action"; import { z } from "zod"; +import { authorizeDispatchAdmin } from "../server/lib/app-roles.js"; import { rejectRequest } from "../server/lib/dispatch-store.js"; export default defineAction({ description: "Reject a pending dispatch change request.", + authorize: authorizeDispatchAdmin, schema: z.object({ id: z.string().describe("Approval request id"), reason: z.string().optional().describe("Optional rejection reason"), diff --git a/packages/dispatch/src/actions/reject-dream-proposal.ts b/packages/dispatch/src/actions/reject-dream-proposal.ts index 736f011e9e9..d98530e2f8e 100644 --- a/packages/dispatch/src/actions/reject-dream-proposal.ts +++ b/packages/dispatch/src/actions/reject-dream-proposal.ts @@ -1,10 +1,14 @@ import { defineAction } from "@agent-native/core/action"; import { z } from "zod"; -import { rejectDreamProposal } from "../server/lib/dreams-store.js"; +import { + authorizeDreamProposalMutation, + rejectDreamProposal, +} from "../server/lib/dreams-store.js"; export default defineAction({ description: "Reject one pending Dispatch dream proposal.", + authorize: authorizeDreamProposalMutation, schema: z.object({ id: z.string().min(1).describe("Dream proposal id."), reason: z.string().optional().describe("Optional rejection reason."), diff --git a/packages/dispatch/src/actions/restore-starter-workspace-resources.ts b/packages/dispatch/src/actions/restore-starter-workspace-resources.ts index de4dbe6e60b..e080be54793 100644 --- a/packages/dispatch/src/actions/restore-starter-workspace-resources.ts +++ b/packages/dispatch/src/actions/restore-starter-workspace-resources.ts @@ -1,11 +1,13 @@ import { defineAction } from "@agent-native/core/action"; import { z } from "zod"; +import { authorizeDispatchAdmin } from "../server/lib/app-roles.js"; import { restoreStarterWorkspaceResources } from "../server/lib/workspace-resources-store.js"; export default defineAction({ description: "Restore missing starter global workspace resources such as company, brand, messaging, guardrails, and company voice. Existing resources are left unchanged.", + authorize: authorizeDispatchAdmin, schema: z.object({ paths: z .array(z.string()) diff --git a/packages/dispatch/src/actions/revoke-workspace-resource-grant.ts b/packages/dispatch/src/actions/revoke-workspace-resource-grant.ts index 58c688b941a..fd3ac629000 100644 --- a/packages/dispatch/src/actions/revoke-workspace-resource-grant.ts +++ b/packages/dispatch/src/actions/revoke-workspace-resource-grant.ts @@ -1,6 +1,7 @@ import { defineAction } from "@agent-native/core/action"; import { z } from "zod"; +import { authorizeDispatchAdmin } from "../server/lib/app-roles.js"; import { requireWorkspaceResourceCtx, revokeResourceGrant, @@ -8,6 +9,7 @@ import { export default defineAction({ description: "Revoke an app's access to a workspace resource. Admin only.", + authorize: authorizeDispatchAdmin, schema: z.object({ grantId: z.string().describe("Grant ID to revoke"), }), diff --git a/packages/dispatch/src/actions/send-platform-message.spec.ts b/packages/dispatch/src/actions/send-platform-message.spec.ts index c10bc025ad1..096f240cc6e 100644 --- a/packages/dispatch/src/actions/send-platform-message.spec.ts +++ b/packages/dispatch/src/actions/send-platform-message.spec.ts @@ -28,6 +28,10 @@ vi.mock("../server/lib/dispatch-store.js", () => ({ getDestinationById: mocks.getDestinationById, })); +vi.mock("../server/lib/app-roles.js", () => ({ + authorizeDispatchAdmin: vi.fn(), +})); + const action = (await import("./send-platform-message.js")).default; describe("send-platform-message tenant scoping", () => { diff --git a/packages/dispatch/src/actions/send-platform-message.ts b/packages/dispatch/src/actions/send-platform-message.ts index 1412bab100f..18959f6bc3a 100644 --- a/packages/dispatch/src/actions/send-platform-message.ts +++ b/packages/dispatch/src/actions/send-platform-message.ts @@ -14,6 +14,7 @@ import { } from "@agent-native/core/server"; import { z } from "zod"; +import { authorizeDispatchAdmin } from "../server/lib/app-roles.js"; import { getDestinationById } from "../server/lib/dispatch-store.js"; function getAdapter( @@ -78,6 +79,7 @@ async function assertOutboundConfigured( export default defineAction({ description: "Send a proactive message to a saved Slack, Telegram, or email destination.", + authorize: authorizeDispatchAdmin, schema: z.object({ platform: z.enum(["slack", "telegram", "email"]).optional(), destinationId: z.string().optional().describe("Saved destination id"), diff --git a/packages/dispatch/src/actions/set-dispatch-approval-policy.ts b/packages/dispatch/src/actions/set-dispatch-approval-policy.ts index fca43941411..6729dbf6f95 100644 --- a/packages/dispatch/src/actions/set-dispatch-approval-policy.ts +++ b/packages/dispatch/src/actions/set-dispatch-approval-policy.ts @@ -1,6 +1,7 @@ import { defineAction } from "@agent-native/core/action"; import { z } from "zod"; +import { authorizeDispatchAdmin } from "../server/lib/app-roles.js"; import { getApprovalPolicy, setApprovalPolicy, @@ -8,6 +9,7 @@ import { export default defineAction({ description: "Enable or disable dispatch approval flow for durable changes.", + authorize: authorizeDispatchAdmin, schema: z.object({ enabled: z.boolean(), approverEmails: z.array(z.string().email()).default([]), diff --git a/packages/dispatch/src/actions/set-dream-settings.spec.ts b/packages/dispatch/src/actions/set-dream-settings.spec.ts index 9c9c67082ef..a5bb61304e8 100644 --- a/packages/dispatch/src/actions/set-dream-settings.spec.ts +++ b/packages/dispatch/src/actions/set-dream-settings.spec.ts @@ -1,9 +1,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ + authorizeDispatchAdmin: vi.fn(), setDreamSettings: vi.fn(), })); +vi.mock("../server/lib/app-roles.js", () => ({ + authorizeDispatchAdmin: mocks.authorizeDispatchAdmin, +})); + vi.mock("../server/lib/dreams-store.js", () => ({ setDreamSettings: mocks.setDreamSettings, })); diff --git a/packages/dispatch/src/actions/set-dream-settings.ts b/packages/dispatch/src/actions/set-dream-settings.ts index c0f63f1350d..0cab49825cd 100644 --- a/packages/dispatch/src/actions/set-dream-settings.ts +++ b/packages/dispatch/src/actions/set-dream-settings.ts @@ -1,11 +1,13 @@ import { defineAction } from "@agent-native/core/action"; import { z } from "zod"; +import { authorizeDispatchAdmin } from "../server/lib/app-roles.js"; import { setDreamSettings } from "../server/lib/dreams-store.js"; export default defineAction({ description: "Update recurring Dispatch dream settings without immediately running or applying a dream pass.", + authorize: authorizeDispatchAdmin, schema: z.object({ enabled: z .boolean() diff --git a/packages/dispatch/src/actions/update-workspace-resource.ts b/packages/dispatch/src/actions/update-workspace-resource.ts index 1d3287efc50..e48846dc18b 100644 --- a/packages/dispatch/src/actions/update-workspace-resource.ts +++ b/packages/dispatch/src/actions/update-workspace-resource.ts @@ -1,11 +1,13 @@ import { defineAction } from "@agent-native/core/action"; import { z } from "zod"; +import { authorizeDispatchAdmin } from "../server/lib/app-roles.js"; import { updateWorkspaceResource } from "../server/lib/workspace-resources-store.js"; export default defineAction({ description: "Update a workspace resource's name, description, content, or scope. When Dispatch approval policy is enabled, changes that affect All-app resources queue an approval request before taking effect.", + authorize: authorizeDispatchAdmin, schema: z.object({ id: z.string().describe("Resource ID"), name: z.string().optional().describe("New name"), diff --git a/packages/dispatch/src/actions/upsert-destination.ts b/packages/dispatch/src/actions/upsert-destination.ts index 01f24771e1a..7c9afb0af00 100644 --- a/packages/dispatch/src/actions/upsert-destination.ts +++ b/packages/dispatch/src/actions/upsert-destination.ts @@ -1,11 +1,13 @@ import { defineAction } from "@agent-native/core/action"; import { z } from "zod"; +import { authorizeDispatchAdmin } from "../server/lib/app-roles.js"; import { upsertDestination } from "../server/lib/dispatch-store.js"; export default defineAction({ description: "Create or update a saved messaging destination for proactive Slack, Telegram, or email sends.", + authorize: authorizeDispatchAdmin, schema: z.object({ id: z.string().optional(), name: z.string().describe("Friendly destination name"), diff --git a/packages/dispatch/src/components/dispatch-access.spec.tsx b/packages/dispatch/src/components/dispatch-access.spec.tsx index 3d12b16e322..bd3381cc5db 100644 --- a/packages/dispatch/src/components/dispatch-access.spec.tsx +++ b/packages/dispatch/src/components/dispatch-access.spec.tsx @@ -4,19 +4,6 @@ import React, { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const mocks = vi.hoisted(() => ({ - useOrgRole: vi.fn(), -})); - -vi.mock("@agent-native/core/client/org", async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - useOrgRole: mocks.useOrgRole, - }; -}); - import { RequireDispatchAccess } from "./dispatch-access.js"; describe("RequireDispatchAccess", () => { @@ -33,40 +20,10 @@ describe("RequireDispatchAccess", () => { afterEach(() => { act(() => root.unmount()); container.remove(); - mocks.useOrgRole.mockReset(); vi.unstubAllGlobals(); }); - it.each(["owner", "admin"] as const)( - "renders the Dispatch shell for an organization %s", - (role) => { - mocks.useOrgRole.mockReturnValue({ - org: { orgId: "org-1" }, - role, - isLoading: false, - error: null, - }); - - act(() => { - root.render( - -
Dispatch shell
-
, - ); - }); - - expect(container.querySelector("[data-dispatch-shell]")).not.toBeNull(); - }, - ); - - it("renders the Dispatch shell without an active organization", () => { - mocks.useOrgRole.mockReturnValue({ - org: undefined, - role: null, - isLoading: false, - error: null, - }); - + it("renders the Dispatch shell for every authenticated user", () => { act(() => { root.render( @@ -77,23 +34,4 @@ describe("RequireDispatchAccess", () => { expect(container.querySelector("[data-dispatch-shell]")).not.toBeNull(); }); - - it("does not render the Dispatch shell for an organization member", () => { - mocks.useOrgRole.mockReturnValue({ - org: { orgId: "org-1" }, - role: "member", - isLoading: false, - error: null, - }); - - act(() => { - root.render( - -
Dispatch shell
-
, - ); - }); - - expect(container.querySelector("[data-dispatch-shell]")).toBeNull(); - }); }); diff --git a/packages/dispatch/src/components/dispatch-access.tsx b/packages/dispatch/src/components/dispatch-access.tsx index 512f3bae8aa..31ab99ba762 100644 --- a/packages/dispatch/src/components/dispatch-access.tsx +++ b/packages/dispatch/src/components/dispatch-access.tsx @@ -1,13 +1,5 @@ -import { canManageOrg, useOrgRole } from "@agent-native/core/client/org"; -import { DefaultSpinner } from "@agent-native/core/client/ui"; import { type ReactNode } from "react"; export function RequireDispatchAccess({ children }: { children: ReactNode }) { - const { org, role, isLoading, error } = useOrgRole(); - - if (isLoading) return ; - if (error) return null; - if (org?.orgId && !canManageOrg(role)) return null; - return <>{children}; } diff --git a/packages/dispatch/src/server/lib/app-creation-store.spec.ts b/packages/dispatch/src/server/lib/app-creation-store.spec.ts index 75c4c4016e5..8c80e5c329c 100644 --- a/packages/dispatch/src/server/lib/app-creation-store.spec.ts +++ b/packages/dispatch/src/server/lib/app-creation-store.spec.ts @@ -35,6 +35,7 @@ const mocks = vi.hoisted(() => { settings.set(key, value); }), getOrgSetting: vi.fn(async () => null), + isWorkspaceAppAccessAllowed: vi.fn(async () => true), resolveAccess: vi.fn(async () => ({ role: "viewer", resource: {}, @@ -106,12 +107,8 @@ vi.mock("@agent-native/core/org", async (importOriginal) => { await importOriginal(); return { ...actual, - isWorkspaceAppAccessAllowed: vi.fn( - async (_appId: string, context: { orgId?: string | null } = {}) => - !context.orgId || - mocks.state.orgRole === "owner" || - mocks.state.orgRole === "admin", - ), + isWorkspaceAppAccessAllowed: (...args: any[]) => + mocks.isWorkspaceAppAccessAllowed(...args), }; }); @@ -158,6 +155,8 @@ afterEach(() => { mocks.settings.clear(); mocks.getOrgSetting.mockReset(); mocks.getOrgSetting.mockResolvedValue(null); + mocks.isWorkspaceAppAccessAllowed.mockReset(); + mocks.isWorkspaceAppAccessAllowed.mockResolvedValue(true); mocks.mutateSetting.mockReset(); mocks.mutateSetting.mockImplementation( async (key: string, updater: (current: any) => any) => { @@ -296,22 +295,6 @@ describe("listWorkspaceApps", () => { }; } - it.each(["owner", "admin", "member"] as const)( - "applies the organization role gate to Dispatch registry links for %s", - async (role) => { - stubNoPendingContext(); - stubManifest(); - mocks.state.orgRole = role; - - const apps = await runWithRequestContext( - { userEmail: `${role}@example.test`, orgId: "org-123" }, - () => listWorkspaceApps({ includeAgentCards: false }), - ); - - expect(apps.some((app) => app.id === "dispatch")).toBe(role !== "member"); - }, - ); - it("prefers the live workspace gateway manifest when available", async () => { const fetchMock = vi.fn(async () => { return new Response( @@ -598,6 +581,22 @@ describe("listWorkspaceApps", () => { expect(apps.map((app) => app.id)).toEqual(["dispatch"]); }); + it("does not expose Dispatch after federated membership is revoked", async () => { + stubManifest(); + mocks.isWorkspaceAppAccessAllowed.mockResolvedValueOnce(false); + + const apps = await runWithRequestContext( + { userEmail: "member@example.test", orgId: "org-123" }, + () => listWorkspaceApps({ includeAgentCards: false }), + ); + + expect(apps).toEqual([]); + expect(mocks.isWorkspaceAppAccessAllowed).toHaveBeenCalledWith("dispatch", { + email: "member@example.test", + orgId: "org-123", + }); + }); + it("does not expose the workspace app registry without an authenticated user", async () => { stubNoPendingContext(); stubManifest([ diff --git a/packages/dispatch/src/server/lib/app-creation-store.ts b/packages/dispatch/src/server/lib/app-creation-store.ts index 006b5f2ff92..5649ecc6724 100644 --- a/packages/dispatch/src/server/lib/app-creation-store.ts +++ b/packages/dispatch/src/server/lib/app-creation-store.ts @@ -1450,7 +1450,7 @@ async function filterWorkspaceAppsByAccess( } if (app.isDispatch) { if ( - await isWorkspaceAppAccessAllowed(app.id, { + await isWorkspaceAppAccessAllowed("dispatch", { email: userEmail, orgId, }) diff --git a/packages/dispatch/src/server/lib/app-roles.spec.ts b/packages/dispatch/src/server/lib/app-roles.spec.ts new file mode 100644 index 00000000000..f877d9a7a12 --- /dev/null +++ b/packages/dispatch/src/server/lib/app-roles.spec.ts @@ -0,0 +1,120 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + assertAny: vi.fn(), + getRequestOrgId: vi.fn(), + getRequestUserEmail: vi.fn(), + isStandaloneDispatchRuntime: vi.fn(), + validateFederatedOrganizationMembershipForCurrentRequest: vi.fn(), +})); + +vi.mock("@agent-native/core/org", () => ({ + defineAppRoles: () => ({ assertAny: mocks.assertAny }), + isMissingOrganizationTableError: (error: unknown) => + /(?:organizations|org_members).*does not exist/i.test(String(error)), + isStandaloneDispatchRuntime: mocks.isStandaloneDispatchRuntime, + validateFederatedOrganizationMembershipForCurrentRequest: + mocks.validateFederatedOrganizationMembershipForCurrentRequest, +})); + +vi.mock("@agent-native/core/server", () => ({ + getRequestOrgId: mocks.getRequestOrgId, + getRequestUserEmail: mocks.getRequestUserEmail, +})); + +import { ForbiddenError } from "@agent-native/core/sharing"; + +import { authorizeDispatchAdmin } from "./app-roles.js"; + +const context = { + caller: "http" as const, + orgId: "org-1", + userEmail: "member@example.test", +}; + +describe("authorizeDispatchAdmin", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.validateFederatedOrganizationMembershipForCurrentRequest.mockResolvedValue( + { active: true, role: "member" }, + ); + mocks.assertAny.mockResolvedValue("admin"); + mocks.getRequestOrgId.mockReturnValue(undefined); + mocks.getRequestUserEmail.mockReturnValue(undefined); + mocks.isStandaloneDispatchRuntime.mockReturnValue(false); + }); + + it("denies an organization member without the Dispatch admin role", async () => { + mocks.assertAny.mockRejectedValue( + new ForbiddenError("Requires dispatch role admin"), + ); + + await expect(authorizeDispatchAdmin({}, context)).rejects.toThrow( + "Requires dispatch role admin", + ); + expect( + mocks.validateFederatedOrganizationMembershipForCurrentRequest, + ).toHaveBeenCalledWith({ + orgId: "org-1", + email: "member@example.test", + }); + expect(mocks.assertAny).toHaveBeenCalledWith(["admin"], { + orgId: "org-1", + userEmail: "member@example.test", + }); + }); + + it("allows an organization admin without an app-role assignment", async () => { + mocks.validateFederatedOrganizationMembershipForCurrentRequest.mockResolvedValue( + { active: true, role: "admin" }, + ); + + await expect(authorizeDispatchAdmin({}, context)).resolves.toBeUndefined(); + expect(mocks.assertAny).not.toHaveBeenCalled(); + }); + + it("denies a stale linked organization admin", async () => { + mocks.validateFederatedOrganizationMembershipForCurrentRequest.mockResolvedValue( + { active: false, role: null }, + ); + + await expect(authorizeDispatchAdmin({}, context)).rejects.toThrow( + "active organization membership", + ); + expect(mocks.assertAny).not.toHaveBeenCalled(); + }); + + it("allows standalone administration when the org schema is absent", async () => { + mocks.isStandaloneDispatchRuntime.mockReturnValue(true); + mocks.validateFederatedOrganizationMembershipForCurrentRequest.mockRejectedValue( + new Error('relation "org_members" does not exist'), + ); + + await expect(authorizeDispatchAdmin({}, context)).resolves.toBeUndefined(); + expect(mocks.assertAny).not.toHaveBeenCalled(); + }); + + it("allows a member with the Dispatch admin role", async () => { + await expect(authorizeDispatchAdmin({}, context)).resolves.toBeUndefined(); + expect(mocks.assertAny).toHaveBeenCalledWith(["admin"], { + orgId: "org-1", + userEmail: "member@example.test", + }); + }); + + it("allows authenticated personal-mode administration", async () => { + await expect( + authorizeDispatchAdmin({}, { ...context, orgId: null }), + ).resolves.toBeUndefined(); + expect( + mocks.validateFederatedOrganizationMembershipForCurrentRequest, + ).not.toHaveBeenCalled(); + expect(mocks.assertAny).not.toHaveBeenCalled(); + }); + + it("denies an unauthenticated caller", async () => { + await expect( + authorizeDispatchAdmin({}, { ...context, userEmail: undefined }), + ).rejects.toBeInstanceOf(ForbiddenError); + }); +}); diff --git a/packages/dispatch/src/server/lib/app-roles.ts b/packages/dispatch/src/server/lib/app-roles.ts new file mode 100644 index 00000000000..5c4dbbcce35 --- /dev/null +++ b/packages/dispatch/src/server/lib/app-roles.ts @@ -0,0 +1,67 @@ +import type { ActionRunContext } from "@agent-native/core/action"; +import { + defineAppRoles, + isMissingOrganizationTableError, + isStandaloneDispatchRuntime, + type AppRoles, + validateFederatedOrganizationMembershipForCurrentRequest, +} from "@agent-native/core/org"; +import { + getRequestOrgId, + getRequestUserEmail, +} from "@agent-native/core/server"; +import { ForbiddenError } from "@agent-native/core/sharing"; + +import { dispatchAccessDescriptor } from "../../shared/app-roles.js"; + +let dispatchAccess: AppRoles<"admin"> | undefined; + +function getDispatchAccess(): AppRoles<"admin"> { + return (dispatchAccess ??= defineAppRoles(dispatchAccessDescriptor)); +} + +/** + * Keep the Dispatch shell open to every signed-in member while protecting + * workspace-wide administration operations for org or Dispatch admins. + */ +export async function authorizeDispatchAdmin( + _args: unknown, + ctx?: ActionRunContext, +): Promise { + const email = + ctx?.userEmail !== undefined ? ctx.userEmail : getRequestUserEmail(); + const orgId = ctx?.orgId !== undefined ? ctx.orgId : getRequestOrgId(); + if (!email?.trim()) { + throw new ForbiddenError( + "Dispatch administration requires an authenticated user.", + ); + } + if (!orgId?.trim()) return; + let membership; + try { + membership = await validateFederatedOrganizationMembershipForCurrentRequest( + { + orgId, + email, + }, + ); + } catch (error) { + if ( + isMissingOrganizationTableError(error) && + isStandaloneDispatchRuntime() + ) { + return; + } + throw error; + } + if (!membership.active) { + throw new ForbiddenError( + "Dispatch administration requires active organization membership.", + ); + } + if (membership.role === "owner" || membership.role === "admin") return; + await getDispatchAccess().assertAny(["admin"], { + userEmail: email, + orgId, + }); +} diff --git a/packages/dispatch/src/server/lib/approval-fencing.spec.ts b/packages/dispatch/src/server/lib/approval-fencing.spec.ts index 7df238bdbac..a6d79ef18be 100644 --- a/packages/dispatch/src/server/lib/approval-fencing.spec.ts +++ b/packages/dispatch/src/server/lib/approval-fencing.spec.ts @@ -340,6 +340,45 @@ describe("dispatch approval request status fencing", () => { }); }); + it("does not let the same email approve a request from another organization", async () => { + const [{ runWithRequestContext }, { getDbExec }, dispatchStore] = + await Promise.all([ + import("@agent-native/core/server"), + import("@agent-native/core/db"), + import("./dispatch-store.js"), + ]); + const exec = getDbExec(); + + const requestId = await runWithRequestContext( + { userEmail: ownerEmail, orgId }, + async () => { + const created = await dispatchStore.createApprovalRequest({ + changeType: "approval-policy.update", + targetType: "dispatch-settings", + targetId: "dispatch-approval-policy", + summary: "Keep this request in its organization", + payload: { enabled: true, approverEmails: [] }, + }); + return (created as any).id as string; + }, + ); + + await runWithRequestContext( + { userEmail: ownerEmail, orgId: otherOrgId }, + async () => { + await expect(dispatchStore.approveRequest(requestId)).rejects.toThrow( + "Approval request not found", + ); + }, + ); + + const rows = await exec.execute({ + sql: "SELECT status FROM dispatch_approval_requests WHERE id = ?", + args: [requestId], + }); + expect(rows.rows[0]).toMatchObject({ status: "pending" }); + }); + it("does not let a caller from a different tenant reject another tenant's request", async () => { const [{ runWithRequestContext }, { getDbExec }, dispatchStore] = await Promise.all([ diff --git a/packages/dispatch/src/server/lib/dispatch-store.ts b/packages/dispatch/src/server/lib/dispatch-store.ts index 07ee2e6f16d..2857c9d7033 100644 --- a/packages/dispatch/src/server/lib/dispatch-store.ts +++ b/packages/dispatch/src/server/lib/dispatch-store.ts @@ -157,7 +157,10 @@ function ctxScope( if (!ctx.orgId) { return and(eq(table.ownerEmail, ctx.ownerEmail), isNull(table.orgId)); } - return or(eq(table.ownerEmail, ctx.ownerEmail), eq(table.orgId, ctx.orgId)); + return or( + and(eq(table.ownerEmail, ctx.ownerEmail), isNull(table.orgId)), + eq(table.orgId, ctx.orgId), + ); } function id() { diff --git a/packages/dispatch/src/server/lib/dreams-store.spec.ts b/packages/dispatch/src/server/lib/dreams-store.spec.ts index b0317c1e59b..20e88fd3187 100644 --- a/packages/dispatch/src/server/lib/dreams-store.spec.ts +++ b/packages/dispatch/src/server/lib/dreams-store.spec.ts @@ -4,6 +4,7 @@ const mocks = vi.hoisted(() => ({ getDb: vi.fn(), currentOwnerEmail: vi.fn(() => "owner@example.test"), currentOrgId: vi.fn(() => null), + authorizeDispatchAdmin: vi.fn(), getApprovalPolicy: vi.fn(), createApprovalRequest: vi.fn(), recordAudit: vi.fn(), @@ -42,6 +43,10 @@ vi.mock("./dispatch-store.js", () => ({ recordAudit: mocks.recordAudit, })); +vi.mock("./app-roles.js", () => ({ + authorizeDispatchAdmin: mocks.authorizeDispatchAdmin, +})); + vi.mock("./thread-debug-store.js", () => ({ searchAgentThreads: mocks.searchAgentThreads, getAgentThreadDebug: mocks.getAgentThreadDebug, @@ -72,6 +77,7 @@ import { schema } from "../../db/index.js"; import { applyApprovedDreamProposal, applyDreamProposal, + authorizeDreamProposalMutation, buildProposalInputs, ensureDreamJob, getDreamSettings, @@ -301,6 +307,78 @@ afterEach(() => { vi.clearAllMocks(); }); +describe("authorizeDreamProposalMutation", () => { + it("allows an owner to change a personal-memory proposal", async () => { + mocks.getDb.mockReturnValue(createDbMock(pendingProposal())); + + await expect( + authorizeDreamProposalMutation( + { id: "proposal-1" }, + { + caller: "http", + orgId: null, + userEmail: "owner@example.test", + }, + ), + ).resolves.toBeUndefined(); + expect(mocks.authorizeDispatchAdmin).not.toHaveBeenCalled(); + }); + + it("requires Dispatch admin authorization for shared proposals", async () => { + mocks.getDb.mockReturnValue( + createDbMock( + pendingProposal({ + orgId: "org-1", + targetType: "shared-learnings", + targetPath: "LEARNINGS.md", + }), + ), + ); + mocks.authorizeDispatchAdmin.mockResolvedValue(undefined); + + await expect( + authorizeDreamProposalMutation( + { id: "proposal-1" }, + { + caller: "http", + orgId: "org-1", + userEmail: "member@example.test", + }, + ), + ).resolves.toBeUndefined(); + expect(mocks.authorizeDispatchAdmin).toHaveBeenCalledWith( + { id: "proposal-1" }, + expect.objectContaining({ + orgId: "org-1", + userEmail: "member@example.test", + }), + ); + }); + + it("does not let an organization member change another user's personal proposal", async () => { + mocks.getDb.mockReturnValue( + createDbMock( + pendingProposal({ + ownerEmail: "other@example.test", + orgId: "org-1", + }), + ), + ); + + await expect( + authorizeDreamProposalMutation( + { id: "proposal-1" }, + { + caller: "http", + orgId: "org-1", + userEmail: "member@example.test", + }, + ), + ).rejects.toThrow("can only be changed by their owner"); + expect(mocks.authorizeDispatchAdmin).not.toHaveBeenCalled(); + }); +}); + describe("listDreamCandidates", () => { it("scores grounded thread signals and keeps per-thread debug errors isolated", async () => { mocks.searchAgentThreads.mockResolvedValue({ diff --git a/packages/dispatch/src/server/lib/dreams-store.ts b/packages/dispatch/src/server/lib/dreams-store.ts index 27d86ed795f..30b3dde897f 100644 --- a/packages/dispatch/src/server/lib/dreams-store.ts +++ b/packages/dispatch/src/server/lib/dreams-store.ts @@ -1,5 +1,6 @@ import crypto from "node:crypto"; +import type { ActionRunContext } from "@agent-native/core/action"; import { and, desc, @@ -20,8 +21,10 @@ import { putOrgSetting, putUserSetting, } from "@agent-native/core/settings"; +import { ForbiddenError } from "@agent-native/core/sharing"; import { getDb, schema } from "../../db/index.js"; +import { authorizeDispatchAdmin } from "./app-roles.js"; import { createApprovalRequest, currentOrgId, @@ -444,7 +447,10 @@ function scopeFor( if (!ctx.orgId) { return and(eq(table.ownerEmail, ctx.ownerEmail), isNull(table.orgId)); } - return or(eq(table.ownerEmail, ctx.ownerEmail), eq(table.orgId, ctx.orgId)); + return or( + and(eq(table.ownerEmail, ctx.ownerEmail), isNull(table.orgId)), + eq(table.orgId, ctx.orgId), + ); } function safeJson(value: unknown): string { @@ -2394,6 +2400,36 @@ async function getProposalRow( return row ?? null; } +export async function authorizeDreamProposalMutation( + args: { id: string }, + ctx?: ActionRunContext, +): Promise { + const ownerEmail = + ctx?.userEmail !== undefined ? ctx.userEmail : currentOwnerEmail(); + const orgId = ctx?.orgId !== undefined ? ctx.orgId : currentOrgId(); + const proposal = await getProposalRow(args.id, { + ownerEmail, + orgId: orgId?.trim() || null, + }); + if (!proposal) { + throw new ForbiddenError("Dream proposal not found"); + } + + if (proposal.targetType === "personal-memory") { + if ( + proposal.ownerEmail.trim().toLowerCase() !== + ownerEmail.trim().toLowerCase() + ) { + throw new ForbiddenError( + "Personal memory proposals can only be changed by their owner", + ); + } + return; + } + + await authorizeDispatchAdmin(args, ctx); +} + export async function createDreamReport(input: { sourceId?: string; sourceIds?: string[];