Skip to content
2 changes: 1 addition & 1 deletion .changeset/restrict-dispatch-org-access.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
58 changes: 54 additions & 4 deletions packages/core/src/client/org/OrgSwitcher.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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(<OrgSwitcher />);
act(() => {
container.querySelector<HTMLButtonElement>("button")!.click();
});
const appsButton = Array.from(
document.body.querySelectorAll<HTMLButtonElement>("button"),
).find((button) => button.textContent?.trim().startsWith("Apps"));
expect(appsButton).not.toBeNull();

act(() => {
appsButton!.click();
});

expect(
document.body.querySelector<HTMLAnchorElement>(
'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: [
{
Expand Down Expand Up @@ -718,10 +768,10 @@ describe("OrgSwitcher", () => {
document.body.querySelector<HTMLAnchorElement>(
'a[href="/dispatch/overview"]',
),
).toBeNull();
).not.toBeNull();
expect(
document.body.querySelector<HTMLAnchorElement>('a[href="/analytics"]'),
).not.toBeNull();
expect(document.body.textContent).not.toContain("more in Dispatch");
expect(document.body.textContent).toContain("more in Dispatch");
});
});
39 changes: 19 additions & 20 deletions packages/core/src/client/org/OrgSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
<PopoverPrimitive.Root>
Expand All @@ -272,7 +272,7 @@ function AppsSubmenu({
{isLoading ? (
<IconLoader2 className="h-3 w-3 animate-spin" />
) : (
accessibleAppsForMenu.length
appsForMenu.length
)}
</span>
<IconChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground rtl:-scale-x-100" />
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -716,9 +715,9 @@ export function OrgSwitcher({
<AppsSubmenu
apps={appLinks.apps}
isLoading={appLinks.isLoading}
isWorkspace={appLinks.isWorkspace}
dispatchHref={appLinks.dispatchHref}
dispatchAllAppsHref={appLinks.dispatchAllAppsHref}
canAccessDispatch={canAccessDispatch}
currentAppId={currentAppId}
onNavigate={() => setOpen(false)}
/>
Expand Down
30 changes: 30 additions & 0 deletions packages/core/src/org/federation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const {
syncOrganizationToIdentityHub,
updateFederatedOrganizationMemberRole,
validateFederatedOrganizationMembership,
validateFederatedOrganizationMembershipForCurrentRequest,
} = await import("./federation.js");

const identity = {
Expand Down Expand Up @@ -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();
Expand Down
17 changes: 9 additions & 8 deletions packages/core/src/org/federation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,9 @@
| { 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);
Expand Down Expand Up @@ -422,7 +418,7 @@
* them while an unavailable authority fails closed.
*/
export async function validateFederatedOrganizationMembership(
event: H3Event,
event: H3Event | undefined,
input: { orgId: string; email: string },
): Promise<FederatedMembershipValidation> {
const email = input.email.trim().toLowerCase();
Expand Down Expand Up @@ -458,7 +454,7 @@
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 };
}
Expand All @@ -471,6 +467,11 @@
"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) {
Expand Down Expand Up @@ -505,7 +506,7 @@
})) as Record<string, unknown> | null;
if (
body?.orgId !== identityId ||
String(body.memberEmail ?? "")

Check warning on line 509 in packages/core/src/org/federation.ts

View workflow job for this annotation

GitHub Actions / Lint & format

typescript(no-base-to-string)

'body.memberEmail ?? ""' will use Object's default stringification format ('[object Object]') when stringified.
.trim()
.toLowerCase() !== email
) {
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/org/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/org/membership.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
2 changes: 1 addition & 1 deletion packages/core/src/org/membership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@
message?: unknown;
cause?: unknown;
};
const message = String(candidate.message ?? "");

Check warning on line 19 in packages/core/src/org/membership.ts

View workflow job for this annotation

GitHub Actions / Lint & format

typescript(no-base-to-string)

'candidate.message ?? ""' will use Object's default stringification format ('[object Object]') when stringified.
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,
)
) {
Expand Down
Loading
Loading