From 23d0d00a5ad18a14a6190c8d79d325724baebc8f Mon Sep 17 00:00:00 2001
From: marcorivm
Date: Wed, 29 Jul 2026 16:54:41 -0600
Subject: [PATCH 01/10] feat(edition): re-land Tier 1 ungating onto 1.44.0
Reconciliation Stage A. getCurrentPlan() reports enterprise; the OSS
policy validator wiring and now-empty policy-flags/policy-oss-locks
modules are removed (the coherence bridge was already dropped upstream);
lock affordances are edition-neutral (UnavailableBadge, informational
ProAppDialog); the OneCLI Cloud promo blocks and cloud_only error string
are removed. No agent-group code, no migration.
---
.../app-connect/_components/connect-flow.tsx | 23 ------
.../_components/get-started-dialog.tsx | 24 ++----
.../_components/app-config-form.tsx | 18 -----
.../connections/_components/apps-tab.tsx | 47 ++----------
.../configure-credentials-dialog.tsx | 16 ----
.../src/lib/components/condition-builder.tsx | 12 +--
.../web/src/lib/components/pro-app-dialog.tsx | 72 +++---------------
apps/web/src/lib/components/team-badge.tsx | 38 ----------
.../src/lib/components/unavailable-badge.tsx | 11 +++
apps/web/src/lib/init/api.ts | 10 +--
.../policy-editor/_components/app-select.tsx | 6 +-
.../_components/app-target-fields.tsx | 21 ++----
.../src/lib/policy-editor/identity-picker.tsx | 2 +-
.../src/lib/policy-editor/resource-scope.tsx | 12 +--
apps/web/src/lib/user-plan.tsx | 9 ++-
.../api/src/apps/connect-credentials.test.ts | 2 +-
packages/api/src/apps/connect-credentials.ts | 2 +-
packages/api/src/lib/policy-flags.test.ts | 37 ----------
packages/api/src/lib/policy-flags.ts | 14 ----
.../src/providers/hooks/policy-validator.ts | 3 +-
.../api/src/services/policy-oss-locks.test.ts | 73 -------------------
packages/api/src/services/policy-oss-locks.ts | 49 -------------
packages/api/src/services/policy-service.ts | 14 ++--
23 files changed, 73 insertions(+), 442 deletions(-)
delete mode 100644 apps/web/src/lib/components/team-badge.tsx
create mode 100644 apps/web/src/lib/components/unavailable-badge.tsx
delete mode 100644 packages/api/src/lib/policy-flags.test.ts
delete mode 100644 packages/api/src/lib/policy-flags.ts
delete mode 100644 packages/api/src/services/policy-oss-locks.test.ts
delete mode 100644 packages/api/src/services/policy-oss-locks.ts
diff --git a/apps/web/src/app/(connect)/app-connect/_components/connect-flow.tsx b/apps/web/src/app/(connect)/app-connect/_components/connect-flow.tsx
index 6871d9ea..80af71c5 100644
--- a/apps/web/src/app/(connect)/app-connect/_components/connect-flow.tsx
+++ b/apps/web/src/app/(connect)/app-connect/_components/connect-flow.tsx
@@ -4,7 +4,6 @@ import type { ReactNode } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Loader2 } from "lucide-react";
import { Button } from "@onecli/ui/components/button";
-import { IS_CLOUD } from "@/lib/env";
import { API_ORIGIN, getAuthToken, getProjectId } from "@/lib/api-fetch";
import { ConnectLayout } from "./connect-layout";
import { ConnectSuccess } from "./connect-success";
@@ -280,28 +279,6 @@ export const ConnectFlow = ({
Use an API key instead
)}
- {!IS_CLOUD && (
- <>
-
-
- Skip setup with{" "}
-
- OneCLI Cloud
-
-
- >
- )}
);
diff --git a/apps/web/src/app/(dashboard)/_components/get-started-dialog.tsx b/apps/web/src/app/(dashboard)/_components/get-started-dialog.tsx
index e72a9911..307c0149 100644
--- a/apps/web/src/app/(dashboard)/_components/get-started-dialog.tsx
+++ b/apps/web/src/app/(dashboard)/_components/get-started-dialog.tsx
@@ -166,17 +166,16 @@ export const GetStartedDialog = ({
- Requires the OneCLI CLI. One-command install is
- available with{" "}
+ Requires the OneCLI CLI — see the{" "}
- OneCLI Cloud
-
- .
+ quickstart
+ {" "}
+ to install it.
)}
@@ -215,17 +214,8 @@ export const GetStartedDialog = ({
) : (
-
- Migration is available with{" "}
-
- OneCLI Cloud
-
- .
+
+ Automated migration isn't available in this build.
)}
diff --git a/apps/web/src/app/(dashboard)/connections/_components/app-config-form.tsx b/apps/web/src/app/(dashboard)/connections/_components/app-config-form.tsx
index 667b56f7..4938b938 100644
--- a/apps/web/src/app/(dashboard)/connections/_components/app-config-form.tsx
+++ b/apps/web/src/app/(dashboard)/connections/_components/app-config-form.tsx
@@ -41,7 +41,6 @@ import {
useDeleteAppConfig,
useToggleAppConfig,
} from "@/hooks/use-app-config";
-import { IS_CLOUD } from "@/lib/env";
import { RedirectUri } from "./redirect-uri";
export interface AppConfigFormHandle {
@@ -301,23 +300,6 @@ export const AppConfigForm = ({
? "Override platform defaults with your own."
: (hint ?? `Required to connect ${appName}.`)}
- {!hasEnvDefaults &&
- !hasCredentials &&
- !enabled &&
- !IS_CLOUD && (
-
- Or connect instantly with{" "}
-
- OneCLI Cloud
- {" "}
- - no credentials needed.
-
- )}
diff --git a/apps/web/src/app/(dashboard)/connections/_components/apps-tab.tsx b/apps/web/src/app/(dashboard)/connections/_components/apps-tab.tsx
index 7db4b7c3..9f2ee603 100644
--- a/apps/web/src/app/(dashboard)/connections/_components/apps-tab.tsx
+++ b/apps/web/src/app/(dashboard)/connections/_components/apps-tab.tsx
@@ -25,7 +25,7 @@ import {
type AppCategory,
} from "./app-categories";
import type { AppDefinition } from "@onecli/api/apps/types";
-import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { useQueryClient } from "@tanstack/react-query";
import type { PageScope } from "@/lib/api";
import { queryKeys } from "@/lib/api/keys";
import { useConnections } from "@/hooks/use-connections";
@@ -40,8 +40,8 @@ import {
useAppMessages,
type AppConnectedEvent,
} from "@/hooks/use-app-connected";
-import { getCurrentPlan } from "@/lib/user-plan";
import { ProAppDialog } from "@/lib/components/pro-app-dialog";
+import { UnavailableBadge } from "@/lib/components/unavailable-badge";
import { AppIcon } from "./app-icon";
import { ConnectAppDialog } from "./connect-app-dialog";
import { ConfigureCredentialsDialog } from "./configure-credentials-dialog";
@@ -121,10 +121,6 @@ export const AppsTab = ({
const configuredQuery = useConfiguredProviders(pageScope);
const envDefaultsQuery = useEnvDefaultProviders();
const availableQuery = useAvailableApps(pageScope);
- const planQuery = useQuery({
- queryKey: queryKeys.userPlan.all(),
- queryFn: getCurrentPlan,
- });
const connectionCounts = useMemo(() => {
const counts = new Map();
@@ -143,12 +139,10 @@ export const AppsTab = ({
() => new Set(envDefaultsQuery.data ?? []),
[envDefaultsQuery.data],
);
- const plan = planQuery.data ?? null;
const loading =
connectionsQuery.isPending ||
configuredQuery.isPending ||
- envDefaultsQuery.isPending ||
- planQuery.isPending;
+ envDefaultsQuery.isPending;
const handleConnected = useCallback(
({ provider, connectionId }: AppConnectedEvent) => {
@@ -387,10 +381,7 @@ export const AppsTab = ({
) : (
filteredApps.map((app) => {
const count = connectionCounts.get(app.id) ?? 0;
- const isLocked =
- !app.available ||
- (app.teamOnly === true &&
- !["team", "scale", "enterprise"].includes(plan ?? ""));
+ const isLocked = !app.available;
return (
{cloudOnly ? (
-
-
-
-
-
-
- Team
-
-
+
) : (
{!hideDetails && (
diff --git a/apps/web/src/app/(dashboard)/connections/_components/configure-credentials-dialog.tsx b/apps/web/src/app/(dashboard)/connections/_components/configure-credentials-dialog.tsx
index 9aed0cef..f910a6e1 100644
--- a/apps/web/src/app/(dashboard)/connections/_components/configure-credentials-dialog.tsx
+++ b/apps/web/src/app/(dashboard)/connections/_components/configure-credentials-dialog.tsx
@@ -15,7 +15,6 @@ import { SecretInput } from "@/components/secret-input";
import type { PageScope } from "@/lib/api";
import { useSaveAppConfig } from "@/hooks/use-app-config";
import type { OAuthConfigField } from "@onecli/api/apps/types";
-import { IS_CLOUD } from "@/lib/env";
import { AppIcon } from "./app-icon";
import { RedirectUri } from "./redirect-uri";
@@ -133,21 +132,6 @@ export const ConfigureCredentialsDialog = ({
>
{saving ? "Saving..." : "Save & Connect"}
-
- {!IS_CLOUD && (
-
- Or use{" "}
-
- OneCLI Cloud
- {" "}
- for pre-configured connections.
-
- )}
diff --git a/apps/web/src/lib/components/condition-builder.tsx b/apps/web/src/lib/components/condition-builder.tsx
index 97064d12..1bb1d88f 100644
--- a/apps/web/src/lib/components/condition-builder.tsx
+++ b/apps/web/src/lib/components/condition-builder.tsx
@@ -10,16 +10,8 @@ export interface ConditionBuilderProps {
export const ConditionBuilder = ({}: ConditionBuilderProps) => (
- Match conditions (body content, headers) are available on{" "}
-
- OneCLI Cloud
-
- .
+ Match conditions (body content, headers) are not yet available in this
+ build.
);
diff --git a/apps/web/src/lib/components/pro-app-dialog.tsx b/apps/web/src/lib/components/pro-app-dialog.tsx
index cf6aa7a4..07030b94 100644
--- a/apps/web/src/lib/components/pro-app-dialog.tsx
+++ b/apps/web/src/lib/components/pro-app-dialog.tsx
@@ -1,7 +1,5 @@
"use client";
-import { ExternalLink } from "lucide-react";
-import { Button } from "@onecli/ui/components/button";
import {
Dialog,
DialogContent,
@@ -9,6 +7,7 @@ import {
DialogTitle,
} from "@onecli/ui/components/dialog";
import { AppIcon } from "@/app/(dashboard)/connections/_components/app-icon";
+import { UnavailableBadge } from "@/lib/components/unavailable-badge";
interface ProAppDialogProps {
appName: string;
@@ -19,6 +18,13 @@ interface ProAppDialogProps {
onOpenChange: (open: boolean) => void;
}
+/**
+ * Shown when the user opens something this build does not implement: an
+ * `available: false` registry app (Connections list) or a capability without an
+ * OSS implementation (granular access). Informational only — the dialog's close
+ * button is the only action. Every EE edition aliases this module away
+ * (`next.config.js` → `@/ee/apps/pro-app-dialog`).
+ */
export const ProAppDialog = ({
appName,
appIcon,
@@ -44,72 +50,16 @@ export const ProAppDialog = ({
{appName}
-
-
-
-
-
-
- Team
-
+
+
{description}
- Available on OneCLI Cloud and on-prem enterprise plans.
+ Not yet available in this build.
-
-
-
- window.open(
- "https://app.onecli.sh",
- "_blank",
- "noopener,noreferrer",
- )
- }
- >
- Try OneCLI Cloud
-
-
-
- window.open(
- "https://cal.com/onecli",
- "_blank",
- "noopener,noreferrer",
- )
- }
- >
- Looking for on-prem? Talk to sales
-
-
-
diff --git a/apps/web/src/lib/components/team-badge.tsx b/apps/web/src/lib/components/team-badge.tsx
deleted file mode 100644
index f9d9e4df..00000000
--- a/apps/web/src/lib/components/team-badge.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-/**
- * The house "Team" pill marking an app that needs a paid OneCLI plan — the
- * same badge the Connections list (`apps-tab.tsx` AppRow) and `ProAppDialog`
- * render inline. Extracted for the policy editor's cloud-only-app surfaces;
- * the two existing inline copies are untouched (future cleanup).
- */
-export const TeamBadge = () => (
-
-
-
-
-
-
- Team
-
-
-);
diff --git a/apps/web/src/lib/components/unavailable-badge.tsx b/apps/web/src/lib/components/unavailable-badge.tsx
new file mode 100644
index 00000000..ca229ff7
--- /dev/null
+++ b/apps/web/src/lib/components/unavailable-badge.tsx
@@ -0,0 +1,11 @@
+/**
+ * The house pill marking an integration or capability this build does not
+ * implement (`available: false` registry entries, and the locked policy-editor
+ * surfaces). Rendered by the Connections list, the policy editor's app
+ * pickers, and `ProAppDialog`.
+ */
+export const UnavailableBadge = () => (
+
+ Unavailable
+
+);
diff --git a/apps/web/src/lib/init/api.ts b/apps/web/src/lib/init/api.ts
index 2512b8cc..0c9b01e8 100644
--- a/apps/web/src/lib/init/api.ts
+++ b/apps/web/src/lib/init/api.ts
@@ -1,6 +1,5 @@
import type { CreateApiAppOptions } from "@onecli/api";
import { ossNewProjectPolicySeeder } from "@onecli/api/services/policy-oss-cutover";
-import { ossPolicyValidator } from "@onecli/api/services/policy-oss-locks";
/**
* The OSS edition's API wiring. Every EE edition ALIASES THIS FILE AWAY
@@ -8,11 +7,12 @@ import { ossPolicyValidator } from "@onecli/api/services/policy-oss-locks";
* here is OSS-only by construction:
*
* - the new-project seeder gives fresh projects their published Default Rule —
- * the per-project enforce signal — pinned to ALLOW since step 6;
- * - the policy validator LOCKS granular resource scoping (a OneCLI Cloud
- * capability the OSS gateway does not enforce) with a loud 422.
+ * the per-project enforce signal — pinned to ALLOW since step 6.
+ *
+ * No `policyValidator` is wired: the provider-hook default is permissive, so
+ * granular resource scoping and cloud-only app targets are accepted at the API
+ * layer. The gateway does not yet ENFORCE resource scoping — see Tier 3.
*/
export const eeOverrides: CreateApiAppOptions | undefined = {
newOrgPolicySeeder: ossNewProjectPolicySeeder,
- policyValidator: ossPolicyValidator,
};
diff --git a/apps/web/src/lib/policy-editor/_components/app-select.tsx b/apps/web/src/lib/policy-editor/_components/app-select.tsx
index 1dd255b2..a3e831b1 100644
--- a/apps/web/src/lib/policy-editor/_components/app-select.tsx
+++ b/apps/web/src/lib/policy-editor/_components/app-select.tsx
@@ -11,7 +11,7 @@ import {
} from "@onecli/ui/components/popover";
import { getApp, getApps } from "@onecli/api/apps/registry";
import { AppIcon } from "@/app/(dashboard)/connections/_components/app-icon";
-import { TeamBadge } from "@/lib/components/team-badge";
+import { UnavailableBadge } from "@/lib/components/unavailable-badge";
/**
* True when the registry knows the app but this edition can't connect it —
@@ -96,7 +96,7 @@ export const AppSelect = ({ value, onChange, id, invalid }: AppSelectProps) => {
size={18}
/>
{selectedApp.name}
- {!selectedApp.available && }
+ {!selectedApp.available && }
>
) : (
Select an app…
@@ -141,7 +141,7 @@ export const AppSelect = ({ value, onChange, id, invalid }: AppSelectProps) => {
{a.name}
- {!a.available && }
+ {!a.available && }
{a.id === value && (
)}
diff --git a/apps/web/src/lib/policy-editor/_components/app-target-fields.tsx b/apps/web/src/lib/policy-editor/_components/app-target-fields.tsx
index 813b50de..94c1ffba 100644
--- a/apps/web/src/lib/policy-editor/_components/app-target-fields.tsx
+++ b/apps/web/src/lib/policy-editor/_components/app-target-fields.tsx
@@ -13,10 +13,11 @@ import { cn } from "@onecli/ui/lib/utils";
import { getApp } from "@onecli/api/apps/registry";
import { AppSelect } from "./app-select";
import { AppToolsPicker } from "./app-tools-picker";
-import { TeamBadge } from "@/lib/components/team-badge";
+import { UnavailableBadge } from "@/lib/components/unavailable-badge";
// Edition seam: EE aliases to the real granular resource editor; the OSS
-// module is a locked "available on OneCLI Cloud" hint. Alias key on purpose —
-// a relative import would bypass turbopack resolveAlias in EE builds.
+// module is a locked "not available in this build" hint. Alias key on
+// purpose — a relative import would bypass turbopack resolveAlias in EE
+// builds.
import { ResourceScopeFields } from "@/lib/policy-editor/resource-scope";
import type { Connection } from "@/lib/api";
@@ -144,18 +145,10 @@ export const AppTargetFields = ({
role="status"
className="flex items-center gap-2.5 rounded-md border border-dashed px-3 py-2.5"
>
-
+
- {providerName(value.provider)} connections are available on{" "}
-
- OneCLI Cloud
-
- .
+ {providerName(value.provider)} connections are not yet available in
+ this build.
) : (
diff --git a/apps/web/src/lib/policy-editor/identity-picker.tsx b/apps/web/src/lib/policy-editor/identity-picker.tsx
index 85f7b932..2979506b 100644
--- a/apps/web/src/lib/policy-editor/identity-picker.tsx
+++ b/apps/web/src/lib/policy-editor/identity-picker.tsx
@@ -4,7 +4,7 @@ import type { ProjectionIdentity } from "@/lib/api";
/**
* The OSS identity-picker seam (step 9.5). Directory identities (users,
- * user-groups) are a OneCLI Cloud capability, and since attach-model step 6
+ * user-groups) are not implemented in this build, and since attach-model step 6
* the only policy console left is the ORG one — which OSS does not mount at
* all. So this stub can never render; it exists to keep the shared rule form
* compiling in an OSS build. The EE editions alias this file to
diff --git a/apps/web/src/lib/policy-editor/resource-scope.tsx b/apps/web/src/lib/policy-editor/resource-scope.tsx
index e01916ca..d905e195 100644
--- a/apps/web/src/lib/policy-editor/resource-scope.tsx
+++ b/apps/web/src/lib/policy-editor/resource-scope.tsx
@@ -5,11 +5,11 @@ import type { Connection } from "@/lib/api";
/**
* The OSS resource-scope seam (step 9.5): granular per-resource scoping
* (GitHub repositories / Dropbox folders on a connection's injected
- * credential) is a OneCLI Cloud capability — the OSS gateway has no guard to
- * enforce it and the API locks it with a 422. Rendered only where the real
- * editor would appear (a single specific connection on an Allow), as a locked
- * capability hint. The EE editions alias this file to
- * `@/ee/policy-editor/resource-scope` (the real fields).
+ * credential) is not implemented in this build — the gateway has no guard to
+ * enforce it (Tier 3). Rendered only where the real editor would appear (a
+ * single specific connection on an Allow), as a locked capability hint. The
+ * EE editions alias this file to `@/ee/policy-editor/resource-scope` (the
+ * real fields).
*/
export interface ResourceScopeFieldsProps {
@@ -23,6 +23,6 @@ export const ResourceScopeFields: (
) => React.JSX.Element = () => (
Resource scoping (limit this connection to specific repositories or folders)
- is available on OneCLI Cloud.
+ is not yet available in this build.
);
diff --git a/apps/web/src/lib/user-plan.tsx b/apps/web/src/lib/user-plan.tsx
index 172ea050..c6997ec0 100644
--- a/apps/web/src/lib/user-plan.tsx
+++ b/apps/web/src/lib/user-plan.tsx
@@ -3,5 +3,10 @@
/** OSS default: no redirect needed. The EE editions override this via turbopack alias. */
export const checkDashboardRedirect = async (): Promise => null;
-/** OSS default: no plan. The EE editions override this via turbopack alias. */
-export const getCurrentPlan = async (): Promise => null;
+/**
+ * This build is fully entitled — mirrors what on-prem reports via
+ * ONPREM_ENTITLEMENT_ALIASES (`next.config.js`), so plan-gated apps and
+ * features are never shown as locked. The EE editions override this via
+ * turbopack alias.
+ */
+export const getCurrentPlan = async (): Promise => "enterprise";
diff --git a/packages/api/src/apps/connect-credentials.test.ts b/packages/api/src/apps/connect-credentials.test.ts
index 3882bad4..830c7ffa 100644
--- a/packages/api/src/apps/connect-credentials.test.ts
+++ b/packages/api/src/apps/connect-credentials.test.ts
@@ -112,7 +112,7 @@ describe("resolveConnectCredentials", () => {
});
expect(result).toEqual({
ok: false,
- error: 'Provider "cloudy" is only available in OneCLI Cloud',
+ error: 'Provider "cloudy" is not yet available in this build',
});
});
diff --git a/packages/api/src/apps/connect-credentials.ts b/packages/api/src/apps/connect-credentials.ts
index 754698a2..a8b72926 100644
--- a/packages/api/src/apps/connect-credentials.ts
+++ b/packages/api/src/apps/connect-credentials.ts
@@ -70,7 +70,7 @@ export const resolveConnectCredentials = async (
if (activeMethod.type === "cloud_only") {
return {
ok: false,
- error: `Provider "${provider}" is only available in OneCLI Cloud`,
+ error: `Provider "${provider}" is not yet available in this build`,
};
}
diff --git a/packages/api/src/lib/policy-flags.test.ts b/packages/api/src/lib/policy-flags.test.ts
deleted file mode 100644
index 6ad9cb89..00000000
--- a/packages/api/src/lib/policy-flags.test.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import { afterEach, describe, expect, it } from "vitest";
-import { isOssEdition } from "./policy-flags";
-
-// The OSS edition drives how the shared policy service phrases capability
-// rejections (a OneCLI Cloud pointer there, byte-identical everywhere else), so
-// the edition resolution itself is pinned: EDITION first, NEXT_PUBLIC_EDITION as
-// the fallback, and an unset/unknown value parsing as OSS.
-describe("isOssEdition", () => {
- const originalEdition = process.env.EDITION;
- const originalPublicEdition = process.env.NEXT_PUBLIC_EDITION;
-
- afterEach(() => {
- if (originalEdition === undefined) delete process.env.EDITION;
- else process.env.EDITION = originalEdition;
- if (originalPublicEdition === undefined)
- delete process.env.NEXT_PUBLIC_EDITION;
- else process.env.NEXT_PUBLIC_EDITION = originalPublicEdition;
- });
-
- it.each([
- ["oss", true],
- ["onprem-slim", false],
- ["onprem-full", false],
- ["cloud", false],
- ["", true], // unset edition parses as oss
- ])("edition %s → %s", (edition, expected) => {
- delete process.env.NEXT_PUBLIC_EDITION;
- process.env.EDITION = edition;
- expect(isOssEdition()).toBe(expected);
- });
-
- it("falls back to NEXT_PUBLIC_EDITION when EDITION is unset", () => {
- delete process.env.EDITION;
- process.env.NEXT_PUBLIC_EDITION = "cloud";
- expect(isOssEdition()).toBe(false);
- });
-});
diff --git a/packages/api/src/lib/policy-flags.ts b/packages/api/src/lib/policy-flags.ts
deleted file mode 100644
index 138be7c0..00000000
--- a/packages/api/src/lib/policy-flags.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-/**
- * Policy runtime edition helpers. Pure and dependency-free (reads only
- * `process.env` plus the pure edition parser), so it is safe to import from
- * routes, middleware, or a standalone startup entry.
- */
-import { parseEdition } from "./edition";
-
-const runtimeEdition = () =>
- parseEdition(process.env.EDITION ?? process.env.NEXT_PUBLIC_EDITION).edition;
-
-/** Whether this runtime is the OSS edition — used by the shared policy
- * service to phrase capability rejections as OneCLI Cloud pointers there
- * (byte-identical messages everywhere else). */
-export const isOssEdition = (): boolean => runtimeEdition() === "oss";
diff --git a/packages/api/src/providers/hooks/policy-validator.ts b/packages/api/src/providers/hooks/policy-validator.ts
index 7f3eb03b..b1e9731b 100644
--- a/packages/api/src/providers/hooks/policy-validator.ts
+++ b/packages/api/src/providers/hooks/policy-validator.ts
@@ -10,8 +10,7 @@ export interface PolicyValidator {
/**
* Edition gate over a rule's targets, run on create/update (never publish —
* a pre-existing row must not brick a whole-scope publish). Absent =
- * permissive (the default); the OSS edition wires an implementation that
- * rejects app targets for cloud-only providers its gateway can't enforce.
+ * permissive (the default); no edition in this repo wires one.
*/
validateTargets?(targets: PolicyTargetInput[]): Promise;
}
diff --git a/packages/api/src/services/policy-oss-locks.test.ts b/packages/api/src/services/policy-oss-locks.test.ts
deleted file mode 100644
index 71df400c..00000000
--- a/packages/api/src/services/policy-oss-locks.test.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { ossPolicyValidator } from "./policy-oss-locks";
-import { ServiceError } from "./errors";
-import type { PolicyTargetInput } from "../validations/policy";
-
-// The OSS edition's policy locks. These run against the DEFAULT registries —
-// exactly what an OSS process sees (no initEeApps): base apps available, the
-// shared EE-stub list (aws-role, datadog, …) present with `available: false`.
-
-describe("ossPolicyValidator.validate (granular session policy)", () => {
- it("rejects unconditionally with the cloud-only message", async () => {
- await expect(
- ossPolicyValidator.validate("org-1", "github", null, {
- repositories: ["a/b"],
- }),
- ).rejects.toMatchObject({
- code: "UNPROCESSABLE",
- message:
- "Granular resource scoping (repositories/folders) is available on OneCLI Cloud.",
- });
- });
-});
-
-describe("ossPolicyValidator.validateTargets (cloud-only apps)", () => {
- const run = (targets: PolicyTargetInput[]) =>
- ossPolicyValidator.validateTargets!(targets);
-
- it("rejects an app target for a cloud-only (EE-stub) provider, naming the app", async () => {
- const err = await run([{ kind: "app", provider: "aws-role" }]).catch(
- (e: unknown) => e,
- );
- expect(err).toBeInstanceOf(ServiceError);
- expect((err as ServiceError).code).toBe("UNPROCESSABLE");
- expect((err as ServiceError).message).toBe(
- "AWS Role connections are available on OneCLI Cloud.",
- );
- });
-
- it("rejects when the cloud-only target is mixed among valid ones", async () => {
- await expect(
- run([
- { kind: "network", hostPattern: "api.example.com" },
- { kind: "app", provider: "datadog" },
- ]),
- ).rejects.toMatchObject({ code: "UNPROCESSABLE" });
- });
-
- it("accepts a base (connectable) app", async () => {
- await expect(
- run([{ kind: "app", provider: "github" }]),
- ).resolves.toBeUndefined();
- });
-
- it("accepts an UNKNOWN provider string (typos, and onprem-style excluded apps, stay non-fatal)", async () => {
- await expect(
- run([{ kind: "app", provider: "not-a-real-app" }]),
- ).resolves.toBeUndefined();
- });
-
- it("ignores non-app target kinds", async () => {
- await expect(
- run([
- { kind: "network", hostPattern: "*.x.com" },
- { kind: "secret", secretScope: "project" },
- { kind: "connection", connectionId: "conn-1" },
- ]),
- ).resolves.toBeUndefined();
- });
-
- it("accepts an empty target list", async () => {
- await expect(run([])).resolves.toBeUndefined();
- });
-});
diff --git a/packages/api/src/services/policy-oss-locks.ts b/packages/api/src/services/policy-oss-locks.ts
deleted file mode 100644
index c2fcceaf..00000000
--- a/packages/api/src/services/policy-oss-locks.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-/**
- * The OSS edition's policy locks (step 9.5): shared implementations wired ONLY
- * through the OSS init seam (`apps/web/src/lib/init/api.ts`, aliased away by
- * every EE edition). The provider-hook DEFAULTS stay permissive — cloud's
- * in-process web app relies on them before its init warms — so the locks are
- * wired, not defaulted.
- */
-import { ServiceError } from "./errors";
-import type { PolicyValidator } from "../providers";
-import { getApp } from "../apps/registry";
-
-/**
- * OSS rejects granular resource scoping outright. One seam covers both storage
- * paths: `assertSessionPolicyValid` (policy-rule create/update/publish) and
- * the legacy equipment `sessionPolicy` write both call
- * `getPolicyValidator().validate(...)`. Without this lock OSS would
- * accept-and-store `{repositories}`/`{folders}` that its gateway never
- * enforces — false security, worse than absence.
- *
- * `validateTargets` (create/update only) rejects app targets naming a
- * cloud-only provider — the registry's EE stubs (`available: false`), which
- * the OSS gateway's base catalog can't resolve, so the rule would be dead.
- * The editor locks the same key visually; this is the belt for the CLI/API
- * path. App targets only: `assertTargetsValid` proves a connection target's
- * OWNERSHIP, not connectability — but no OSS flow can mint an EE-provider
- * connection in the first place (connect rejects `cloud_only` providers), so
- * connection targets need no provider check. Unknown provider strings stay
- * accepted (today's behavior).
- */
-export const ossPolicyValidator: PolicyValidator = {
- validate: async () => {
- throw new ServiceError(
- "UNPROCESSABLE",
- "Granular resource scoping (repositories/folders) is available on OneCLI Cloud.",
- );
- },
- validateTargets: async (targets) => {
- for (const t of targets) {
- if (t.kind !== "app") continue;
- const app = getApp(t.provider);
- if (app?.available === false) {
- throw new ServiceError(
- "UNPROCESSABLE",
- `${app.name} connections are available on OneCLI Cloud.`,
- );
- }
- }
- },
-};
diff --git a/packages/api/src/services/policy-service.ts b/packages/api/src/services/policy-service.ts
index 5c1b2a07..a36c5aa8 100644
--- a/packages/api/src/services/policy-service.ts
+++ b/packages/api/src/services/policy-service.ts
@@ -1,6 +1,5 @@
import { db, Prisma } from "@onecli/db";
import { ServiceError } from "./errors";
-import { isOssEdition } from "../lib/policy-flags";
import { type ResourceScope } from "./resource-scope";
import { getPolicyValidator, getRuleActionGate } from "../providers";
import type {
@@ -363,15 +362,12 @@ export const assertIdentitiesValid = async (
const userIds = idsOf("user");
const groupIds = idsOf("group");
- // Level restriction. The OSS edition phrases it as the capability lock it
- // is there (directory identities are a OneCLI Cloud capability); the EE
- // editions keep the scope-shaped message byte-identical.
+ // Level restriction — the same scope-shaped rule in every edition: a project
+ // rule targets agents, an org rule targets directory identities.
if (base.scope === "project" && (userIds.length || groupIds.length)) {
throw new ServiceError(
"UNPROCESSABLE",
- isOssEdition()
- ? "Group and user identities are available on OneCLI Cloud."
- : "A project rule can target a specific agent or all agents.",
+ "A project rule can target a specific agent or all agents.",
);
}
if (base.scope === "organization" && agentIds.length) {
@@ -561,8 +557,8 @@ export const assertTargetsValid = async (
* with a connection target — then runs the wired policy validator per
* connection target. EE deep-checks the shape against the provider (repos
* exist on the installation, absolute Dropbox paths) and gates the team+
- * entitlement; OSS wires a validator that REJECTS session policies outright
- * (granular scoping is a OneCLI Cloud capability — step 9.5). A no-op for
+ * entitlement; OSS wires no validator — the permissive default accepts
+ * session policies the OSS gateway does not yet enforce (Tier 3). A no-op for
* behavioral / absent conditions. Same org fence as `assertTargetsValid`.
*
* Callers pass the MERGED (post-update) action/targets/conditions, so no PATCH
From 45f516b3c35f366e475057f9788fcc9e17f20e35 Mon Sep 17 00:00:00 2001
From: marcorivm
Date: Wed, 29 Jul 2026 17:47:57 -0600
Subject: [PATCH 02/10] feat(api): re-land user groups onto 1.44.0 (agent
groups dropped)
Reconciliation Stage C. /v1/org/groups CRUD + replace-set membership
(org-membership validated on the global-FK userId), /groups as a single
user-groups view. Agent groups and the org-agent directory are dropped
(orphaned once agent groups are gone). No orphan-neutralization pass:
upstream's grants engine treats an FK-orphaned rule identity as inert, so
a group delete cannot widen a rule. +59 tests, no agent-group code, no
migration.
---
.../groups/_components/admin-only-notice.tsx | 20 +
.../_components/create-group-dialog.tsx | 96 ++
.../_components/group-members-dialog.tsx | 277 ++++
.../groups/_components/group-row-actions.tsx | 203 +++
.../groups/_components/groups-content.tsx | 47 +
.../groups/_components/groups-table.tsx | 91 ++
.../groups/_components/local-mode-notice.tsx | 21 +
.../src/app/(dashboard)/groups/loading.tsx | 27 +
apps/web/src/app/(dashboard)/groups/page.tsx | 30 +
apps/web/src/lib/nav-config.ts | 5 +
packages/api/src/routes/org/groups.test.ts | 1388 +++++++++++++++++
packages/api/src/routes/org/groups.ts | 219 +++
packages/api/src/routes/org/index.ts | 2 +
packages/api/src/services/audit-service.ts | 5 +-
.../api/src/services/org-group-service.ts | 497 ++++++
packages/api/src/validations/org.ts | 36 +
16 files changed, 2963 insertions(+), 1 deletion(-)
create mode 100644 apps/web/src/app/(dashboard)/groups/_components/admin-only-notice.tsx
create mode 100644 apps/web/src/app/(dashboard)/groups/_components/create-group-dialog.tsx
create mode 100644 apps/web/src/app/(dashboard)/groups/_components/group-members-dialog.tsx
create mode 100644 apps/web/src/app/(dashboard)/groups/_components/group-row-actions.tsx
create mode 100644 apps/web/src/app/(dashboard)/groups/_components/groups-content.tsx
create mode 100644 apps/web/src/app/(dashboard)/groups/_components/groups-table.tsx
create mode 100644 apps/web/src/app/(dashboard)/groups/_components/local-mode-notice.tsx
create mode 100644 apps/web/src/app/(dashboard)/groups/loading.tsx
create mode 100644 apps/web/src/app/(dashboard)/groups/page.tsx
create mode 100644 packages/api/src/routes/org/groups.test.ts
create mode 100644 packages/api/src/routes/org/groups.ts
create mode 100644 packages/api/src/services/org-group-service.ts
diff --git a/apps/web/src/app/(dashboard)/groups/_components/admin-only-notice.tsx b/apps/web/src/app/(dashboard)/groups/_components/admin-only-notice.tsx
new file mode 100644
index 00000000..cda9a616
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/groups/_components/admin-only-notice.tsx
@@ -0,0 +1,20 @@
+import { Lock } from "lucide-react";
+import { Card } from "@onecli/ui/components/card";
+
+/**
+ * Rendered when the groups query 403s — the API is the authority on who is
+ * an admin (the /team D-K pattern). A plain card: no retry, no toast (the
+ * 403 is deterministic).
+ */
+export const AdminOnlyNotice = () => (
+
+
+
+
+ Admins only
+
+ Managing groups requires an organization admin. Ask an admin if you need a
+ group created or changed.
+
+
+);
diff --git a/apps/web/src/app/(dashboard)/groups/_components/create-group-dialog.tsx b/apps/web/src/app/(dashboard)/groups/_components/create-group-dialog.tsx
new file mode 100644
index 00000000..d8e2f311
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/groups/_components/create-group-dialog.tsx
@@ -0,0 +1,96 @@
+"use client";
+
+import { useState } from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@onecli/ui/components/dialog";
+import { Button } from "@onecli/ui/components/button";
+import { Input } from "@onecli/ui/components/input";
+import { Label } from "@onecli/ui/components/label";
+import { cn } from "@onecli/ui/lib/utils";
+import { useCreateGroup } from "@/hooks/use-groups";
+
+export interface CreateGroupDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export const CreateGroupDialog = ({
+ open,
+ onOpenChange,
+}: CreateGroupDialogProps) => {
+ const [name, setName] = useState("");
+ const [touched, setTouched] = useState(false);
+ const createGroup = useCreateGroup();
+
+ const trimmed = name.trim();
+ const nameError =
+ trimmed.length === 0
+ ? "Name is required."
+ : trimmed.length > 100
+ ? "Name must be 100 characters or fewer."
+ : null;
+ const showNameError = touched && nameError !== null;
+
+ const handleCreate = () => {
+ setTouched(true);
+ if (nameError || createGroup.isPending) return;
+ createGroup.mutate(trimmed, { onSuccess: () => handleClose(false) });
+ };
+
+ const handleClose = (value: boolean) => {
+ if (!value) {
+ setName("");
+ setTouched(false);
+ }
+ onOpenChange(value);
+ };
+
+ return (
+
+
+
+ Create group
+
+ Groups organize members for project access and policy rules.
+
+
+
+
Name
+
setName(e.target.value)}
+ onBlur={() => setTouched(true)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") handleCreate();
+ }}
+ autoFocus
+ className={cn(showNameError && "border-destructive")}
+ />
+ {showNameError && (
+
{nameError}
+ )}
+
+
+ handleClose(false)}>
+ Cancel
+
+
+ {createGroup.isPending ? "Creating..." : "Create"}
+
+
+
+
+ );
+};
diff --git a/apps/web/src/app/(dashboard)/groups/_components/group-members-dialog.tsx b/apps/web/src/app/(dashboard)/groups/_components/group-members-dialog.tsx
new file mode 100644
index 00000000..5a5480cb
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/groups/_components/group-members-dialog.tsx
@@ -0,0 +1,277 @@
+"use client";
+
+import { useEffect, useMemo, useRef, useState } from "react";
+import { UsersRound, Loader2, Search, TriangleAlert } from "lucide-react";
+import { toast } from "sonner";
+import {
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@onecli/ui/components/dialog";
+import { Button } from "@onecli/ui/components/button";
+import { Input } from "@onecli/ui/components/input";
+import { Badge } from "@onecli/ui/components/badge";
+import { Checkbox } from "@onecli/ui/components/checkbox";
+import { MAX_GROUP_MEMBERS } from "@onecli/api/validations/org";
+import { useOrgMembersList } from "@/hooks/use-org-members";
+import { useGroupMembers, useSetGroupMembers } from "@/hooks/use-groups";
+
+export interface GroupMembersDialogProps {
+ groupId: string;
+ groupName: string;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+/**
+ * Replace-set member picker for one group: candidates are the org's members
+ * (`useOrgMembersList`), the current set is the group's members, and Save PUTs
+ * the exact selection back. Scales via a filter + select-all/clear, with a
+ * viewport-bounded scroll list so the dialog never overflows.
+ */
+export const GroupMembersDialog = ({
+ groupId,
+ groupName,
+ open,
+ onOpenChange,
+}: GroupMembersDialogProps) => {
+ const {
+ data: candidates = [],
+ isPending: candidatesPending,
+ isError: candidatesError,
+ } = useOrgMembersList(open);
+ const {
+ data: current = [],
+ isPending: currentPending,
+ isError: currentError,
+ } = useGroupMembers(groupId, open);
+ const setMembers = useSetGroupMembers();
+ const isPending = candidatesPending || currentPending;
+ // Either feed failing must surface as an ERROR, never an empty baseline:
+ // this is a replace-set picker, so seeding from a failed current-members
+ // read would render every real member unchecked and let one toggle + Save
+ // silently wipe the group's membership.
+ const isError = candidatesError || currentError;
+
+ const [selected, setSelected] = useState>(() => new Set());
+ const [saving, setSaving] = useState(false);
+ const [search, setSearch] = useState("");
+
+ const initialSelected = useMemo(
+ () => new Set(current.map((m) => m.userId)),
+ [current],
+ );
+
+ // Seed the edit buffer once per open, once both feeds load — guarded so a
+ // background refetch can't clobber in-progress edits. Search clears on close.
+ const seededRef = useRef(false);
+ useEffect(() => {
+ if (!open) {
+ seededRef.current = false;
+ setSearch("");
+ return;
+ }
+ if (seededRef.current || isPending || isError) return;
+ setSelected(new Set(initialSelected));
+ seededRef.current = true;
+ }, [open, isPending, isError, initialSelected]);
+
+ const filteredCandidates = useMemo(() => {
+ const q = search.trim().toLowerCase();
+ if (!q) return candidates;
+ return candidates.filter(
+ (m) =>
+ m.email.toLowerCase().includes(q) ||
+ (m.name ?? "").toLowerCase().includes(q),
+ );
+ }, [candidates, search]);
+
+ const dirty = useMemo(() => {
+ if (selected.size !== initialSelected.size) return true;
+ for (const id of selected) if (!initialSelected.has(id)) return true;
+ return false;
+ }, [selected, initialSelected]);
+
+ const toggle = (userId: string) => {
+ setSelected((prev) => {
+ const next = new Set(prev);
+ if (next.has(userId)) next.delete(userId);
+ else next.add(userId);
+ return next;
+ });
+ };
+
+ // Select-all/clear act on ALL candidates, not just the filtered view. A
+ // group caps at MAX_GROUP_MEMBERS server-side, so past that many candidates
+ // Select-all can't produce a saveable set — disable it and say why rather
+ // than let Save PUT an oversized set that fails validation with a raw 422.
+ const selectAllExceedsCap = candidates.length > MAX_GROUP_MEMBERS;
+ const selectAll = () => setSelected(new Set(candidates.map((m) => m.userId)));
+ const clearAll = () => setSelected(new Set());
+
+ const handleSave = async () => {
+ setSaving(true);
+ try {
+ await setMembers.mutateAsync({ groupId, userIds: [...selected] });
+ onOpenChange(false);
+ toast.success("Group members updated");
+ } catch {
+ // The mutation hook already toasts the server reason — just keep the
+ // dialog open so the selection isn't lost.
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+
+
+ Members of {groupName}
+
+ Choose which organization members belong to this group. Project
+ access granted to the group follows its membership.
+
+
+
+
+ {isError ? (
+
+
+
+
+ Couldn't load members
+
+
+ Something went wrong fetching the member lists. Close the
+ dialog and try again.
+
+
+
+ ) : isPending ? (
+
+
+
+ ) : candidates.length === 0 ? (
+
+
+
+
+
No members yet
+
+ Invite teammates from the Team page to add them to groups.
+
+
+ ) : (
+
+ {/* Search */}
+
+
+ setSearch(e.target.value)}
+ className="h-8 pl-8 text-sm"
+ />
+
+
+ {/* Toolbar: count + bulk actions */}
+
+
+
+ {selected.size}
+ {" "}
+ of {candidates.length} selected
+
+
+
+ Select all
+
+ /
+
+ Clear
+
+
+
+
+ {/* List — a native max-height scroller: it shrinks to fit a few
+ members and caps at the viewport, scrolling the rows for many.
+ (A Radix ScrollArea can't scroll under `max-height` — its
+ viewport needs a *definite* height — so it would clip instead
+ of scroll; a plain overflow container is correct here.) */}
+
+
+ {filteredCandidates.map((memberRow) => (
+
+ toggle(memberRow.userId)}
+ />
+
+
+ {memberRow.name ?? memberRow.email}
+
+ {memberRow.name && (
+
+ {memberRow.email}
+
+ )}
+
+ {memberRow.status === "suspended" && (
+
+ Suspended
+
+ )}
+
+ ))}
+
+ {filteredCandidates.length === 0 && (
+
+ No members match “{search}”
+
+ )}
+
+
+
+ )}
+
+
+
+ onOpenChange(false)}>
+ Cancel
+
+
+ {saving ? "Saving..." : "Save"}
+
+
+
+
+ );
+};
diff --git a/apps/web/src/app/(dashboard)/groups/_components/group-row-actions.tsx b/apps/web/src/app/(dashboard)/groups/_components/group-row-actions.tsx
new file mode 100644
index 00000000..12aa3d3a
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/groups/_components/group-row-actions.tsx
@@ -0,0 +1,203 @@
+"use client";
+
+import { useState } from "react";
+import { MoreHorizontal, Loader2 } from "lucide-react";
+import { Button } from "@onecli/ui/components/button";
+import { Input } from "@onecli/ui/components/input";
+import { Label } from "@onecli/ui/components/label";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@onecli/ui/components/dropdown-menu";
+import {
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@onecli/ui/components/dialog";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@onecli/ui/components/alert-dialog";
+import { useRenameGroup, useDeleteGroup } from "@/hooks/use-groups";
+import type { GroupRow } from "@/lib/api";
+import { GroupMembersDialog } from "./group-members-dialog";
+
+export interface GroupRowActionsProps {
+ group: GroupRow;
+}
+
+export const GroupRowActions = ({ group }: GroupRowActionsProps) => {
+ const [renameOpen, setRenameOpen] = useState(false);
+ const [membersOpen, setMembersOpen] = useState(false);
+ const [deleteOpen, setDeleteOpen] = useState(false);
+ const [name, setName] = useState(group.name);
+ const rename = useRenameGroup();
+ const remove = useDeleteGroup();
+ // SCIM-sourced rows (possible after an EE-to-OSS migration) are read-only:
+ // every mutation deterministically 409s server-side, so offering the
+ // actions would only surface error toasts.
+ const isManual = group.source === "manual";
+
+ const trimmed = name.trim();
+ const nameError =
+ trimmed.length === 0
+ ? "Name is required."
+ : trimmed.length > 100
+ ? "Name must be 100 characters or fewer."
+ : null;
+
+ const handleRenameOpen = (open: boolean) => {
+ if (open) setName(group.name);
+ setRenameOpen(open);
+ };
+
+ const handleRename = () => {
+ if (nameError || rename.isPending) return;
+ rename.mutate(
+ { groupId: group.id, name: trimmed },
+ { onSuccess: () => setRenameOpen(false) },
+ );
+ };
+
+ const handleDelete = () => {
+ remove.mutate(group.id, { onSuccess: () => setDeleteOpen(false) });
+ };
+
+ return (
+ <>
+
+
+
+ {rename.isPending || remove.isPending ? (
+
+ ) : (
+
+ )}
+
+
+
+ {!isManual && (
+
+ Managed by your identity provider
+
+ )}
+ handleRenameOpen(true)}
+ >
+ Rename
+
+ setMembersOpen(true)}
+ >
+ Manage members
+
+
+ setDeleteOpen(true)}
+ >
+ Delete
+
+
+
+
+
+
+
+ Rename {group.name}
+
+
+
Name
+
setName(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") handleRename();
+ }}
+ autoFocus
+ />
+ {nameError && name !== group.name && (
+
{nameError}
+ )}
+
+
+ handleRenameOpen(false)}>
+ Cancel
+
+
+ {rename.isPending ? "Renaming..." : "Rename"}
+
+
+
+
+
+
+
+
+
+
+ Delete {group.name}?
+
+ {/* The impact counts matter: the project-access cascade is a
+ silent access revocation. */}
+ This removes the group and its {group.memberCount} membership
+ {group.memberCount === 1 ? "" : "s"}. Any project access granted
+ through this group is revoked immediately. This cannot be undone.
+
+
+
+
+ Cancel
+
+ {
+ e.preventDefault();
+ handleDelete();
+ }}
+ disabled={remove.isPending}
+ >
+ {remove.isPending ? (
+ <>
+
+ Deleting...
+ >
+ ) : (
+ "Delete"
+ )}
+
+
+
+
+ >
+ );
+};
diff --git a/apps/web/src/app/(dashboard)/groups/_components/groups-content.tsx b/apps/web/src/app/(dashboard)/groups/_components/groups-content.tsx
new file mode 100644
index 00000000..5479fe50
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/groups/_components/groups-content.tsx
@@ -0,0 +1,47 @@
+"use client";
+
+import { Card } from "@onecli/ui/components/card";
+import { Skeleton } from "@onecli/ui/components/skeleton";
+import { useGroups } from "@/hooks/use-groups";
+import { AdminOnlyNotice } from "./admin-only-notice";
+import { LocalModeNotice } from "./local-mode-notice";
+import { GroupsTable } from "./groups-table";
+
+export interface GroupsContentProps {
+ /** Threaded from the RSC page (server-only auth mode); false = local mode. */
+ groupsEnabled: boolean;
+}
+
+export const GroupsContent = ({ groupsEnabled }: GroupsContentProps) => {
+ // The groups query's 403 is the admin authority (the /team D-K pattern): a
+ // non-admin gets a deterministic error and the surface renders the
+ // admin-only notice — the API gates the whole router on admin anyway.
+ const groups = useGroups(groupsEnabled);
+
+ // Local mode has a single built-in identity, so groups are inert — return
+ // before the query's pending/error branches so no doomed request fires
+ // against an unreachable org backend (matches TeamContent's ordering).
+ if (!groupsEnabled) return ;
+
+ if (groups.isPending) {
+ return (
+
+ {[1, 2].map((i) => (
+
+
+
+ ))}
+
+ );
+ }
+
+ if (groups.isError) return ;
+
+ return ;
+};
diff --git a/apps/web/src/app/(dashboard)/groups/_components/groups-table.tsx b/apps/web/src/app/(dashboard)/groups/_components/groups-table.tsx
new file mode 100644
index 00000000..c69e3f37
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/groups/_components/groups-table.tsx
@@ -0,0 +1,91 @@
+"use client";
+
+import { useState } from "react";
+import { Plus, UsersRound } from "lucide-react";
+import { Button } from "@onecli/ui/components/button";
+import { Badge } from "@onecli/ui/components/badge";
+import { Card } from "@onecli/ui/components/card";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@onecli/ui/components/table";
+import type { GroupRow } from "@/lib/api";
+import { GroupRowActions } from "./group-row-actions";
+import { CreateGroupDialog } from "./create-group-dialog";
+
+export interface GroupsTableProps {
+ groups: GroupRow[];
+}
+
+// No error prop: the parent (groups-content) early-returns AdminOnlyNotice on
+// the groups query's error, so this table only renders with a live feed.
+export const GroupsTable = ({ groups }: GroupsTableProps) => {
+ const [createOpen, setCreateOpen] = useState(false);
+
+ return (
+
+
+
setCreateOpen(true)}>
+
+ Create group
+
+
+ {groups.length === 0 ? (
+
+
+
+
+ No groups yet
+
+ Create a group to organize members for project access and policy.
+
+
+ ) : (
+
+
+
+
+ Name
+ Members
+ Created
+
+
+
+
+ {groups.map((row) => (
+
+
+ {row.name}
+ {row.source === "scim" && (
+
+ IdP-managed
+
+ )}
+
+
+ {row.memberCount}
+
+
+ {new Date(row.createdAt).toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ })}
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+
+ );
+};
diff --git a/apps/web/src/app/(dashboard)/groups/_components/local-mode-notice.tsx b/apps/web/src/app/(dashboard)/groups/_components/local-mode-notice.tsx
new file mode 100644
index 00000000..2dbdd06f
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/groups/_components/local-mode-notice.tsx
@@ -0,0 +1,21 @@
+import { UsersRound } from "lucide-react";
+import { Card } from "@onecli/ui/components/card";
+
+/**
+ * Local auth mode has exactly one identity, so groups are inert — there is
+ * nobody to group.
+ */
+export const LocalModeNotice = () => (
+
+
+
+
+ Groups are unavailable in local mode
+
+ This instance runs in local auth mode, which has exactly one built-in
+ identity (admin@localhost) — there is nobody to group. To invite teammates
+ and group them, configure Google OAuth (NEXTAUTH_SECRET +
+ GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET) and restart.
+
+
+);
diff --git a/apps/web/src/app/(dashboard)/groups/loading.tsx b/apps/web/src/app/(dashboard)/groups/loading.tsx
new file mode 100644
index 00000000..447a4c2a
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/groups/loading.tsx
@@ -0,0 +1,27 @@
+import { Card } from "@onecli/ui/components/card";
+import { Skeleton } from "@onecli/ui/components/skeleton";
+import { PageHeader } from "@dashboard/page-header";
+
+export default function GroupsLoading() {
+ return (
+
+
+
+ {[1, 2].map((i) => (
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/apps/web/src/app/(dashboard)/groups/page.tsx b/apps/web/src/app/(dashboard)/groups/page.tsx
new file mode 100644
index 00000000..d463f257
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/groups/page.tsx
@@ -0,0 +1,30 @@
+import { Suspense } from "react";
+import type { Metadata } from "next";
+import { PageHeader } from "@dashboard/page-header";
+import { getAuthMode } from "@/lib/auth/auth-mode";
+import { GroupsContent } from "./_components/groups-content";
+
+export const metadata: Metadata = {
+ title: "Groups",
+};
+
+export default function GroupsPage() {
+ // Auth mode is server-only (fs-backed runtime config), so it is resolved
+ // here and threaded down as a prop (the TeamContent precedent). Local mode
+ // gates groups entirely — one built-in identity means nobody to group. No
+ // server-side auth/role resolution at page level — no dashboard page does
+ // it, and the API's 403 is the authority on who is an admin.
+ const groupsEnabled = getAuthMode() !== "local";
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/lib/nav-config.ts b/apps/web/src/lib/nav-config.ts
index a34a1a38..0cb6bb93 100644
--- a/apps/web/src/lib/nav-config.ts
+++ b/apps/web/src/lib/nav-config.ts
@@ -6,6 +6,7 @@ import {
Activity,
User,
Users,
+ UsersRound,
KeyRound,
ShieldCheck,
Globe,
@@ -31,6 +32,10 @@ export const navItems: NavItem[] = [
// Always visible (D-J): the page itself degrades for non-admins and in
// local auth mode — hiding the item would require a session role field.
{ title: "Team", url: "/team", icon: Users },
+ // Always visible (D-J): the page itself degrades for non-admins and gates
+ // groups in local auth mode — hiding the item would require a session role
+ // field.
+ { title: "Groups", url: "/groups", icon: UsersRound },
{ title: "Settings", url: "/settings", icon: Settings },
];
diff --git a/packages/api/src/routes/org/groups.test.ts b/packages/api/src/routes/org/groups.test.ts
new file mode 100644
index 00000000..d901f7a9
--- /dev/null
+++ b/packages/api/src/routes/org/groups.test.ts
@@ -0,0 +1,1388 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { Hono } from "hono";
+import type { ApiEnv } from "../../types";
+
+// `/v1/org/groups` end-to-end through the real app: the OSS org routes
+// mounted on the `eeRoutes` seam, the OSS role resolver wired as the
+// RoleResolver, and `CAPS.rbac` on. Admin callers arrive with an org API key
+// (whose key path re-checks admin through the resolver); the non-admin cases
+// use a session, since a non-admin's org key fails key authentication
+// outright. (Same harness as invitations.test.ts / members.test.ts — cloned,
+// not shared.)
+//
+// Reconciliation Stage C ships USER GROUPS ONLY. Role automation
+// (group→org-role mappings) and policy-rule orphan neutralization are separate
+// later stages and are NOT exercised here — the OSS grants engine already
+// treats a rule identity orphaned by an FK cascade as inert, so a group delete
+// needs no explicit neutralization pass.
+
+const ORG = "org-1";
+const OTHER_ORG = "org-2";
+const OWNER = "user-owner";
+const ADMIN = "user-admin";
+const MEMBER = "user-member";
+const OUTSIDER = "user-outsider";
+const ADMIN_KEY = "oc_org_admin-key";
+const PROJECT_KEY = "oc_project-key-of-owner";
+
+vi.hoisted(() => {
+ process.env.NEXT_PUBLIC_EDITION = "oss";
+ process.env.SECRET_ENCRYPTION_KEY = "test-secret";
+ process.env.OAUTH_STATE_SECRET = "test-secret";
+});
+
+interface MemberRow {
+ organizationId: string;
+ userId: string;
+ role: string;
+ status: string;
+ ssoExempt: boolean;
+ suspendedAt: Date | null;
+ createdAt: Date;
+}
+
+interface UserRow {
+ id: string;
+ externalAuthId: string;
+ email: string;
+ name: string | null;
+}
+
+interface GroupRow {
+ id: string;
+ organizationId: string;
+ name: string;
+ source: string;
+ externalId: string | null;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+interface GroupMemberRow {
+ groupId: string;
+ userId: string;
+ createdByUserId: string | null;
+ createdAt: Date;
+}
+
+interface ProjectAccessRow {
+ id: string;
+ projectId: string;
+ groupId: string;
+}
+
+interface AuditRow {
+ organizationId?: string;
+ userId: string;
+ action: string;
+ service: string;
+ source: string;
+ metadata: Record;
+}
+
+const store = vi.hoisted(() => ({
+ members: [] as MemberRow[],
+ users: [] as UserRow[],
+ groups: [] as GroupRow[],
+ groupMembers: [] as GroupMemberRow[],
+ projectAccess: [] as ProjectAccessRow[],
+ audits: [] as AuditRow[],
+ seq: 0,
+ txCount: 0,
+ /** Simulate a create-create race: the name pre-check misses, create P2002s. */
+ race: false,
+ /** Which user the session provider resolves to (null = no session). */
+ sessionUserId: null as string | null,
+}));
+
+vi.mock("@onecli/db", () => {
+ class PrismaClientKnownRequestError extends Error {
+ code: string;
+ constructor(message: string, code: string) {
+ super(message);
+ this.code = code;
+ }
+ }
+
+ // The subset of the Prisma `where` shapes these routes actually build.
+ interface KeysetClause {
+ createdAt?: Date | { gt?: Date };
+ id?: { gt: string };
+ userId?: { gt: string };
+ }
+ interface GroupWhere {
+ id?: string | { not: string };
+ organizationId?: string;
+ source?: string;
+ name?: string | { contains: string };
+ /** The keyset predicate — the service nests it under AND, never top-level. */
+ AND?: { OR: KeysetClause[] }[];
+ }
+ interface GroupSelect {
+ id?: boolean;
+ name?: boolean;
+ source?: boolean;
+ externalId?: boolean;
+ createdAt?: boolean;
+ updatedAt?: boolean;
+ _count?: { select: { members?: boolean; projectAccess?: boolean } };
+ }
+ interface GroupMemberWhere {
+ groupId?: string | { in: string[] };
+ userId?: string | { in: string[] };
+ user?: {
+ OR: { email?: { contains: string }; name?: { contains: string } }[];
+ };
+ AND?: { OR: KeysetClause[] }[];
+ }
+ interface OrgMemberWhere {
+ organizationId?: string;
+ userId?: string | { in: string[] };
+ role?: string | { not?: string };
+ status?: string | { not?: string };
+ }
+
+ const matchesKeyset = (
+ row: { createdAt: Date; id?: string; userId?: string },
+ filter: { OR: KeysetClause[] }[] | undefined,
+ ) => {
+ if (!filter) return true;
+ return filter.every((conjunct) =>
+ conjunct.OR.some((clause) => {
+ if (clause.createdAt instanceof Date) {
+ if (row.createdAt.getTime() !== clause.createdAt.getTime())
+ return false;
+ if (clause.id !== undefined && row.id !== undefined)
+ return row.id > clause.id.gt;
+ if (clause.userId !== undefined && row.userId !== undefined)
+ return row.userId > clause.userId.gt;
+ return false;
+ }
+ const gt = clause.createdAt?.gt;
+ return gt !== undefined && row.createdAt.getTime() > gt.getTime();
+ }),
+ );
+ };
+
+ const filterGroups = (where: GroupWhere) =>
+ store.groups.filter((row) => {
+ if (typeof where.id === "string" && row.id !== where.id) return false;
+ if (
+ typeof where.id === "object" &&
+ where.id !== null &&
+ row.id === where.id.not
+ )
+ return false;
+ if (
+ where.organizationId !== undefined &&
+ row.organizationId !== where.organizationId
+ )
+ return false;
+ if (where.source !== undefined && row.source !== where.source)
+ return false;
+ if (typeof where.name === "string" && row.name !== where.name)
+ return false;
+ if (
+ typeof where.name === "object" &&
+ where.name !== null &&
+ !row.name.toLowerCase().includes(where.name.contains.toLowerCase())
+ )
+ return false;
+ return matchesKeyset(row, where.AND);
+ });
+
+ // Mirror Prisma's `select` (incl. `_count`) so a route can't accidentally
+ // leak a column the service didn't ask for.
+ const pickGroup = (row: GroupRow, select?: GroupSelect) => {
+ if (!select) return { ...row };
+ const picked: Record = {};
+ for (const key of [
+ "id",
+ "name",
+ "source",
+ "externalId",
+ "createdAt",
+ "updatedAt",
+ ] as const) {
+ if (select[key]) picked[key] = row[key];
+ }
+ if (select._count) {
+ const count: Record = {};
+ if (select._count.select.members) {
+ count.members = store.groupMembers.filter(
+ (m) => m.groupId === row.id,
+ ).length;
+ }
+ if (select._count.select.projectAccess) {
+ count.projectAccess = store.projectAccess.filter(
+ (pa) => pa.groupId === row.id,
+ ).length;
+ }
+ picked._count = count;
+ }
+ return picked;
+ };
+
+ const filterGroupMembers = (where: GroupMemberWhere) =>
+ store.groupMembers.filter((row) => {
+ if (typeof where.groupId === "string" && row.groupId !== where.groupId)
+ return false;
+ if (
+ typeof where.groupId === "object" &&
+ where.groupId !== null &&
+ !where.groupId.in.includes(row.groupId)
+ )
+ return false;
+ if (typeof where.userId === "string" && row.userId !== where.userId)
+ return false;
+ if (
+ typeof where.userId === "object" &&
+ where.userId !== null &&
+ !where.userId.in.includes(row.userId)
+ )
+ return false;
+ if (where.user) {
+ const user = store.users.find((u) => u.id === row.userId);
+ if (!user) return false;
+ const hit = where.user.OR.some((clause) => {
+ if (clause.email)
+ return user.email
+ .toLowerCase()
+ .includes(clause.email.contains.toLowerCase());
+ if (clause.name)
+ return (user.name ?? "")
+ .toLowerCase()
+ .includes(clause.name.contains.toLowerCase());
+ return false;
+ });
+ if (!hit) return false;
+ }
+ return matchesKeyset(row, where.AND);
+ });
+
+ const findMember = (organizationId: string, userId: string) =>
+ store.members.find(
+ (row) => row.organizationId === organizationId && row.userId === userId,
+ );
+
+ const filterOrgMembers = (where: OrgMemberWhere) =>
+ store.members.filter((row) => {
+ if (
+ where.organizationId !== undefined &&
+ row.organizationId !== where.organizationId
+ )
+ return false;
+ if (typeof where.userId === "string" && row.userId !== where.userId)
+ return false;
+ if (
+ typeof where.userId === "object" &&
+ where.userId !== null &&
+ !where.userId.in.includes(row.userId)
+ )
+ return false;
+ if (where.status !== undefined) {
+ const ok =
+ typeof where.status === "string"
+ ? row.status === where.status
+ : where.status.not === undefined || row.status !== where.status.not;
+ if (!ok) return false;
+ }
+ if (where.role !== undefined) {
+ const ok =
+ typeof where.role === "string"
+ ? row.role === where.role
+ : where.role.not === undefined || row.role !== where.role.not;
+ if (!ok) return false;
+ }
+ return true;
+ });
+
+ const dbGroup = {
+ findFirst: async ({
+ where,
+ select,
+ }: {
+ where: GroupWhere;
+ select?: GroupSelect;
+ }) => {
+ // Race simulation: the create pre-check (a name-keyed findFirst)
+ // misses, so the create itself must surface the P2002.
+ if (store.race && where.name !== undefined) return null;
+ const row = filterGroups(where)[0];
+ return row ? pickGroup(row, select) : null;
+ },
+ findMany: async ({
+ where,
+ select,
+ take,
+ }: {
+ where: GroupWhere;
+ select?: GroupSelect;
+ take?: number;
+ }) => {
+ const rows = filterGroups(where)
+ .slice()
+ .sort(
+ (a, b) =>
+ a.createdAt.getTime() - b.createdAt.getTime() ||
+ a.id.localeCompare(b.id),
+ );
+ const limited = take === undefined ? rows : rows.slice(0, take);
+ return limited.map((row) => pickGroup(row, select));
+ },
+ create: async ({
+ data,
+ select,
+ }: {
+ data: {
+ organizationId: string;
+ name: string;
+ source: string;
+ externalId?: string | null;
+ };
+ select?: GroupSelect;
+ }) => {
+ const dupe = store.groups.some(
+ (g) => g.organizationId === data.organizationId && g.name === data.name,
+ );
+ if (dupe) {
+ throw new PrismaClientKnownRequestError(
+ "Unique constraint failed",
+ "P2002",
+ );
+ }
+ const row: GroupRow = {
+ id: `g-${++store.seq}`,
+ organizationId: data.organizationId,
+ name: data.name,
+ source: data.source,
+ externalId: data.externalId ?? null,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+ store.groups.push(row);
+ return pickGroup(row, select);
+ },
+ // Org-scoped conditional write (the rename path): unique violations
+ // surface as P2002, a filter miss as count 0.
+ updateMany: async ({
+ where,
+ data,
+ }: {
+ where: GroupWhere;
+ data: { name: string };
+ }) => {
+ const rows = filterGroups(where);
+ for (const row of rows) {
+ const dupe = store.groups.some(
+ (g) =>
+ g.organizationId === row.organizationId &&
+ g.name === data.name &&
+ g.id !== row.id,
+ );
+ if (dupe) {
+ throw new PrismaClientKnownRequestError(
+ "Unique constraint failed",
+ "P2002",
+ );
+ }
+ row.name = data.name;
+ row.updatedAt = new Date();
+ }
+ return { count: rows.length };
+ },
+ // Delete applies the DB cascades the shipped migration declares:
+ // GroupMember and ProjectAccess group bindings go with the row (as do
+ // GroupRoleMapping and PolicyRuleIdentity in stages that populate them).
+ deleteMany: async ({ where }: { where: GroupWhere }) => {
+ const rows = filterGroups(where);
+ for (const row of rows) {
+ store.groupMembers = store.groupMembers.filter(
+ (m) => m.groupId !== row.id,
+ );
+ store.projectAccess = store.projectAccess.filter(
+ (pa) => pa.groupId !== row.id,
+ );
+ }
+ const ids = new Set(rows.map((r) => r.id));
+ store.groups = store.groups.filter((g) => !ids.has(g.id));
+ return { count: rows.length };
+ },
+ };
+
+ return {
+ Prisma: { JsonNull: null, PrismaClientKnownRequestError },
+ db: {
+ apiKey: {
+ findUnique: async ({ where }: { where: { key?: string } }) => {
+ if (where.key === "oc_org_admin-key")
+ return {
+ userId: "user-admin",
+ organizationId: "org-1",
+ scope: "organization",
+ };
+ // A PROJECT-scoped key owned by the org's OWNER: it authenticates
+ // fine, which is exactly why the router needs its own scope guard.
+ if (where.key === "oc_project-key-of-owner")
+ return { userId: "user-owner", projectId: "proj-1" };
+ return null;
+ },
+ findFirst: async () => null,
+ findMany: async () => [],
+ },
+ user: {
+ findUnique: async ({
+ where,
+ select,
+ }: {
+ where: { id?: string; externalAuthId?: string; email?: string };
+ select?: Record;
+ }) => {
+ if (select?.organizationMemberships) {
+ return {
+ organizationMemberships: store.members
+ .filter((m) => m.userId === where.id)
+ .map((m) => ({ organizationId: m.organizationId })),
+ };
+ }
+ return (
+ store.users.find(
+ (u) =>
+ (where.id !== undefined && u.id === where.id) ||
+ (where.externalAuthId !== undefined &&
+ u.externalAuthId === where.externalAuthId) ||
+ (where.email !== undefined && u.email === where.email),
+ ) ?? null
+ );
+ },
+ },
+ organizationMember: {
+ findUnique: async ({
+ where,
+ }: {
+ where: {
+ organizationId_userId: { organizationId: string; userId: string };
+ };
+ }) => {
+ const { organizationId, userId } = where.organizationId_userId;
+ return findMember(organizationId, userId) ?? null;
+ },
+ // The session auth path resolves membership through these — a stub
+ // returning null would read every session caller as org-less (401).
+ findFirst: async ({
+ where,
+ }: {
+ where: {
+ organizationId?: string;
+ userId?: string;
+ status?: string | { not?: string };
+ };
+ }) =>
+ store.members.find(
+ (row) =>
+ (where.organizationId === undefined ||
+ row.organizationId === where.organizationId) &&
+ (where.userId === undefined || row.userId === where.userId) &&
+ (where.status === undefined ||
+ (typeof where.status === "string"
+ ? row.status === where.status
+ : where.status.not === undefined ||
+ row.status !== where.status.not)),
+ ) ?? null,
+ // THE membership-validation query: { organizationId, userId: { in } }.
+ findMany: async ({
+ where,
+ select,
+ }: {
+ where: OrgMemberWhere;
+ select?: { userId?: boolean; role?: boolean };
+ }) =>
+ filterOrgMembers(where).map((row) => {
+ if (!select) return { ...row };
+ const picked: Record = {};
+ if (select.userId) picked.userId = row.userId;
+ if (select.role) picked.role = row.role;
+ return picked;
+ }),
+ count: async () => 0,
+ },
+ group: dbGroup,
+ groupMember: {
+ findUnique: async ({
+ where,
+ }: {
+ where: { groupId_userId: { groupId: string; userId: string } };
+ }) => {
+ const { groupId, userId } = where.groupId_userId;
+ const row = store.groupMembers.find(
+ (m) => m.groupId === groupId && m.userId === userId,
+ );
+ return row ? { userId: row.userId } : null;
+ },
+ findMany: async ({
+ where,
+ select,
+ take,
+ }: {
+ where: GroupMemberWhere;
+ select?: {
+ groupId?: boolean;
+ userId?: boolean;
+ createdAt?: boolean;
+ user?: { select: { email?: boolean; name?: boolean } };
+ };
+ take?: number;
+ }) => {
+ const rows = filterGroupMembers(where)
+ .slice()
+ .sort(
+ (a, b) =>
+ a.createdAt.getTime() - b.createdAt.getTime() ||
+ a.userId.localeCompare(b.userId),
+ );
+ const limited = take === undefined ? rows : rows.slice(0, take);
+ return limited.map((row) => {
+ if (!select) return { ...row };
+ const picked: Record = {};
+ if (select.groupId) picked.groupId = row.groupId;
+ if (select.userId) picked.userId = row.userId;
+ if (select.createdAt) picked.createdAt = row.createdAt;
+ if (select.user) {
+ const user = store.users.find((u) => u.id === row.userId);
+ picked.user = {
+ email: user?.email ?? "missing@example.com",
+ name: user?.name ?? null,
+ };
+ }
+ return picked;
+ });
+ },
+ upsert: async ({
+ where,
+ create,
+ }: {
+ where: { groupId_userId: { groupId: string; userId: string } };
+ create: GroupMemberRow;
+ }) => {
+ const { groupId, userId } = where.groupId_userId;
+ const existing = store.groupMembers.find(
+ (m) => m.groupId === groupId && m.userId === userId,
+ );
+ if (existing) return existing;
+ const row: GroupMemberRow = { ...create, createdAt: new Date() };
+ store.groupMembers.push(row);
+ return row;
+ },
+ createMany: async ({
+ data,
+ }: {
+ data: { groupId: string; userId: string; createdByUserId: string }[];
+ skipDuplicates?: boolean;
+ }) => {
+ let count = 0;
+ for (const d of data) {
+ const exists = store.groupMembers.some(
+ (m) => m.groupId === d.groupId && m.userId === d.userId,
+ );
+ if (exists) continue; // skipDuplicates
+ store.groupMembers.push({ ...d, createdAt: new Date() });
+ count++;
+ }
+ return { count };
+ },
+ deleteMany: async ({ where }: { where: GroupMemberWhere }) => {
+ const rows = filterGroupMembers(where);
+ const keys = new Set(rows.map((r) => `${r.groupId}:${r.userId}`));
+ const before = store.groupMembers.length;
+ store.groupMembers = store.groupMembers.filter(
+ (m) => !keys.has(`${m.groupId}:${m.userId}`),
+ );
+ return { count: before - store.groupMembers.length };
+ },
+ },
+ project: {
+ findFirst: async () => ({ id: "proj-1", organizationId: "org-1" }),
+ findUnique: async () => ({ id: "proj-1", organizationId: "org-1" }),
+ },
+ projectAccess: { findFirst: async () => null },
+ auditLog: {
+ create: async ({ data }: { data: AuditRow }) => {
+ store.audits.push(data);
+ return data;
+ },
+ },
+ // The replace-set writer runs its delete+create under ONE array-form
+ // transaction; the delete path is a plain conditional deleteMany.
+ $transaction: async (arg: unknown) => {
+ store.txCount++;
+ if (typeof arg === "function") {
+ return (arg as (tx: unknown) => Promise)({ group: dbGroup });
+ }
+ return Promise.all(arg as Promise[]);
+ },
+ },
+ };
+});
+
+import { createApiApp } from "../../app";
+import { registerOssOrgRoutes } from "./index";
+import { ossRoleResolver } from "../../services/org-role-resolver";
+
+const sessionProvider = {
+ getSession: async () => {
+ const user = store.users.find((u) => u.id === store.sessionUserId);
+ return user ? { id: user.externalAuthId, email: user.email } : null;
+ },
+};
+
+const app: Hono = createApiApp(sessionProvider, {
+ eeRoutes: registerOssOrgRoutes,
+ roleResolver: ossRoleResolver,
+});
+
+const at = (minutes: number) => new Date(Date.UTC(2026, 0, 1, 0, minutes));
+
+const member = (
+ userId: string,
+ role: string,
+ createdAt: Date,
+ organizationId = ORG,
+): MemberRow => ({
+ organizationId,
+ userId,
+ role,
+ status: "active",
+ ssoExempt: false,
+ suspendedAt: null,
+ createdAt,
+});
+
+const group = (
+ id: string,
+ name: string,
+ overrides: Partial = {},
+): GroupRow => ({
+ id,
+ organizationId: ORG,
+ name,
+ source: "manual",
+ externalId: null,
+ createdAt: at(10),
+ updatedAt: at(10),
+ ...overrides,
+});
+
+beforeEach(() => {
+ store.users = [
+ {
+ id: OWNER,
+ externalAuthId: "ext-owner",
+ email: "owner@example.com",
+ name: "Olive Owner",
+ },
+ {
+ id: ADMIN,
+ externalAuthId: "ext-admin",
+ email: "admin@example.com",
+ name: "Adam Admin",
+ },
+ {
+ id: MEMBER,
+ externalAuthId: "ext-member",
+ email: "member@elsewhere.test",
+ name: null,
+ },
+ {
+ id: OUTSIDER,
+ externalAuthId: "ext-outsider",
+ email: "outsider@other.test",
+ name: "Odette Outsider",
+ },
+ ];
+ store.members = [
+ member(OWNER, "owner", at(0)),
+ member(ADMIN, "admin", at(1)),
+ member(MEMBER, "member", at(2)),
+ member(OUTSIDER, "admin", at(3), OTHER_ORG),
+ ];
+ store.groups = [
+ group("g-a", "Engineering", { createdAt: at(10), updatedAt: at(10) }),
+ group("g-b", "Design", { createdAt: at(11), updatedAt: at(11) }),
+ group("g-scim", "Provisioned", {
+ source: "scim",
+ externalId: "idp-77",
+ createdAt: at(12),
+ updatedAt: at(12),
+ }),
+ // A group in a DIFFERENT org — never visible through this org's routes.
+ group("g-x", "Foreign", { organizationId: OTHER_ORG, createdAt: at(13) }),
+ ];
+ store.groupMembers = [
+ {
+ groupId: "g-a",
+ userId: OWNER,
+ createdByUserId: ADMIN,
+ createdAt: at(20),
+ },
+ {
+ groupId: "g-a",
+ userId: ADMIN,
+ createdByUserId: ADMIN,
+ createdAt: at(21),
+ },
+ {
+ groupId: "g-scim",
+ userId: MEMBER,
+ createdByUserId: null,
+ createdAt: at(22),
+ },
+ // Membership of the foreign group, for cross-org isolation checks.
+ {
+ groupId: "g-x",
+ userId: OUTSIDER,
+ createdByUserId: null,
+ createdAt: at(23),
+ },
+ ];
+ store.projectAccess = [{ id: "pa-1", projectId: "proj-1", groupId: "g-a" }];
+ store.audits = [];
+ store.seq = 100;
+ store.txCount = 0;
+ store.race = false;
+ store.sessionUserId = null;
+});
+
+const groupRow = (id: string) => store.groups.find((g) => g.id === id);
+const membersOf = (groupId: string) =>
+ store.groupMembers
+ .filter((m) => m.groupId === groupId)
+ .map((m) => m.userId)
+ .sort();
+
+const asAdmin = { headers: { Authorization: `Bearer ${ADMIN_KEY}` } };
+const asProjectKey = { headers: { Authorization: `Bearer ${PROJECT_KEY}` } };
+
+interface GroupListBody {
+ data: {
+ id: string;
+ name: string;
+ source: string;
+ externalId: string | null;
+ memberCount: number;
+ createdAt: string;
+ updatedAt: string;
+ }[];
+ nextCursor: string | null;
+}
+
+interface MemberListBody {
+ data: {
+ userId: string;
+ email: string;
+ name: string | null;
+ addedAt: string;
+ }[];
+ nextCursor: string | null;
+}
+
+const list = async (query = ""): Promise => {
+ const res = await app.request(`/v1/org/groups${query}`, asAdmin);
+ expect(res.status).toBe(200);
+ return (await res.json()) as GroupListBody;
+};
+
+const create = (body: unknown, init: RequestInit = asAdmin) =>
+ app.request("/v1/org/groups", {
+ ...init,
+ method: "POST",
+ body: JSON.stringify(body),
+ });
+
+const rename = (id: string, body: unknown, init: RequestInit = asAdmin) =>
+ app.request(`/v1/org/groups/${id}`, {
+ ...init,
+ method: "PATCH",
+ body: JSON.stringify(body),
+ });
+
+const remove = (id: string, init: RequestInit = asAdmin) =>
+ app.request(`/v1/org/groups/${id}`, { ...init, method: "DELETE" });
+
+const putMembers = (id: string, body: unknown, init: RequestInit = asAdmin) =>
+ app.request(`/v1/org/groups/${id}/members`, {
+ ...init,
+ method: "PUT",
+ body: JSON.stringify(body),
+ });
+
+const putMember = (id: string, userId: string, init: RequestInit = asAdmin) =>
+ app.request(`/v1/org/groups/${id}/members/${userId}`, {
+ ...init,
+ method: "PUT",
+ body: JSON.stringify({}),
+ });
+
+const deleteMember = (
+ id: string,
+ userId: string,
+ init: RequestInit = asAdmin,
+) =>
+ app.request(`/v1/org/groups/${id}/members/${userId}`, {
+ ...init,
+ method: "DELETE",
+ });
+
+describe("GET /v1/org/groups", () => {
+ it("returns the org's groups in the page envelope with member counts", async () => {
+ const body = await list();
+ expect(body.nextCursor).toBeNull();
+ expect(body.data.map((row) => row.id)).toEqual(["g-a", "g-b", "g-scim"]);
+ expect(body.data[0]).toEqual({
+ id: "g-a",
+ name: "Engineering",
+ source: "manual",
+ externalId: null,
+ memberCount: 2,
+ createdAt: at(10).toISOString(),
+ updatedAt: at(10).toISOString(),
+ });
+ expect(body.data[2]).toMatchObject({
+ source: "scim",
+ externalId: "idp-77",
+ memberCount: 1,
+ });
+ });
+
+ it("never leaks groups of another organization", async () => {
+ const body = await list();
+ expect(body.data.some((row) => row.id === "g-x")).toBe(false);
+ });
+
+ it("filters by source", async () => {
+ const body = await list("?source=scim");
+ expect(body.data.map((r) => r.id)).toEqual(["g-scim"]);
+ const manual = await list("?source=manual");
+ expect(manual.data.map((r) => r.id)).toEqual(["g-a", "g-b"]);
+ });
+
+ it("rejects an unknown source with 422", async () => {
+ const res = await app.request("/v1/org/groups?source=github", asAdmin);
+ expect(res.status).toBe(422);
+ });
+
+ it("filters by free-text q over name, case-insensitively", async () => {
+ const body = await list("?q=ENGINEER");
+ expect(body.data.map((r) => r.id)).toEqual(["g-a"]);
+ });
+
+ it("pages with an opaque cursor and ends with nextCursor null", async () => {
+ const first = await list("?limit=2");
+ expect(first.data.map((r) => r.id)).toEqual(["g-a", "g-b"]);
+ expect(first.nextCursor).toBeTruthy();
+
+ const second = await list(
+ `?limit=2&cursor=${encodeURIComponent(first.nextCursor ?? "")}`,
+ );
+ expect(second.data.map((r) => r.id)).toEqual(["g-scim"]);
+ expect(second.nextCursor).toBeNull();
+ });
+
+ it("walks every page exactly once when createdAt ties", async () => {
+ // Same millisecond for all: only the id half of the cursor can separate
+ // them, so a one-at-a-time walk is the tiebreak's real test.
+ for (const row of store.groups) row.createdAt = at(7);
+
+ const seen: string[] = [];
+ let cursor: string | null = null;
+ for (let page = 0; page < 10; page++) {
+ const body: GroupListBody = await list(
+ `?limit=1${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`,
+ );
+ seen.push(...body.data.map((r) => r.id));
+ cursor = body.nextCursor;
+ if (!cursor) break;
+ }
+
+ expect(cursor).toBeNull();
+ expect(seen.slice().sort()).toEqual(["g-a", "g-b", "g-scim"]);
+ expect(new Set(seen).size).toBe(seen.length);
+ });
+
+ it("treats a malformed cursor as the first page instead of failing", async () => {
+ const body = await list("?cursor=not-a-real-cursor");
+ expect(body.data).toHaveLength(3);
+ });
+
+ it("rejects out-of-range limits with 422", async () => {
+ for (const limit of ["5000", "0", "abc"]) {
+ const res = await app.request(`/v1/org/groups?limit=${limit}`, asAdmin);
+ expect(res.status).toBe(422);
+ }
+ });
+
+ it("403s a project-scoped key even when its user is an org owner", async () => {
+ const res = await app.request("/v1/org/groups", asProjectKey);
+ expect(res.status).toBe(403);
+ });
+
+ it("403s a non-admin member (deterministic, not a 401)", async () => {
+ store.sessionUserId = MEMBER;
+ const res = await app.request("/v1/org/groups");
+ expect(res.status).toBe(403);
+ });
+
+ it("rejects a suspended admin's org key (suspended reads as no role)", async () => {
+ const row = store.members.find((m) => m.userId === ADMIN);
+ if (row) row.status = "suspended";
+ const res = await app.request("/v1/org/groups", asAdmin);
+ expect(res.status).toBe(401);
+ });
+
+ it("401s an unauthenticated caller", async () => {
+ const res = await app.request("/v1/org/groups");
+ expect(res.status).toBe(401);
+ });
+});
+
+describe("POST /v1/org/groups", () => {
+ it("creates a manual group and audits it", async () => {
+ const res = await create({ name: "Platform" });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as GroupListBody["data"][number];
+ expect(body).toMatchObject({
+ name: "Platform",
+ source: "manual",
+ externalId: null,
+ memberCount: 0,
+ });
+ expect(store.audits).toHaveLength(1);
+ expect(store.audits[0]).toMatchObject({
+ organizationId: ORG,
+ userId: ADMIN,
+ action: "create",
+ service: "group",
+ source: "api",
+ metadata: { groupId: body.id, name: "Platform" },
+ });
+ });
+
+ it("ignores body source/externalId: creates are always manual", async () => {
+ const res = await create({
+ name: "Sneaky",
+ source: "scim",
+ externalId: "idp-evil",
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as GroupListBody["data"][number];
+ expect(body.source).toBe("manual");
+ expect(body.externalId).toBeNull();
+ expect(groupRow(body.id)?.source).toBe("manual");
+ });
+
+ it("trims the name before storing", async () => {
+ const res = await create({ name: " Padded " });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as GroupListBody["data"][number];
+ expect(body.name).toBe("Padded");
+ });
+
+ it("422s an empty / whitespace-only / overlong / missing name", async () => {
+ for (const body of [
+ { name: "" },
+ { name: " " },
+ { name: "x".repeat(101) },
+ {},
+ ]) {
+ const res = await create(body);
+ expect(res.status).toBe(422);
+ }
+ expect(store.audits).toHaveLength(0);
+ });
+
+ it("422s a missing/unparseable body", async () => {
+ const res = await app.request("/v1/org/groups", {
+ ...asAdmin,
+ method: "POST",
+ });
+ expect(res.status).toBe(422);
+ });
+
+ it("409s a duplicate name and audits nothing", async () => {
+ const res = await create({ name: "Engineering" });
+ expect(res.status).toBe(409);
+ expect(store.audits).toHaveLength(0);
+ expect(store.groups.filter((g) => g.name === "Engineering")).toHaveLength(
+ 1,
+ );
+ });
+
+ it("409s a create-create race surfaced as P2002", async () => {
+ store.race = true;
+ const res = await create({ name: "Engineering" });
+ expect(res.status).toBe(409);
+ expect(store.audits).toHaveLength(0);
+ });
+
+ it("403s a project-scoped key and audits/creates nothing", async () => {
+ const res = await create({ name: "Platform" }, asProjectKey);
+ expect(res.status).toBe(403);
+ expect(store.audits).toHaveLength(0);
+ expect(store.groups.some((g) => g.name === "Platform")).toBe(false);
+ });
+
+ it("403s a non-admin member and audits nothing", async () => {
+ store.sessionUserId = MEMBER;
+ const res = await create({ name: "Platform" }, {});
+ expect(res.status).toBe(403);
+ expect(store.audits).toHaveLength(0);
+ });
+});
+
+describe("PATCH /v1/org/groups/:groupId", () => {
+ it("renames a group and audits the change discriminator", async () => {
+ const res = await rename("g-a", { name: "Core Engineering" });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as GroupListBody["data"][number];
+ expect(body).toMatchObject({
+ id: "g-a",
+ name: "Core Engineering",
+ memberCount: 2,
+ });
+ expect(groupRow("g-a")?.name).toBe("Core Engineering");
+ expect(store.audits).toHaveLength(1);
+ expect(store.audits[0]).toMatchObject({
+ action: "update",
+ service: "group",
+ metadata: { groupId: "g-a", change: "name", name: "Core Engineering" },
+ });
+ });
+
+ it("permits a rename-to-self as a no-op 200", async () => {
+ const res = await rename("g-a", { name: "Engineering" });
+ expect(res.status).toBe(200);
+ expect(groupRow("g-a")?.name).toBe("Engineering");
+ });
+
+ it("409s a rename onto another group's name", async () => {
+ const res = await rename("g-a", { name: "Design" });
+ expect(res.status).toBe(409);
+ expect(groupRow("g-a")?.name).toBe("Engineering");
+ expect(store.audits).toHaveLength(0);
+ });
+
+ it("404s an unknown group", async () => {
+ const res = await rename("g-nope", { name: "Anything" });
+ expect(res.status).toBe(404);
+ });
+
+ it("404s a group of another organization (cross-org isolation)", async () => {
+ const res = await rename("g-x", { name: "Captured" });
+ expect(res.status).toBe(404);
+ expect(groupRow("g-x")?.name).toBe("Foreign");
+ });
+
+ it("409s a scim-provisioned group (IdP-owned)", async () => {
+ const res = await rename("g-scim", { name: "Mine now" });
+ expect(res.status).toBe(409);
+ expect(groupRow("g-scim")?.name).toBe("Provisioned");
+ expect(store.audits).toHaveLength(0);
+ });
+
+ it("422s an invalid name", async () => {
+ const res = await rename("g-a", { name: " " });
+ expect(res.status).toBe(422);
+ });
+});
+
+describe("DELETE /v1/org/groups/:groupId", () => {
+ it("deletes, reports the impact read BEFORE the delete, and cascades", async () => {
+ const res = await remove("g-a");
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({
+ id: "g-a",
+ name: "Engineering",
+ removedMembers: 2,
+ removedProjectBindings: 1,
+ });
+ // Cascades applied: membership and project bindings went with the group.
+ expect(groupRow("g-a")).toBeUndefined();
+ expect(membersOf("g-a")).toEqual([]);
+ expect(store.projectAccess.some((pa) => pa.groupId === "g-a")).toBe(false);
+ });
+
+ it("audits counts only — never id arrays", async () => {
+ const res = await remove("g-a");
+ expect(res.status).toBe(200);
+ expect(store.audits).toHaveLength(1);
+ expect(store.audits[0]).toMatchObject({
+ action: "delete",
+ service: "group",
+ metadata: {
+ groupId: "g-a",
+ name: "Engineering",
+ removedMembers: 2,
+ removedProjectBindings: 1,
+ },
+ });
+ for (const value of Object.values(store.audits[0]?.metadata ?? {})) {
+ expect(Array.isArray(value)).toBe(false);
+ }
+ });
+
+ it("404s an unknown group and audits nothing", async () => {
+ const res = await remove("g-nope");
+ expect(res.status).toBe(404);
+ expect(store.audits).toHaveLength(0);
+ });
+
+ it("404s a group of another organization (cross-org isolation)", async () => {
+ const res = await remove("g-x");
+ expect(res.status).toBe(404);
+ expect(groupRow("g-x")).toBeTruthy();
+ });
+
+ it("409s a scim-provisioned group", async () => {
+ const res = await remove("g-scim");
+ expect(res.status).toBe(409);
+ expect(groupRow("g-scim")).toBeTruthy();
+ });
+
+ it("403s a project-scoped key and deletes nothing", async () => {
+ const res = await remove("g-a", asProjectKey);
+ expect(res.status).toBe(403);
+ expect(groupRow("g-a")).toBeTruthy();
+ expect(store.audits).toHaveLength(0);
+ });
+});
+
+describe("GET /v1/org/groups/:groupId/members", () => {
+ const listMembers = async (
+ groupId: string,
+ query = "",
+ ): Promise => {
+ const res = await app.request(
+ `/v1/org/groups/${groupId}/members${query}`,
+ asAdmin,
+ );
+ expect(res.status).toBe(200);
+ return (await res.json()) as MemberListBody;
+ };
+
+ it("returns the group's members with user identity joined in", async () => {
+ const body = await listMembers("g-a");
+ expect(body.nextCursor).toBeNull();
+ expect(body.data).toEqual([
+ {
+ userId: OWNER,
+ email: "owner@example.com",
+ name: "Olive Owner",
+ addedAt: at(20).toISOString(),
+ },
+ {
+ userId: ADMIN,
+ email: "admin@example.com",
+ name: "Adam Admin",
+ addedAt: at(21).toISOString(),
+ },
+ ]);
+ });
+
+ it("filters by q over email and name, case-insensitively", async () => {
+ const byEmail = await listMembers("g-a", "?q=OWNER@example");
+ expect(byEmail.data.map((r) => r.userId)).toEqual([OWNER]);
+ const byName = await listMembers("g-a", "?q=adam");
+ expect(byName.data.map((r) => r.userId)).toEqual([ADMIN]);
+ });
+
+ it("pages the member list with the two-part cursor", async () => {
+ const first = await listMembers("g-a", "?limit=1");
+ expect(first.data.map((r) => r.userId)).toEqual([OWNER]);
+ expect(first.nextCursor).toBeTruthy();
+ const second = await listMembers(
+ "g-a",
+ `?limit=1&cursor=${encodeURIComponent(first.nextCursor ?? "")}`,
+ );
+ expect(second.data.map((r) => r.userId)).toEqual([ADMIN]);
+ expect(second.nextCursor).toBeNull();
+ });
+
+ it("404s a group of another organization (no membership oracle)", async () => {
+ const res = await app.request("/v1/org/groups/g-x/members", asAdmin);
+ expect(res.status).toBe(404);
+ });
+
+ it("lists a scim group's members (reads are always allowed)", async () => {
+ const body = await listMembers("g-scim");
+ expect(body.data.map((r) => r.userId)).toEqual([MEMBER]);
+ });
+});
+
+describe("PUT /v1/org/groups/:groupId/members (replace-set)", () => {
+ it("applies the exact set: adds, removes, keeps, and returns the delta", async () => {
+ // g-a currently {OWNER, ADMIN}; target {ADMIN, MEMBER}.
+ const res = await putMembers("g-a", { userIds: [ADMIN, MEMBER] });
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({ added: 1, removed: 1 });
+ expect(membersOf("g-a")).toEqual([ADMIN, MEMBER].sort());
+ expect(store.txCount).toBe(1);
+ expect(store.audits).toHaveLength(1);
+ expect(store.audits[0]).toMatchObject({
+ action: "update",
+ service: "group",
+ source: "api",
+ metadata: { groupId: "g-a", change: "members", added: 1, removed: 1 },
+ });
+ // Counts only, never id arrays.
+ for (const value of Object.values(store.audits[0]?.metadata ?? {})) {
+ expect(Array.isArray(value)).toBe(false);
+ }
+ });
+
+ it("an empty set clears the group", async () => {
+ const res = await putMembers("g-a", { userIds: [] });
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({ added: 0, removed: 2 });
+ expect(membersOf("g-a")).toEqual([]);
+ });
+
+ it("a no-op set returns {0,0} without opening a transaction", async () => {
+ const res = await putMembers("g-a", { userIds: [OWNER, ADMIN] });
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({ added: 0, removed: 0 });
+ expect(store.txCount).toBe(0);
+ expect(membersOf("g-a")).toEqual([ADMIN, OWNER].sort());
+ });
+
+ it("deduplicates repeated ids in the payload", async () => {
+ const res = await putMembers("g-b", { userIds: [MEMBER, MEMBER] });
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({ added: 1, removed: 0 });
+ expect(membersOf("g-b")).toEqual([MEMBER]);
+ });
+
+ it("422s a set beyond the cap", async () => {
+ const userIds = Array.from({ length: 1001 }, (_, i) => `u-${i}`);
+ const res = await putMembers("g-a", { userIds });
+ expect(res.status).toBe(422);
+ });
+
+ it("400s when ANY id is not a member of this org — the security core", async () => {
+ const res = await putMembers("g-a", { userIds: [ADMIN, OUTSIDER] });
+ expect(res.status).toBe(400);
+ // Nothing written, nothing audited: the whole write is rejected.
+ expect(membersOf("g-a")).toEqual([ADMIN, OWNER].sort());
+ expect(store.audits).toHaveLength(0);
+ expect(store.txCount).toBe(0);
+ });
+
+ it("allows suspended members (suspension is an auth-time gate)", async () => {
+ const row = store.members.find((m) => m.userId === MEMBER);
+ if (row) row.status = "suspended";
+ const res = await putMembers("g-b", { userIds: [MEMBER] });
+ expect(res.status).toBe(200);
+ expect(membersOf("g-b")).toEqual([MEMBER]);
+ });
+
+ it("404s a cross-org group before validating membership", async () => {
+ const res = await putMembers("g-x", { userIds: [OUTSIDER] });
+ expect(res.status).toBe(404);
+ expect(membersOf("g-x")).toEqual([OUTSIDER]);
+ });
+
+ it("409s a scim group (membership is IdP-owned)", async () => {
+ const res = await putMembers("g-scim", { userIds: [ADMIN] });
+ expect(res.status).toBe(409);
+ expect(membersOf("g-scim")).toEqual([MEMBER]);
+ });
+
+ it("422s a malformed body", async () => {
+ for (const body of [{}, { userIds: "ADMIN" }, { userIds: [""] }, null]) {
+ const res = await putMembers("g-a", body);
+ expect(res.status).toBe(422);
+ }
+ });
+
+ it("403s a non-admin and writes/audits nothing", async () => {
+ store.sessionUserId = MEMBER;
+ const res = await putMembers("g-a", { userIds: [] }, {});
+ expect(res.status).toBe(403);
+ expect(membersOf("g-a")).toEqual([ADMIN, OWNER].sort());
+ expect(store.audits).toHaveLength(0);
+ });
+});
+
+describe("PUT /v1/org/groups/:groupId/members/:userId (single add)", () => {
+ it("adds a member, returns a JSON body, and audits", async () => {
+ const res = await putMember("g-b", MEMBER);
+ expect(res.status).toBe(200);
+ // apiPut ALWAYS parses the response — a 204 here would break the client.
+ expect(res.headers.get("content-type")).toContain("application/json");
+ expect(await res.json()).toEqual({ added: true });
+ expect(membersOf("g-b")).toEqual([MEMBER]);
+ expect(store.audits).toHaveLength(1);
+ expect(store.audits[0]).toMatchObject({
+ metadata: {
+ groupId: "g-b",
+ change: "members",
+ userId: MEMBER,
+ added: true,
+ },
+ });
+ });
+
+ it("is idempotent: a second add reports added: false", async () => {
+ expect((await putMember("g-b", MEMBER)).status).toBe(200);
+ const res = await putMember("g-b", MEMBER);
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({ added: false });
+ expect(membersOf("g-b")).toEqual([MEMBER]);
+ });
+
+ it("400s a user from another organization", async () => {
+ const res = await putMember("g-b", OUTSIDER);
+ expect(res.status).toBe(400);
+ expect(membersOf("g-b")).toEqual([]);
+ expect(store.audits).toHaveLength(0);
+ });
+
+ it("404s a cross-org group / 409s a scim group", async () => {
+ expect((await putMember("g-x", MEMBER)).status).toBe(404);
+ expect((await putMember("g-scim", ADMIN)).status).toBe(409);
+ });
+});
+
+describe("DELETE /v1/org/groups/:groupId/members/:userId (single remove)", () => {
+ it("removes a member and audits", async () => {
+ const res = await deleteMember("g-a", OWNER);
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({ removed: true });
+ expect(membersOf("g-a")).toEqual([ADMIN]);
+ expect(store.audits).toHaveLength(1);
+ expect(store.audits[0]).toMatchObject({
+ metadata: {
+ groupId: "g-a",
+ change: "members",
+ userId: OWNER,
+ removed: true,
+ },
+ });
+ });
+
+ it("is idempotent: a missing membership is removed:false, not 404", async () => {
+ const res = await deleteMember("g-b", MEMBER);
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({ removed: false });
+ });
+
+ it("404s only for a missing/cross-org GROUP", async () => {
+ expect((await deleteMember("g-nope", MEMBER)).status).toBe(404);
+ expect((await deleteMember("g-x", OUTSIDER)).status).toBe(404);
+ expect(membersOf("g-x")).toEqual([OUTSIDER]);
+ });
+
+ it("409s a scim group", async () => {
+ const res = await deleteMember("g-scim", MEMBER);
+ expect(res.status).toBe(409);
+ expect(membersOf("g-scim")).toEqual([MEMBER]);
+ });
+});
diff --git a/packages/api/src/routes/org/groups.ts b/packages/api/src/routes/org/groups.ts
new file mode 100644
index 00000000..243a348f
--- /dev/null
+++ b/packages/api/src/routes/org/groups.ts
@@ -0,0 +1,219 @@
+import { Hono } from "hono";
+import type { Context } from "hono";
+import type { ApiEnv } from "../../types";
+import { auth } from "../../middleware/auth";
+import { ServiceError } from "../../services/errors";
+import { parse } from "./parse";
+import {
+ addOrgGroupMember,
+ createOrgGroup,
+ deleteOrgGroup,
+ listOrgGroupMembers,
+ listOrgGroups,
+ removeOrgGroupMember,
+ renameOrgGroup,
+ setOrgGroupMembers,
+} from "../../services/org-group-service";
+import {
+ createGroupSchema,
+ directoryListQuerySchema,
+ groupListQuerySchema,
+ renameGroupSchema,
+ setGroupMembersSchema,
+} from "../../validations/org";
+import {
+ withAudit,
+ AUDIT_ACTIONS,
+ AUDIT_SERVICES,
+ AUDIT_SOURCE,
+} from "../../services/audit-service";
+
+/**
+ * `/v1/org/groups` — the organization's human groups.
+ *
+ * Same guard stack as `/v1/org/members`, for the same reasons:
+ *
+ * `requireProject: false`: these are ORG-scoped routes, so a caller with no
+ * project context (an org API key without `X-Project-Id`) must still get
+ * through. `role: "admin"` makes the whole router admin-only — a plain member
+ * gets a deterministic 403, which is exactly what the web client expects
+ * (directory queries are not retried on 403). Both only work because the OSS
+ * edition now registers a `RoleResolver`.
+ *
+ * `role` alone is SCOPE-BLIND, so it is not sufficient on its own: a
+ * project-scoped key (the credential an agent carries) resolves to its owning
+ * user, and if that user happens to be an org admin the role check passes. A
+ * leaked agent key would then be able to rewrite group membership — the very
+ * substrate project access and policy identities hang off. Org-wide authority
+ * requires an org-wide credential, so project-scoped callers are rejected
+ * outright.
+ */
+export const orgGroupRoutes = () => {
+ const app = new Hono();
+ app.use("*", auth({ requireProject: false, role: "admin" }));
+ app.use("*", async (c, next) => {
+ if (c.get("auth").scope === "project") {
+ throw new ServiceError(
+ "FORBIDDEN",
+ "Organization management requires an organization-scoped credential.",
+ );
+ }
+ return next();
+ });
+
+ // `organizationId` in every audit params below is deliberate: besides
+ // scoping the audit row it flushes the gateway's org cache
+ // (invalidateGatewayCacheForOrg). Group membership is exactly what the
+ // gateway's principal resolution reads — a missed flush becomes a stale
+ // authorization decision, so EVERY membership write must go through withAudit.
+ const auditBase = (c: Context) => ({
+ organizationId: c.get("auth").organizationId,
+ userId: c.get("auth").userId,
+ userEmail: c.get("auth").userEmail,
+ service: AUDIT_SERVICES.GROUP,
+ source: AUDIT_SOURCE.API,
+ });
+
+ // GET /org/groups — cursor-paged, optionally filtered by source / free text.
+ app.get("/", async (c) => {
+ const auth = c.get("auth");
+ const query = parse(groupListQuerySchema, c.req.query());
+ return c.json(await listOrgGroups(auth.organizationId, query));
+ });
+
+ // POST /org/groups — create a manual group.
+ app.post("/", async (c) => {
+ const auth = c.get("auth");
+ const body = await c.req.json().catch(() => null);
+ const input = parse(createGroupSchema, body);
+
+ const group = await withAudit(
+ () => createOrgGroup(auth.organizationId, input.name),
+ (created) => ({
+ ...auditBase(c),
+ action: AUDIT_ACTIONS.CREATE,
+ metadata: { groupId: created.id, name: created.name },
+ }),
+ );
+ return c.json(group);
+ });
+
+ // PATCH /org/groups/:groupId — rename.
+ app.patch("/:groupId", async (c) => {
+ const auth = c.get("auth");
+ const groupId = c.req.param("groupId");
+ const body = await c.req.json().catch(() => null);
+ const input = parse(renameGroupSchema, body);
+
+ const group = await withAudit(
+ () => renameOrgGroup(auth.organizationId, groupId, input.name),
+ (renamed) => ({
+ ...auditBase(c),
+ action: AUDIT_ACTIONS.UPDATE,
+ metadata: { groupId: renamed.id, change: "name", name: renamed.name },
+ }),
+ );
+ return c.json(group);
+ });
+
+ // DELETE /org/groups/:groupId — the response carries the cascade impact
+ // (read before the delete) so the UI can report what went with the group.
+ app.delete("/:groupId", async (c) => {
+ const auth = c.get("auth");
+ const groupId = c.req.param("groupId");
+
+ const result = await withAudit(
+ () => deleteOrgGroup(auth.organizationId, groupId),
+ (deleted) => ({
+ ...auditBase(c),
+ action: AUDIT_ACTIONS.DELETE,
+ // Counts only, never id arrays — audit metadata must stay bounded.
+ metadata: {
+ groupId: deleted.id,
+ name: deleted.name,
+ removedMembers: deleted.removedMembers,
+ removedProjectBindings: deleted.removedProjectBindings,
+ },
+ }),
+ );
+ return c.json(result);
+ });
+
+ // GET /org/groups/:groupId/members — cursor-paged member list.
+ app.get("/:groupId/members", async (c) => {
+ const auth = c.get("auth");
+ const groupId = c.req.param("groupId");
+ const query = parse(directoryListQuerySchema, c.req.query());
+ return c.json(
+ await listOrgGroupMembers(auth.organizationId, groupId, query),
+ );
+ });
+
+ // PUT /org/groups/:groupId/members — bulk replace-set (the dialog's save).
+ // Every PUT returns a JSON body: the client's apiPut ALWAYS parses, so a
+ // 204 here would throw in the browser.
+ app.put("/:groupId/members", async (c) => {
+ const auth = c.get("auth");
+ const groupId = c.req.param("groupId");
+ const body = await c.req.json().catch(() => null);
+ const input = parse(setGroupMembersSchema, body);
+
+ const result = await withAudit(
+ () =>
+ setOrgGroupMembers(
+ auth.organizationId,
+ auth.userId,
+ groupId,
+ input.userIds,
+ ),
+ (delta) => ({
+ ...auditBase(c),
+ action: AUDIT_ACTIONS.UPDATE,
+ metadata: {
+ groupId,
+ change: "members",
+ added: delta.added,
+ removed: delta.removed,
+ },
+ }),
+ );
+ return c.json(result);
+ });
+
+ // PUT /org/groups/:groupId/members/:userId — idempotent single add.
+ app.put("/:groupId/members/:userId", async (c) => {
+ const auth = c.get("auth");
+ const groupId = c.req.param("groupId");
+ const userId = c.req.param("userId");
+
+ const result = await withAudit(
+ () =>
+ addOrgGroupMember(auth.organizationId, auth.userId, groupId, userId),
+ (r) => ({
+ ...auditBase(c),
+ action: AUDIT_ACTIONS.UPDATE,
+ metadata: { groupId, change: "members", userId, added: r.added },
+ }),
+ );
+ return c.json(result);
+ });
+
+ // DELETE /org/groups/:groupId/members/:userId — idempotent single remove.
+ app.delete("/:groupId/members/:userId", async (c) => {
+ const auth = c.get("auth");
+ const groupId = c.req.param("groupId");
+ const userId = c.req.param("userId");
+
+ const result = await withAudit(
+ () => removeOrgGroupMember(auth.organizationId, groupId, userId),
+ (r) => ({
+ ...auditBase(c),
+ action: AUDIT_ACTIONS.UPDATE,
+ metadata: { groupId, change: "members", userId, removed: r.removed },
+ }),
+ );
+ return c.json(result);
+ });
+
+ return app;
+};
diff --git a/packages/api/src/routes/org/index.ts b/packages/api/src/routes/org/index.ts
index 1cbcbff7..57c3ccfc 100644
--- a/packages/api/src/routes/org/index.ts
+++ b/packages/api/src/routes/org/index.ts
@@ -2,6 +2,7 @@ import type { Hono } from "hono";
import type { ApiEnv } from "../../types";
import { orgMemberRoutes } from "./members";
import { orgInvitationRoutes } from "./invitations";
+import { orgGroupRoutes } from "./groups";
/**
* The OSS edition's `/v1/org/*` surface.
@@ -21,4 +22,5 @@ import { orgInvitationRoutes } from "./invitations";
export const registerOssOrgRoutes = (app: Hono) => {
app.route("/org/members", orgMemberRoutes());
app.route("/org/invitations", orgInvitationRoutes());
+ app.route("/org/groups", orgGroupRoutes());
};
diff --git a/packages/api/src/services/audit-service.ts b/packages/api/src/services/audit-service.ts
index c5db2cf5..1c4e7486 100644
--- a/packages/api/src/services/audit-service.ts
+++ b/packages/api/src/services/audit-service.ts
@@ -59,7 +59,10 @@ export const AUDIT_SERVICES = {
// ACCEPTANCE is deliberately not here: accepting creates a membership, so it
// audits as a MEMBER create with `via: "invitation"` metadata.
INVITATION: "invitation",
- // EE-only (directory): human groups (manual + SCIM-provisioned)
+ // Directory: human groups. OSS writes them via `/v1/org/groups` (create /
+ // rename / delete; membership changes audit as UPDATE with
+ // `change: "members"` — there is deliberately no GROUP_MEMBER service,
+ // matching the INVITATION→MEMBER precedent); EE adds SCIM-provisioned writes.
GROUP: "group",
// EE-only (directory): group→org-role mappings (the mapping config itself;
// the member role changes it drives are audited under MEMBER).
diff --git a/packages/api/src/services/org-group-service.ts b/packages/api/src/services/org-group-service.ts
new file mode 100644
index 00000000..aa4ff9b8
--- /dev/null
+++ b/packages/api/src/services/org-group-service.ts
@@ -0,0 +1,497 @@
+import { db, Prisma } from "@onecli/db";
+import { ServiceError } from "./errors";
+import {
+ clampDirectoryLimit,
+ decodeCursor,
+ toDirectoryPage,
+ type DirectoryPage,
+} from "../lib/cursor";
+import type { GroupListQuery } from "../validations/org";
+
+// The org's human-group directory: list/create/rename/delete plus the three
+// membership writers. Scoped to ONE organization on every call — the caller's
+// `auth.organizationId`, never a body/query parameter — so this can never
+// read or write across orgs.
+//
+// `source` is read-only: creates hard-code "manual", and "scim" rows
+// (IdP-provisioned in EE) reject every mutation with 409 — the dashboard must
+// never fight the IdP over a provisioned group.
+
+/** One row of the groups directory (matches the client's `GroupRow`). */
+export interface GroupListRow {
+ id: string;
+ name: string;
+ source: string;
+ externalId: string | null;
+ memberCount: number;
+ createdAt: string;
+ updatedAt: string;
+}
+
+/** One row of a group's member list (matches the client's `GroupMemberRow`). */
+export interface GroupMemberListRow {
+ userId: string;
+ email: string;
+ name: string | null;
+ addedAt: string;
+}
+
+/** What a delete actually removed — the cascade impact, read BEFORE the delete. */
+export interface GroupDeleteResult {
+ id: string;
+ name: string;
+ removedMembers: number;
+ removedProjectBindings: number;
+}
+
+export type ListOrgGroupsParams = Partial;
+
+/**
+ * Same keyset shape as the invitations directory: ordered `createdAt asc,
+ * id asc` and paged by that exact two-part key, since `createdAt` alone is
+ * not unique.
+ */
+const CURSOR_PARTS = 2;
+
+const cursorFilter = (raw: string | undefined) => {
+ const parts = decodeCursor(raw, CURSOR_PARTS);
+ if (!parts) return undefined;
+ const [createdAtIso, id] = parts;
+ if (createdAtIso === undefined || id === undefined) return undefined;
+ const createdAt = new Date(createdAtIso);
+ // A cursor whose timestamp half is not a date is malformed — serve page one
+ // rather than handing an Invalid Date to the query layer.
+ if (Number.isNaN(createdAt.getTime())) return undefined;
+ return {
+ OR: [{ createdAt: { gt: createdAt } }, { createdAt, id: { gt: id } }],
+ };
+};
+
+/** Member pages are keyed `createdAt asc, userId asc` (composite PK, no id). */
+const memberCursorFilter = (raw: string | undefined) => {
+ const parts = decodeCursor(raw, CURSOR_PARTS);
+ if (!parts) return undefined;
+ const [createdAtIso, userId] = parts;
+ if (createdAtIso === undefined || userId === undefined) return undefined;
+ const createdAt = new Date(createdAtIso);
+ if (Number.isNaN(createdAt.getTime())) return undefined;
+ return {
+ OR: [
+ { createdAt: { gt: createdAt } },
+ { createdAt, userId: { gt: userId } },
+ ],
+ };
+};
+
+export const listOrgGroups = async (
+ organizationId: string,
+ params: ListOrgGroupsParams = {},
+): Promise> => {
+ const limit = clampDirectoryLimit(params.limit);
+ const after = cursorFilter(params.cursor);
+ const q = params.q?.trim();
+
+ const rows = await db.group.findMany({
+ where: {
+ organizationId,
+ ...(params.source ? { source: params.source } : {}),
+ ...(q ? { name: { contains: q, mode: "insensitive" as const } } : {}),
+ // The keyset predicate lives under AND, never as a top-level `OR` spread:
+ // a future filter that also needs `OR` would otherwise overwrite the
+ // cursor clause and silently restart pagination from the first page.
+ ...(after ? { AND: [after] } : {}),
+ },
+ select: {
+ id: true,
+ name: true,
+ source: true,
+ externalId: true,
+ createdAt: true,
+ updatedAt: true,
+ _count: { select: { members: true } },
+ },
+ orderBy: [{ createdAt: "asc" }, { id: "asc" }],
+ take: limit + 1,
+ });
+
+ const groups: GroupListRow[] = rows.map((row) => ({
+ id: row.id,
+ name: row.name,
+ source: row.source,
+ externalId: row.externalId,
+ memberCount: row._count.members,
+ createdAt: row.createdAt.toISOString(),
+ updatedAt: row.updatedAt.toISOString(),
+ }));
+
+ return toDirectoryPage(groups, limit, (row) => [row.createdAt, row.id]);
+};
+
+/**
+ * Resolve a group WITHIN the caller's org — always
+ * `findFirst({ id, organizationId })`, NEVER `findUnique({ where: { id } })`:
+ * a cross-org id must read as absent (404), not leak another org's row.
+ */
+const requireGroup = async (organizationId: string, groupId: string) => {
+ const group = await db.group.findFirst({
+ where: { id: groupId, organizationId },
+ select: { id: true, name: true, source: true },
+ });
+ if (!group) throw new ServiceError("NOT_FOUND", "Group not found.");
+ return group;
+};
+
+/** Mutations additionally require manual provenance ("scim" rows are IdP-owned). */
+const requireManualGroup = async (organizationId: string, groupId: string) => {
+ const group = await requireGroup(organizationId, groupId);
+ if (group.source !== "manual") {
+ throw new ServiceError(
+ "CONFLICT",
+ "This group is managed by your identity provider and cannot be changed here.",
+ );
+ }
+ return group;
+};
+
+const isUniqueViolation = (err: unknown) =>
+ err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002";
+
+export const createOrgGroup = async (
+ organizationId: string,
+ name: string,
+): Promise => {
+ // Friendly pre-check for the common case; the P2002 catch below covers the
+ // create-create race the pre-check cannot see.
+ const dupe = await db.group.findFirst({
+ where: { organizationId, name },
+ select: { id: true },
+ });
+ if (dupe) {
+ throw new ServiceError(
+ "CONFLICT",
+ "A group with this name already exists.",
+ );
+ }
+
+ try {
+ const row = await db.group.create({
+ // `source` is hard-coded: a create can never mint a "scim" row.
+ data: { organizationId, name, source: "manual" },
+ select: {
+ id: true,
+ name: true,
+ source: true,
+ externalId: true,
+ createdAt: true,
+ updatedAt: true,
+ },
+ });
+ return {
+ id: row.id,
+ name: row.name,
+ source: row.source,
+ externalId: row.externalId,
+ memberCount: 0,
+ createdAt: row.createdAt.toISOString(),
+ updatedAt: row.updatedAt.toISOString(),
+ };
+ } catch (err) {
+ if (isUniqueViolation(err)) {
+ throw new ServiceError(
+ "CONFLICT",
+ "A group with this name already exists.",
+ );
+ }
+ throw err;
+ }
+};
+
+export const renameOrgGroup = async (
+ organizationId: string,
+ groupId: string,
+ name: string,
+): Promise => {
+ const group = await requireManualGroup(organizationId, groupId);
+
+ // Rename-to-self is a permitted no-op (a 409 here would make the rename
+ // dialog's "Save without edits" an error).
+ if (group.name !== name) {
+ const dupe = await db.group.findFirst({
+ where: { organizationId, name, id: { not: groupId } },
+ select: { id: true },
+ });
+ if (dupe) {
+ throw new ServiceError(
+ "CONFLICT",
+ "A group with this name already exists.",
+ );
+ }
+ }
+
+ // Org-scoped conditional write (the delete path's deleteMany pattern) — a
+ // count of 0 means the row vanished between the check and the write, which
+ // is a 404, not the P2025 500 a bare update() would surface.
+ try {
+ const { count } = await db.group.updateMany({
+ where: { id: groupId, organizationId },
+ data: { name },
+ });
+ if (count === 0) throw new ServiceError("NOT_FOUND", "Group not found.");
+ } catch (err) {
+ if (isUniqueViolation(err)) {
+ throw new ServiceError(
+ "CONFLICT",
+ "A group with this name already exists.",
+ );
+ }
+ throw err;
+ }
+
+ const row = await db.group.findFirst({
+ where: { id: groupId, organizationId },
+ select: {
+ id: true,
+ name: true,
+ source: true,
+ externalId: true,
+ createdAt: true,
+ updatedAt: true,
+ _count: { select: { members: true } },
+ },
+ });
+ if (!row) throw new ServiceError("NOT_FOUND", "Group not found.");
+ return {
+ id: row.id,
+ name: row.name,
+ source: row.source,
+ externalId: row.externalId,
+ memberCount: row._count.members,
+ createdAt: row.createdAt.toISOString(),
+ updatedAt: row.updatedAt.toISOString(),
+ };
+};
+
+/**
+ * Delete a group. The DB cascades take everything down with the row —
+ * GroupMember, the group's ProjectAccess bindings, its GroupRoleMapping, and
+ * PolicyRuleIdentity.group rows — so the impact is read FIRST and returned:
+ * the project-access cascade is a SILENT access revocation, and the confirm
+ * dialog must be able to say what went with the group.
+ *
+ * NOTE (reconciliation Stage C): the OSS grants engine treats a rule identity
+ * orphaned by an FK cascade as INERT — it is skipped at compile time, never
+ * widened to "any principal" (see grants-service). So a group delete needs no
+ * explicit orphan-neutralization pass here; the identity rows simply cascade
+ * away and the rules that referenced them lose one target. Role automation
+ * (group→org-role mappings) is likewise not a live OSS concept, so there is no
+ * mapping re-resolution to run. Both integrations belong to later stages.
+ */
+export const deleteOrgGroup = async (
+ organizationId: string,
+ groupId: string,
+): Promise => {
+ const group = await db.group.findFirst({
+ where: { id: groupId, organizationId },
+ select: {
+ id: true,
+ name: true,
+ source: true,
+ _count: { select: { members: true, projectAccess: true } },
+ },
+ });
+ if (!group) throw new ServiceError("NOT_FOUND", "Group not found.");
+ if (group.source !== "manual") {
+ throw new ServiceError(
+ "CONFLICT",
+ "This group is managed by your identity provider and cannot be changed here.",
+ );
+ }
+
+ // Org-scoped conditional delete: a count of 0 means the row vanished (or
+ // never belonged to this org) between the read and the write — 404 either
+ // way, never a cross-org delete. The DB cascades (GroupMember, ProjectAccess,
+ // GroupRoleMapping, PolicyRuleIdentity) run with the row.
+ const { count } = await db.group.deleteMany({
+ where: { id: groupId, organizationId },
+ });
+ if (count === 0) throw new ServiceError("NOT_FOUND", "Group not found.");
+
+ return {
+ id: group.id,
+ name: group.name,
+ removedMembers: group._count.members,
+ removedProjectBindings: group._count.projectAccess,
+ };
+};
+
+export interface ListOrgGroupMembersParams {
+ limit?: number;
+ cursor?: string;
+ q?: string;
+}
+
+export const listOrgGroupMembers = async (
+ organizationId: string,
+ groupId: string,
+ params: ListOrgGroupMembersParams = {},
+): Promise> => {
+ await requireGroup(organizationId, groupId);
+ const limit = clampDirectoryLimit(params.limit);
+ const after = memberCursorFilter(params.cursor);
+ const q = params.q?.trim();
+
+ const rows = await db.groupMember.findMany({
+ where: {
+ groupId,
+ ...(q
+ ? {
+ user: {
+ OR: [
+ { email: { contains: q, mode: "insensitive" as const } },
+ { name: { contains: q, mode: "insensitive" as const } },
+ ],
+ },
+ }
+ : {}),
+ // Keyset predicate under AND — see listOrgGroups.
+ ...(after ? { AND: [after] } : {}),
+ },
+ select: {
+ userId: true,
+ createdAt: true,
+ user: { select: { email: true, name: true } },
+ },
+ orderBy: [{ createdAt: "asc" }, { userId: "asc" }],
+ take: limit + 1,
+ });
+
+ const members: GroupMemberListRow[] = rows.map((row) => ({
+ userId: row.userId,
+ email: row.user.email,
+ name: row.user.name,
+ addedAt: row.createdAt.toISOString(),
+ }));
+
+ return toDirectoryPage(members, limit, (row) => [row.addedAt, row.userId]);
+};
+
+/**
+ * THE security invariant of every membership write: `GroupMember.userId` FKs
+ * the GLOBAL `User` table, so the org scope exists ONLY in this check. Every
+ * id must resolve to a member of the caller's org — one foreign id in the set
+ * and the whole write is rejected, or a group could capture users from
+ * another organization.
+ *
+ * Suspended members are deliberately allowed: suspension is an AUTH-time
+ * gate, and stripping group rows on suspend would silently rewrite the
+ * member's access shape on reinstate.
+ */
+const assertOrgMembers = async (organizationId: string, userIds: string[]) => {
+ if (userIds.length === 0) return;
+ const rows = await db.organizationMember.findMany({
+ where: { organizationId, userId: { in: userIds } },
+ select: { userId: true },
+ });
+ const known = new Set(rows.map((row) => row.userId));
+ if (userIds.some((id) => !known.has(id))) {
+ throw new ServiceError(
+ "BAD_REQUEST",
+ "One or more users are not members of this organization.",
+ );
+ }
+};
+
+/**
+ * Replace the group's member set. Returns the honest delta; a no-delta call
+ * returns `{ added: 0, removed: 0 }` WITHOUT opening a transaction (and the
+ * route's audit/flush still runs — cheap, and simpler than making withAudit
+ * conditional).
+ */
+export const setOrgGroupMembers = async (
+ organizationId: string,
+ actorUserId: string,
+ groupId: string,
+ userIds: string[],
+): Promise<{ added: number; removed: number }> => {
+ await requireManualGroup(organizationId, groupId);
+
+ const targetIds = [...new Set(userIds)];
+ await assertOrgMembers(organizationId, targetIds);
+
+ const target = new Set(targetIds);
+ const currentRows = await db.groupMember.findMany({
+ where: { groupId },
+ select: { userId: true },
+ });
+ const current = new Set(currentRows.map((row) => row.userId));
+
+ const toAdd = [...target].filter((id) => !current.has(id));
+ const toRemove = [...current].filter((id) => !target.has(id));
+
+ if (toAdd.length === 0 && toRemove.length === 0) {
+ return { added: 0, removed: 0 };
+ }
+
+ await db.$transaction([
+ db.groupMember.deleteMany({
+ where: { groupId, userId: { in: toRemove } },
+ }),
+ // skipDuplicates makes a concurrent double-add idempotent (composite PK)
+ // instead of surfacing a P2002.
+ db.groupMember.createMany({
+ data: toAdd.map((userId) => ({
+ groupId,
+ userId,
+ createdByUserId: actorUserId,
+ })),
+ skipDuplicates: true,
+ }),
+ ]);
+
+ return { added: toAdd.length, removed: toRemove.length };
+};
+
+/**
+ * Idempotent single add (the scripting surface). `added` is honest: false
+ * when the membership already existed.
+ */
+export const addOrgGroupMember = async (
+ organizationId: string,
+ actorUserId: string,
+ groupId: string,
+ userId: string,
+): Promise<{ added: boolean }> => {
+ await requireManualGroup(organizationId, groupId);
+ await assertOrgMembers(organizationId, [userId]);
+
+ const existing = await db.groupMember.findUnique({
+ where: { groupId_userId: { groupId, userId } },
+ select: { userId: true },
+ });
+
+ await db.groupMember.upsert({
+ where: { groupId_userId: { groupId, userId } },
+ create: { groupId, userId, createdByUserId: actorUserId },
+ update: {},
+ });
+
+ return { added: !existing };
+};
+
+/**
+ * Idempotent single remove: a missing membership is NOT a 404 (`removed:
+ * false`) — only a missing/cross-org GROUP is.
+ */
+export const removeOrgGroupMember = async (
+ organizationId: string,
+ groupId: string,
+ userId: string,
+): Promise<{ removed: boolean }> => {
+ await requireManualGroup(organizationId, groupId);
+
+ const { count } = await db.groupMember.deleteMany({
+ where: { groupId, userId },
+ });
+
+ return { removed: count > 0 };
+};
diff --git a/packages/api/src/validations/org.ts b/packages/api/src/validations/org.ts
index 1e42d62e..fe4fbaed 100644
--- a/packages/api/src/validations/org.ts
+++ b/packages/api/src/validations/org.ts
@@ -62,6 +62,42 @@ export const createInvitationSchema = z.object({
export type CreateInvitationInput = z.infer;
+// ── Groups ────────────────────────────────────────────────────────────────
+
+/**
+ * Group provenance — a READ-side filter only. `source` is never accepted on a
+ * write: creates hard-code `"manual"`, and `"scim"` rows (IdP-provisioned in
+ * EE) reject every mutation with 409 so the dashboard can never fight the
+ * IdP over ownership of a provisioned group.
+ */
+export const groupSourceSchema = z.enum(["manual", "scim"]);
+
+/** Group display name — trimmed, 1–100 chars. */
+export const groupNameSchema = z.string().trim().min(1).max(100);
+
+/**
+ * Replace-set ceiling for a single group's membership. Deliberately NOT
+ * `DIRECTORY_LIMIT_MAX` (a page-size bound, 200): the members dialog drains
+ * every page and PUTs the full set back, so the write cap must comfortably
+ * exceed one page while still bounding the request body.
+ */
+export const MAX_GROUP_MEMBERS = 1000;
+
+export const groupListQuerySchema = directoryListQuerySchema.extend({
+ source: groupSourceSchema.optional(),
+});
+
+export type GroupListQuery = z.infer;
+
+/** Body `source`/`externalId` are ignored by construction: not in the schema. */
+export const createGroupSchema = z.object({ name: groupNameSchema });
+
+export const renameGroupSchema = z.object({ name: groupNameSchema });
+
+export const setGroupMembersSchema = z.object({
+ userIds: z.array(z.string().min(1)).max(MAX_GROUP_MEMBERS),
+});
+
/**
* `PATCH /v1/org/members/:userId` accepts EXACTLY ONE change per request —
* either a lifecycle change (`status`) or a role change (`role`). A body
From 6b798cebf79be9b96a58ed27e9f27ad9f2b1d04c Mon Sep 17 00:00:00 2001
From: marcorivm
Date: Wed, 29 Jul 2026 18:34:58 -0600
Subject: [PATCH 03/10] feat(api): re-land project access and role mappings
onto 1.44.0
Reconciliation Stage D. /v1/projects (rename, access bindings replace-set,
safe delete with pinned cascade and last-owner/stranding guards) plus
/settings/project UI; /v1/org/role-mappings (CRUD, ordering, preview) with
monotonic raise-only apply re-wired into the group membership writers via
the applyRoleMappingsForGroup seam Stage C had stripped. Role changes
audit under MEMBER, config under ROLE_MAPPING. +136 tests, no agent-group
code, no migration. (Role-mappings management UI folds into Stage E.)
---
.../project/_components/admin-only-notice.tsx | 22 +
.../_components/delete-project-card.tsx | 155 ++
.../project/_components/local-mode-notice.tsx | 20 +
.../_components/project-access-card.tsx | 419 ++++
.../_components/project-access-dialog.tsx | 435 ++++
.../project/_components/project-name-card.tsx | 94 +
.../_components/project-settings-content.tsx | 98 +
.../project/_components/read-only-notice.tsx | 20 +
.../(dashboard)/settings/project/loading.tsx | 23 +
.../app/(dashboard)/settings/project/page.tsx | 37 +
apps/web/src/hooks/use-projects.ts | 11 +-
apps/web/src/lib/api/client.ts | 29 +-
apps/web/src/lib/api/index.ts | 1 +
apps/web/src/lib/api/keys.ts | 5 +
apps/web/src/lib/api/projects.ts | 7 +-
packages/api/src/lib/gateway-invalidate.ts | 9 +-
packages/api/src/routes/org/groups.test.ts | 549 ++++--
packages/api/src/routes/org/groups.ts | 6 +-
packages/api/src/routes/org/index.ts | 19 +-
packages/api/src/routes/org/projects.test.ts | 1747 +++++++++++++++++
packages/api/src/routes/org/projects.ts | 211 ++
.../api/src/routes/org/role-mappings.test.ts | 1248 ++++++++++++
packages/api/src/routes/org/role-mappings.ts | 200 ++
.../api/src/services/org-group-service.ts | 64 +-
.../services/org-role-mapping-service.test.ts | 254 +++
.../src/services/org-role-mapping-service.ts | 789 ++++++++
.../src/services/organization-service.test.ts | 423 +++-
.../api/src/services/organization-service.ts | 44 +
.../src/services/project-access-service.ts | 385 ++++
.../api/src/services/project-service.test.ts | 78 +
packages/api/src/services/project-service.ts | 387 ++++
packages/api/src/validations/org.ts | 78 +
packages/api/src/validations/project.ts | 68 +
33 files changed, 7701 insertions(+), 234 deletions(-)
create mode 100644 apps/web/src/app/(dashboard)/settings/project/_components/admin-only-notice.tsx
create mode 100644 apps/web/src/app/(dashboard)/settings/project/_components/delete-project-card.tsx
create mode 100644 apps/web/src/app/(dashboard)/settings/project/_components/local-mode-notice.tsx
create mode 100644 apps/web/src/app/(dashboard)/settings/project/_components/project-access-card.tsx
create mode 100644 apps/web/src/app/(dashboard)/settings/project/_components/project-access-dialog.tsx
create mode 100644 apps/web/src/app/(dashboard)/settings/project/_components/project-name-card.tsx
create mode 100644 apps/web/src/app/(dashboard)/settings/project/_components/project-settings-content.tsx
create mode 100644 apps/web/src/app/(dashboard)/settings/project/_components/read-only-notice.tsx
create mode 100644 apps/web/src/app/(dashboard)/settings/project/loading.tsx
create mode 100644 apps/web/src/app/(dashboard)/settings/project/page.tsx
create mode 100644 packages/api/src/routes/org/projects.test.ts
create mode 100644 packages/api/src/routes/org/projects.ts
create mode 100644 packages/api/src/routes/org/role-mappings.test.ts
create mode 100644 packages/api/src/routes/org/role-mappings.ts
create mode 100644 packages/api/src/services/org-role-mapping-service.test.ts
create mode 100644 packages/api/src/services/org-role-mapping-service.ts
create mode 100644 packages/api/src/services/project-access-service.ts
create mode 100644 packages/api/src/services/project-service.test.ts
create mode 100644 packages/api/src/services/project-service.ts
create mode 100644 packages/api/src/validations/project.ts
diff --git a/apps/web/src/app/(dashboard)/settings/project/_components/admin-only-notice.tsx b/apps/web/src/app/(dashboard)/settings/project/_components/admin-only-notice.tsx
new file mode 100644
index 00000000..db4e627a
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/settings/project/_components/admin-only-notice.tsx
@@ -0,0 +1,22 @@
+import { Lock } from "lucide-react";
+
+/**
+ * Rendered inside the sharing dialog when the candidate directories 403.
+ * `/v1/org/members` and `/v1/org/groups` are admin-only, so a project owner who
+ * is not an org admin can still SEE and prune the current bindings — they just
+ * cannot enumerate who else exists to add. The API is the authority; this is
+ * what its deterministic 403 looks like.
+ */
+export const AdminOnlyNotice = () => (
+
+
+
+
+
Admins only
+
+ Browsing the organization's members and groups requires an admin. Ask
+ an admin to share this project, or remove existing access from the list
+ behind this dialog.
+
+
+);
diff --git a/apps/web/src/app/(dashboard)/settings/project/_components/delete-project-card.tsx b/apps/web/src/app/(dashboard)/settings/project/_components/delete-project-card.tsx
new file mode 100644
index 00000000..45e5606d
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/settings/project/_components/delete-project-card.tsx
@@ -0,0 +1,155 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { Loader2 } from "lucide-react";
+import { toast } from "sonner";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@onecli/ui/components/card";
+import { Button } from "@onecli/ui/components/button";
+import { Input } from "@onecli/ui/components/input";
+import { Label } from "@onecli/ui/components/label";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@onecli/ui/components/alert-dialog";
+import type { Project } from "@/lib/api";
+import { useDeleteProject } from "@/hooks/use-projects";
+
+export interface DeleteProjectCardProps {
+ project: Project;
+ canManage: boolean;
+}
+
+/**
+ * `Project.name` is nullable (legacy `accounts` rows carry NULL), and those
+ * neglected projects are exactly the ones an admin wants gone — so the
+ * type-to-confirm gate falls back to a fixed literal instead of an empty string
+ * nobody can type.
+ */
+const FALLBACK_CONFIRMATION = "delete";
+
+export const DeleteProjectCard = ({
+ project,
+ canManage,
+}: DeleteProjectCardProps) => {
+ const [open, setOpen] = useState(false);
+ const [confirmation, setConfirmation] = useState("");
+ const remove = useDeleteProject();
+ const router = useRouter();
+
+ const name = project.name?.trim() ?? "";
+ const expected = name || FALLBACK_CONFIRMATION;
+ // Client-side only: `apiDelete` sends no body, so this is friction, not a
+ // check. The server's refusals (last project in the org, a member who would
+ // be left with none) are the real guards and their messages are toasted
+ // verbatim by the hook.
+ const confirmed = confirmation.trim() === expected;
+
+ const handleOpenChange = (next: boolean) => {
+ if (next) setConfirmation("");
+ setOpen(next);
+ };
+
+ const handleDelete = () => {
+ if (!confirmed || remove.isPending) return;
+ remove.mutate(project.id, {
+ onSuccess: () => {
+ setOpen(false);
+ toast.success("Project deleted");
+ // The next request re-resolves a different default project.
+ router.replace("/overview");
+ },
+ });
+ };
+
+ return (
+ <>
+
+
+ Delete this project
+
+ Agents, API keys, secrets, connections and policy rules in this
+ project are deleted permanently. Activity history is kept. This
+ cannot be undone.
+
+
+
+ handleOpenChange(true)}
+ >
+ Delete project
+
+
+
+
+ {/* AlertDialog, not Dialog: the app's convention for every destructive
+ confirm (group + member row actions, connections, secrets, keys). */}
+
+
+
+
+ Delete {name || "this project"}?
+
+
+ This deletes the project's agents, API keys, secrets, app
+ connections and policy rules. Anyone who relies on this project
+ loses access to it. Activity history is kept.
+
+
+
+
+ Type {expected} to confirm
+
+ setConfirmation(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") handleDelete();
+ }}
+ />
+
+
+
+ Cancel
+
+ {/* preventDefault + manual mutate keeps the dialog open while the
+ request is in flight (the group-row-actions pattern). */}
+ {
+ e.preventDefault();
+ handleDelete();
+ }}
+ disabled={!confirmed || remove.isPending}
+ >
+ {remove.isPending ? (
+ <>
+
+ Deleting...
+ >
+ ) : (
+ "Delete project"
+ )}
+
+
+
+
+ >
+ );
+};
diff --git a/apps/web/src/app/(dashboard)/settings/project/_components/local-mode-notice.tsx b/apps/web/src/app/(dashboard)/settings/project/_components/local-mode-notice.tsx
new file mode 100644
index 00000000..0eafda21
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/settings/project/_components/local-mode-notice.tsx
@@ -0,0 +1,20 @@
+import { UsersRound } from "lucide-react";
+
+/**
+ * Local auth mode has exactly one built-in identity, so there is nobody to
+ * share a project WITH. Rename and delete stay live — only this card degrades.
+ */
+export const LocalModeNotice = () => (
+
+
+
+
+
Sharing is unavailable in local mode
+
+ This instance runs in local auth mode, which has exactly one built-in
+ identity (admin@localhost). To invite teammates and share projects with
+ them, configure Google OAuth (NEXTAUTH_SECRET + GOOGLE_CLIENT_ID/
+ GOOGLE_CLIENT_SECRET) and restart.
+
+
+);
diff --git a/apps/web/src/app/(dashboard)/settings/project/_components/project-access-card.tsx b/apps/web/src/app/(dashboard)/settings/project/_components/project-access-card.tsx
new file mode 100644
index 00000000..cb1e5473
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/settings/project/_components/project-access-card.tsx
@@ -0,0 +1,419 @@
+"use client";
+
+import { useState } from "react";
+import { Loader2, Trash2, UserPlus, UsersRound } from "lucide-react";
+import { toast } from "sonner";
+import {
+ Card,
+ CardAction,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@onecli/ui/components/card";
+import { Button } from "@onecli/ui/components/button";
+import { Badge } from "@onecli/ui/components/badge";
+import { Skeleton } from "@onecli/ui/components/skeleton";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@onecli/ui/components/select";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@onecli/ui/components/alert-dialog";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@onecli/ui/components/tooltip";
+import type { ProjectAccessBindings, SetProjectAccessInput } from "@/lib/api";
+import {
+ useProjectAccess,
+ useSetProjectAccess,
+} from "@/hooks/use-project-access";
+import { LocalModeNotice } from "./local-mode-notice";
+import { ProjectAccessDialog } from "./project-access-dialog";
+
+export interface ProjectAccessCardProps {
+ projectId: string;
+ userId: string;
+ canManage: boolean;
+ isOrgAdmin: boolean;
+ sharingEnabled: boolean;
+}
+
+/**
+ * The contract has NO per-row endpoints: every row action PUTs the FULL set.
+ * So each control builds the next set from the currently-cached bindings and
+ * applies exactly one change — one write path, with the server's guards (an
+ * owner must remain; nobody may strand themselves) as the safety net.
+ */
+const toInput = (bindings: ProjectAccessBindings): SetProjectAccessInput => ({
+ users: bindings.users.map((u) => ({ userId: u.userId, role: u.role })),
+ groupIds: bindings.groups.map((g) => g.groupId),
+});
+
+/** The row a confirmation dialog is currently asking about. */
+type PendingRemoval =
+ | { kind: "user"; userId: string; label: string; isSelf: boolean }
+ | { kind: "group"; groupId: string; name: string; memberCount: number };
+
+export const ProjectAccessCard = ({
+ projectId,
+ userId,
+ canManage,
+ isOrgAdmin,
+ sharingEnabled,
+}: ProjectAccessCardProps) => {
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [removal, setRemoval] = useState(null);
+ // Which ROW is mutating, so the click that started it shows a spinner
+ // instead of silently greying every control out.
+ const [busyRowId, setBusyRowId] = useState(null);
+ const access = useProjectAccess(projectId, sharingEnabled);
+ const setAccess = useSetProjectAccess();
+
+ const bindings = access.data;
+ const ownerCount =
+ bindings?.users.filter((u) => u.role === "owner").length ?? 0;
+
+ const apply = (rowId: string, next: SetProjectAccessInput) => {
+ setBusyRowId(rowId);
+ setAccess.mutate(
+ { projectId, ...next },
+ // The hook toasts the server's reason on failure — including the guard
+ // messages, which are the whole point of this surface.
+ {
+ onSuccess: () => {
+ setRemoval(null);
+ toast.success("Project access updated");
+ },
+ onSettled: () => setBusyRowId(null),
+ },
+ );
+ };
+
+ const removeUser = (targetUserId: string) => {
+ if (!bindings) return;
+ const next = toInput(bindings);
+ apply(targetUserId, {
+ ...next,
+ users: next.users.filter((u) => u.userId !== targetUserId),
+ });
+ };
+
+ const changeUserRole = (targetUserId: string, role: "owner" | "member") => {
+ if (!bindings) return;
+ const next = toInput(bindings);
+ apply(targetUserId, {
+ ...next,
+ users: next.users.map((u) =>
+ u.userId === targetUserId ? { ...u, role } : u,
+ ),
+ });
+ };
+
+ const removeGroup = (groupId: string) => {
+ if (!bindings) return;
+ const next = toInput(bindings);
+ apply(groupId, {
+ ...next,
+ groupIds: next.groupIds.filter((id) => id !== groupId),
+ });
+ };
+
+ const confirmRemoval = () => {
+ if (!removal) return;
+ if (removal.kind === "user") removeUser(removal.userId);
+ else removeGroup(removal.groupId);
+ };
+
+ return (
+
+
+ Access
+
+ People and groups who can use this project. Owners can also rename,
+ share and delete it.
+
+ {sharingEnabled && (
+
+ setDialogOpen(true)}
+ >
+
+ Manage access
+
+
+ )}
+
+
+ {!sharingEnabled ? (
+
+ ) : access.isPending ? (
+
+ {[1, 2].map((i) => (
+
+ ))}
+
+ ) : access.isError ? (
+
+
Couldn't load access
+
+ Something went wrong fetching this project's bindings. Reload
+ the page to try again.
+
+
+ ) : (
+ <>
+
+
People
+ {bindings && bindings.users.length > 0 ? (
+
+ {bindings.users.map((row) => {
+ // Client-side mirror of the server's "keep one owner"
+ // guard, so the common case never round-trips to a 400.
+ const isLastOwner = row.role === "owner" && ownerCount <= 1;
+ // A non-admin may not drop or demote themselves (the
+ // server refuses). An admin CAN, for hand-off — the server
+ // only stops them when it would leave them with no project
+ // at all, which the client cannot know (it sees one
+ // project), so that case stays a server 400 and the
+ // confirmation below spells the risk out.
+ const isSelfLock = row.userId === userId && !isOrgAdmin;
+ const locked = isLastOwner || isSelfLock;
+ const lockReason = isLastOwner
+ ? "A project must keep at least one owner"
+ : "You cannot remove your own access to this project";
+ const busy =
+ setAccess.isPending && busyRowId === row.userId;
+
+ return (
+
+
+
+ {row.name ?? row.email}
+
+ {row.name && (
+
+ {row.email}
+
+ )}
+
+ {row.isOwner && (
+
+
+
+ Creator
+
+
+
+ Created this project. Removing their access also
+ stops their project API key from working.
+
+
+ )}
+
+ changeUserRole(
+ row.userId,
+ value === "owner" ? "owner" : "member",
+ )
+ }
+ >
+
+
+
+
+ Owner
+ Member
+
+
+
+
+
+
+ setRemoval({
+ kind: "user",
+ userId: row.userId,
+ label: row.name ?? row.email,
+ isSelf: row.userId === userId,
+ })
+ }
+ >
+ {busy ? (
+
+ ) : (
+
+ )}
+
+
+
+ {locked && (
+ {lockReason}
+ )}
+
+
+ );
+ })}
+
+ ) : (
+
+ Nobody has direct access to this project yet.
+
+ )}
+
+
+
+
Groups
+ {bindings && bindings.groups.length > 0 ? (
+
+ {bindings.groups.map((row) => {
+ const busy =
+ setAccess.isPending && busyRowId === row.groupId;
+ return (
+
+
+
+
+ {row.name}
+
+
+ {row.memberCount} member
+ {row.memberCount === 1 ? "" : "s"}
+
+
+
+ setRemoval({
+ kind: "group",
+ groupId: row.groupId,
+ name: row.name,
+ memberCount: row.memberCount,
+ })
+ }
+ >
+ {busy ? (
+
+ ) : (
+
+ )}
+
+
+ );
+ })}
+
+ ) : (
+
+ No groups have access to this project.
+
+ )}
+
+ Everyone in a group listed here can use the project. Deleting
+ the group removes that access.
+
+
+ >
+ )}
+
+
+ {sharingEnabled && bindings && (
+
+ )}
+
+ {/* Removing a binding revokes LIVE authorization — the gateway and the
+ API both read these rows — so it is confirmed like every other
+ destructive action in the app, with the concrete consequence named. */}
+ {
+ if (!open && !setAccess.isPending) setRemoval(null);
+ }}
+ >
+
+
+
+ {removal?.kind === "group"
+ ? `Remove ${removal.name}?`
+ : `Remove ${removal?.label ?? "this person"}?`}
+
+
+ {removal?.kind === "group"
+ ? `All ${removal.memberCount} member${
+ removal.memberCount === 1 ? "" : "s"
+ } of this group lose access to this project immediately, unless they also have direct access.`
+ : "They lose access to this project immediately, and any project API key they hold stops authenticating."}
+ {removal?.kind === "user" && removal.isSelf
+ ? " This is your own access: if this project is the only one you can reach, the API will refuse rather than lock you out."
+ : ""}
+
+
+
+
+ Cancel
+
+ {
+ e.preventDefault();
+ confirmRemoval();
+ }}
+ disabled={setAccess.isPending}
+ >
+ {setAccess.isPending ? (
+ <>
+
+ Removing...
+ >
+ ) : (
+ "Remove"
+ )}
+
+
+
+
+
+ );
+};
diff --git a/apps/web/src/app/(dashboard)/settings/project/_components/project-access-dialog.tsx b/apps/web/src/app/(dashboard)/settings/project/_components/project-access-dialog.tsx
new file mode 100644
index 00000000..a0631f8a
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/settings/project/_components/project-access-dialog.tsx
@@ -0,0 +1,435 @@
+"use client";
+
+import { useEffect, useMemo, useRef, useState } from "react";
+import { Loader2, Search, TriangleAlert, UsersRound } from "lucide-react";
+import { toast } from "sonner";
+import {
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@onecli/ui/components/dialog";
+import {
+ AnimatedTabs,
+ AnimatedTabList,
+ AnimatedTabTrigger,
+ AnimatedTabContent,
+} from "@onecli/ui/components/animated-tabs";
+import { Button } from "@onecli/ui/components/button";
+import { Input } from "@onecli/ui/components/input";
+import { Badge } from "@onecli/ui/components/badge";
+import { Checkbox } from "@onecli/ui/components/checkbox";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@onecli/ui/components/select";
+import { ApiError, type ProjectAccessBindings } from "@/lib/api";
+import { useOrgMembersList } from "@/hooks/use-org-members";
+import { useGroups } from "@/hooks/use-groups";
+import { useSetProjectAccess } from "@/hooks/use-project-access";
+import { AdminOnlyNotice } from "./admin-only-notice";
+
+export interface ProjectAccessDialogProps {
+ projectId: string;
+ /** The bindings the buffer is seeded from — never a failed read. */
+ current: ProjectAccessBindings;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+type ManagementRole = "owner" | "member";
+
+/**
+ * Replace-set picker for one project — the group-members dialog extended to two
+ * candidate feeds (org members and org groups) sharing one edit buffer. Save
+ * PUTs the exact selection back; there are no per-row endpoints.
+ */
+export const ProjectAccessDialog = ({
+ projectId,
+ current,
+ open,
+ onOpenChange,
+}: ProjectAccessDialogProps) => {
+ const {
+ data: memberCandidates = [],
+ isPending: membersPending,
+ isError: membersError,
+ error: membersFailure,
+ } = useOrgMembersList(open);
+ const {
+ data: groupCandidates = [],
+ isPending: groupsPending,
+ isError: groupsError,
+ error: groupsFailure,
+ } = useGroups(open);
+ const setAccess = useSetProjectAccess();
+
+ const isPending = membersPending || groupsPending;
+ // EITHER feed failing must surface as an ERROR, never as an empty baseline:
+ // this is a replace-set picker, so seeding from a failed read would render
+ // every real grant unchecked and let one toggle + Save wipe the bindings.
+ // (`current` is always the live bindings — the card only renders the dialog
+ // once they loaded.)
+ const isError = membersError || groupsError;
+ // A 403 is the EXPECTED admin-only case (a project owner who is not an org
+ // admin cannot enumerate the directory); anything else is a transport or
+ // server failure and must not be reported as a permission problem.
+ const isForbidden = [membersFailure, groupsFailure].some(
+ (failure) => failure instanceof ApiError && failure.status === 403,
+ );
+
+ const [tab, setTab] = useState("people");
+ const [users, setUsers] = useState>(
+ () => new Map(),
+ );
+ const [groupIds, setGroupIds] = useState>(() => new Set());
+ const [saving, setSaving] = useState(false);
+ const [search, setSearch] = useState("");
+
+ const initialUsers = useMemo(
+ () => new Map(current.users.map((u) => [u.userId, u.role])),
+ [current.users],
+ );
+ const initialGroups = useMemo(
+ () => new Set(current.groups.map((g) => g.groupId)),
+ [current.groups],
+ );
+
+ // Seed the edit buffer once per open, once both feeds settle — guarded so a
+ // background refetch can't clobber in-progress edits. Search clears on close.
+ const seededRef = useRef(false);
+ useEffect(() => {
+ if (!open) {
+ seededRef.current = false;
+ setSearch("");
+ setTab("people");
+ return;
+ }
+ if (seededRef.current || isPending || isError) return;
+ setUsers(new Map(initialUsers));
+ setGroupIds(new Set(initialGroups));
+ seededRef.current = true;
+ }, [open, isPending, isError, initialUsers, initialGroups]);
+
+ const filteredMembers = useMemo(() => {
+ const q = search.trim().toLowerCase();
+ if (!q) return memberCandidates;
+ return memberCandidates.filter(
+ (m) =>
+ m.email.toLowerCase().includes(q) ||
+ (m.name ?? "").toLowerCase().includes(q),
+ );
+ }, [memberCandidates, search]);
+
+ const filteredGroups = useMemo(() => {
+ const q = search.trim().toLowerCase();
+ if (!q) return groupCandidates;
+ return groupCandidates.filter((g) => g.name.toLowerCase().includes(q));
+ }, [groupCandidates, search]);
+
+ const dirty = useMemo(() => {
+ if (users.size !== initialUsers.size) return true;
+ for (const [id, role] of users) {
+ if (initialUsers.get(id) !== role) return true;
+ }
+ if (groupIds.size !== initialGroups.size) return true;
+ for (const id of groupIds) if (!initialGroups.has(id)) return true;
+ return false;
+ }, [users, groupIds, initialUsers, initialGroups]);
+
+ const hasOwner = [...users.values()].includes("owner");
+
+ const toggleUser = (userId: string) => {
+ setUsers((prev) => {
+ const next = new Map(prev);
+ if (next.has(userId)) next.delete(userId);
+ // Checking a person defaults them to a plain use grant; the per-row
+ // select promotes.
+ else next.set(userId, "member");
+ return next;
+ });
+ };
+
+ const setUserRole = (userId: string, role: ManagementRole) => {
+ setUsers((prev) => {
+ const next = new Map(prev);
+ if (next.has(userId)) next.set(userId, role);
+ return next;
+ });
+ };
+
+ const toggleGroup = (groupId: string) => {
+ setGroupIds((prev) => {
+ const next = new Set(prev);
+ if (next.has(groupId)) next.delete(groupId);
+ else next.add(groupId);
+ return next;
+ });
+ };
+
+ // Select-all/clear act on ALL candidates, not just the filtered view.
+ const selectAll = () => {
+ if (tab === "people") {
+ setUsers((prev) => {
+ const next = new Map(prev);
+ for (const m of memberCandidates) {
+ if (!next.has(m.userId)) next.set(m.userId, "member");
+ }
+ return next;
+ });
+ } else {
+ setGroupIds(new Set(groupCandidates.map((g) => g.id)));
+ }
+ };
+ const clearAll = () => {
+ if (tab === "people") setUsers(new Map());
+ else setGroupIds(new Set());
+ };
+
+ const handleSave = async () => {
+ setSaving(true);
+ try {
+ await setAccess.mutateAsync({
+ projectId,
+ users: [...users].map(([userId, role]) => ({ userId, role })),
+ groupIds: [...groupIds],
+ });
+ onOpenChange(false);
+ toast.success("Project access updated");
+ } catch {
+ // The mutation hook already toasts the server reason — keep the dialog
+ // open so the selection isn't lost.
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const selectedCount = tab === "people" ? users.size : groupIds.size;
+ const candidateCount =
+ tab === "people" ? memberCandidates.length : groupCandidates.length;
+
+ return (
+
+
+
+ Manage project access
+
+ Choose who can use this project. Owners can also rename, share and
+ delete it. Groups grant access to everyone in them.
+
+
+
+
+ {isError ? (
+ isForbidden ? (
+
+ ) : (
+
+
+ Couldn't load candidates
+
+
+ Something went wrong fetching the organization's members
+ and groups. Close this dialog and try again.
+
+
+ )
+ ) : isPending ? (
+
+
+
+ ) : (
+
+
+ People
+ Groups
+
+
+
+
+
+ setSearch(e.target.value)}
+ className="h-8 pl-8 text-sm"
+ />
+
+
+
+
+
+ {selectedCount}
+ {" "}
+ of {candidateCount} selected
+
+
+
+ Select all
+
+ /
+
+ Clear
+
+
+
+
+
+ {/* A native max-height scroller, as in the group-members dialog:
+ it shrinks to fit a few rows and caps at the viewport. */}
+
+
+
+ {filteredMembers.map((row) => (
+
+
toggleUser(row.userId)}
+ />
+
+
+ {row.name ?? row.email}
+
+ {row.name && (
+
+ {row.email}
+
+ )}
+
+ {row.status === "suspended" && (
+
+ Suspended
+
+ )}
+
+ setUserRole(
+ row.userId,
+ value === "owner" ? "owner" : "member",
+ )
+ }
+ >
+
+
+
+
+ Owner
+ Member
+
+
+
+ ))}
+
+ {filteredMembers.length === 0 && (
+
+ {memberCandidates.length === 0
+ ? "Invite teammates from the Team page to share this project."
+ : `No people match “${search}”`}
+
+ )}
+
+
+
+
+
+
+
+ {filteredGroups.map((row) => (
+
+ toggleGroup(row.id)}
+ />
+
+
+
+ {row.name}
+
+
+ {row.memberCount} member
+ {row.memberCount === 1 ? "" : "s"}
+
+
+ {row.source === "scim" && (
+
+ IdP-managed
+
+ )}
+
+ ))}
+
+ {filteredGroups.length === 0 && (
+
+ {groupCandidates.length === 0
+ ? "Create a group on the Groups page to share this project with a team."
+ : `No groups match “${search}”`}
+
+ )}
+
+
+
+
+ )}
+
+
+
+ {!isError && !isPending && !hasOwner && (
+
+ A project must keep
+ at least one owner.
+
+ )}
+
+ onOpenChange(false)}>
+ Cancel
+
+
+ {saving ? "Saving..." : "Save"}
+
+
+
+
+
+ );
+};
diff --git a/apps/web/src/app/(dashboard)/settings/project/_components/project-name-card.tsx b/apps/web/src/app/(dashboard)/settings/project/_components/project-name-card.tsx
new file mode 100644
index 00000000..53ab6fc5
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/settings/project/_components/project-name-card.tsx
@@ -0,0 +1,94 @@
+"use client";
+
+import { useState } from "react";
+import { useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@onecli/ui/components/card";
+import { Button } from "@onecli/ui/components/button";
+import { Input } from "@onecli/ui/components/input";
+import { Label } from "@onecli/ui/components/label";
+import type { Project } from "@/lib/api";
+import { queryKeys } from "@/lib/api/keys";
+import { useRenameProject } from "@/hooks/use-projects";
+
+export interface ProjectNameCardProps {
+ project: Project;
+ canManage: boolean;
+}
+
+export const ProjectNameCard = ({
+ project,
+ canManage,
+}: ProjectNameCardProps) => {
+ const [name, setName] = useState(project.name ?? "");
+ const rename = useRenameProject();
+ const qc = useQueryClient();
+
+ const trimmed = name.trim();
+ const error =
+ trimmed.length === 0
+ ? "Name is required."
+ : trimmed.length > 100
+ ? "Name must be 100 characters or fewer."
+ : null;
+ const dirty = trimmed !== (project.name ?? "");
+
+ const handleSave = () => {
+ if (error || !dirty || rename.isPending) return;
+ rename.mutate(
+ { id: project.id, name: trimmed },
+ {
+ onSuccess: () => {
+ // The rename hook deliberately owns no cache, so the invalidation
+ // lives with the component that knows which query it fed.
+ qc.invalidateQueries({
+ queryKey: queryKeys.projects.detail(project.id),
+ });
+ toast.success("Project renamed");
+ },
+ },
+ );
+ };
+
+ return (
+
+
+ Name
+
+ How this project appears across the dashboard. Names do not have to be
+ unique.
+
+
+
+
+
Project name
+
setName(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") handleSave();
+ }}
+ />
+ {error && dirty && (
+
{error}
+ )}
+
+
+ {rename.isPending ? "Saving..." : "Save"}
+
+
+
+ );
+};
diff --git a/apps/web/src/app/(dashboard)/settings/project/_components/project-settings-content.tsx b/apps/web/src/app/(dashboard)/settings/project/_components/project-settings-content.tsx
new file mode 100644
index 00000000..3999be51
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/settings/project/_components/project-settings-content.tsx
@@ -0,0 +1,98 @@
+"use client";
+
+import { Card } from "@onecli/ui/components/card";
+import { Skeleton } from "@onecli/ui/components/skeleton";
+import { useProject } from "@/hooks/use-projects";
+import { useProjectAccess } from "@/hooks/use-project-access";
+import { useOrgMembersList } from "@/hooks/use-org-members";
+import { ProjectNameCard } from "./project-name-card";
+import { ProjectAccessCard } from "./project-access-card";
+import { DeleteProjectCard } from "./delete-project-card";
+import { ReadOnlyNotice } from "./read-only-notice";
+
+export interface ProjectSettingsContentProps {
+ projectId: string;
+ /** The signed-in user's DB id — the same id `ProjectAccessUserRow` carries. */
+ userId: string;
+ /** Threaded from the RSC page (server-only auth mode); false = local mode. */
+ sharingEnabled: boolean;
+}
+
+export const ProjectSettingsContent = ({
+ projectId,
+ userId,
+ sharingEnabled,
+}: ProjectSettingsContentProps) => {
+ const project = useProject(projectId);
+ const access = useProjectAccess(projectId, sharingEnabled);
+ // Doubles as the ADMIN PROBE and as the sharing dialog's candidate feed (one
+ // query key, so the dialog reuses this fetch). `/v1/org/members` is
+ // admin-only, so a success means "org admin" and a 403 means "not".
+ const orgMembers = useOrgMembersList(sharingEnabled);
+
+ // `canManage` is a DISPLAY hint, never an authorization decision — the API's
+ // 403 is the authority, and every mutation surfaces its message as a toast.
+ // Both signals come from data already fetched: an owner binding of my own, or
+ // a successful admin-only directory read.
+ const isOrgAdmin = orgMembers.isSuccess;
+ const holdsOwnerBinding = Boolean(
+ access.data?.users.some((u) => u.userId === userId && u.role === "owner"),
+ );
+ // LOCAL MODE (`!sharingEnabled`, the default OSS self-host) short-circuits:
+ // both probes above are disabled queries there, so neither can ever answer.
+ // That single built-in identity is the organization's owner, so rename and
+ // delete stay live — only the sharing card degrades. The API still decides:
+ // `canManageProject` resolves the local identity's org role through the
+ // ossRoleResolver, and its 403 would surface as a toast.
+ const canManage = !sharingEnabled || isOrgAdmin || holdsOwnerBinding;
+
+ // Both probes are also the reason the page waits: rendering before they
+ // settle would flash every control disabled for a legitimate owner (an
+ // orgMembers 403 settles as `isError`, so a non-admin does not wait twice).
+ const probesPending =
+ sharingEnabled && (orgMembers.isPending || access.isPending);
+
+ if (project.isPending || probesPending) {
+ return (
+ <>
+ {[1, 2, 3].map((i) => (
+
+
+
+
+
+
+
+ ))}
+ >
+ );
+ }
+
+ if (project.isError || !project.data) {
+ // A plain card: no retry, no toast — the failure is deterministic.
+ return (
+
+ Couldn't load this project
+
+ Something went wrong fetching the project. Reload the page to try
+ again.
+
+
+ );
+ }
+
+ return (
+ <>
+ {!canManage && }
+
+
+
+ >
+ );
+};
diff --git a/apps/web/src/app/(dashboard)/settings/project/_components/read-only-notice.tsx b/apps/web/src/app/(dashboard)/settings/project/_components/read-only-notice.tsx
new file mode 100644
index 00000000..4784bcee
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/settings/project/_components/read-only-notice.tsx
@@ -0,0 +1,20 @@
+import { Eye } from "lucide-react";
+
+/**
+ * Rendered when the signed-in user may USE this project but not manage it — a
+ * member holding a plain use grant. Without it the page is a wall of silently
+ * disabled controls (the /team and /groups pages surface the same distinction
+ * with their admin-only notice).
+ */
+export const ReadOnlyNotice = () => (
+
+
+
+
You can view these settings
+
+ Only a project owner or an organization admin can rename this project,
+ change who can use it, or delete it.
+
+
+
+);
diff --git a/apps/web/src/app/(dashboard)/settings/project/loading.tsx b/apps/web/src/app/(dashboard)/settings/project/loading.tsx
new file mode 100644
index 00000000..8827ada1
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/settings/project/loading.tsx
@@ -0,0 +1,23 @@
+import { Card } from "@onecli/ui/components/card";
+import { Skeleton } from "@onecli/ui/components/skeleton";
+import { PageHeader } from "@dashboard/page-header";
+
+export default function ProjectSettingsLoading() {
+ return (
+
+
+ {[1, 2, 3].map((i) => (
+
+
+
+
+
+
+
+ ))}
+
+ );
+}
diff --git a/apps/web/src/app/(dashboard)/settings/project/page.tsx b/apps/web/src/app/(dashboard)/settings/project/page.tsx
new file mode 100644
index 00000000..c0ecaed1
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/settings/project/page.tsx
@@ -0,0 +1,37 @@
+import { Suspense } from "react";
+import type { Metadata } from "next";
+import { PageHeader } from "@dashboard/page-header";
+import { getAuthMode } from "@/lib/auth/auth-mode";
+import { resolveProjectContext } from "@/lib/actions/resolve-user";
+import { ProjectSettingsContent } from "./_components/project-settings-content";
+
+export const metadata: Metadata = {
+ title: "Project",
+};
+
+export default async function ProjectSettingsPage() {
+ // Auth mode is server-only (fs-backed runtime config), so it is resolved here
+ // and threaded down (the /groups + /team precedent). Local mode has exactly
+ // one identity, so sharing is inert — rename and delete stay live.
+ const sharingEnabled = getAuthMode() !== "local";
+ // OSS sends no `X-Project-Id`, and the client session carries no project id,
+ // so the active project is resolved here — through the SAME helper the server
+ // actions use, which gates identically to the API's `resolveProjectId`.
+ const { projectId, userId } = await resolveProjectContext();
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/hooks/use-projects.ts b/apps/web/src/hooks/use-projects.ts
index fa84f85d..1ca3f427 100644
--- a/apps/web/src/hooks/use-projects.ts
+++ b/apps/web/src/hooks/use-projects.ts
@@ -1,8 +1,9 @@
"use client";
-import { useMutation } from "@tanstack/react-query";
+import { useMutation, useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { projects } from "@/lib/api";
+import { queryKeys } from "@/lib/api/keys";
// Project rename/delete go through the audited `/v1/projects/:id` routes. Delete
// flushes the gateway cache for the removed keys server-side, so there is
@@ -10,6 +11,14 @@ import { projects } from "@/lib/api";
// callers handle the on-success refresh/redirect themselves (as the old actions
// did) rather than invalidating a query cache.
+/** The current project's row (name/slug/createdAt) for the settings page. */
+export const useProject = (projectId: string | undefined) =>
+ useQuery({
+ queryKey: queryKeys.projects.detail(projectId ?? ""),
+ queryFn: () => projects.get(projectId ?? ""),
+ enabled: Boolean(projectId),
+ });
+
export const useRenameProject = () =>
useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) =>
diff --git a/apps/web/src/lib/api/client.ts b/apps/web/src/lib/api/client.ts
index 405231ad..89ca8b6c 100644
--- a/apps/web/src/lib/api/client.ts
+++ b/apps/web/src/lib/api/client.ts
@@ -8,11 +8,30 @@ const extractErrorMessage = (body: Record, status: number) => {
return `Request failed: ${status}`;
};
+/**
+ * A failed API response. Still a plain `Error` (every `err instanceof Error`
+ * toast keeps working), plus the HTTP status — a 403 from an admin-only route
+ * is an EXPECTED outcome some surfaces render differently from a transport
+ * failure, and the message alone cannot tell them apart.
+ */
+export class ApiError extends Error {
+ readonly status: number;
+
+ constructor(message: string, status: number) {
+ super(message);
+ this.name = "ApiError";
+ this.status = status;
+ }
+}
+
+const toApiError = (body: Record, status: number) =>
+ new ApiError(extractErrorMessage(body, status), status);
+
export const apiGet = async (path: string): Promise => {
const res = await apiFetch(path);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
- throw new Error(extractErrorMessage(body, res.status));
+ throw toApiError(body, res.status);
}
return res.json();
};
@@ -24,7 +43,7 @@ export const apiPost = async (path: string, body: unknown): Promise => {
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
- throw new Error(extractErrorMessage(data, res.status));
+ throw toApiError(data, res.status);
}
return res.json();
};
@@ -36,7 +55,7 @@ export const apiPatch = async (path: string, body: unknown): Promise => {
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
- throw new Error(extractErrorMessage(data, res.status));
+ throw toApiError(data, res.status);
}
return res.json();
};
@@ -48,7 +67,7 @@ export const apiPut = async (path: string, body: unknown): Promise => {
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
- throw new Error(extractErrorMessage(data, res.status));
+ throw toApiError(data, res.status);
}
return res.json();
};
@@ -57,6 +76,6 @@ export const apiDelete = async (path: string): Promise => {
const res = await apiFetch(path, { method: "DELETE" });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
- throw new Error(extractErrorMessage(body, res.status));
+ throw toApiError(body, res.status);
}
};
diff --git a/apps/web/src/lib/api/index.ts b/apps/web/src/lib/api/index.ts
index 9b662576..08c53b62 100644
--- a/apps/web/src/lib/api/index.ts
+++ b/apps/web/src/lib/api/index.ts
@@ -108,4 +108,5 @@ export type {
AppPermissionDefinitionSummary,
} from "@onecli/api/apps/app-permissions/types";
export { apiGet, apiPost, apiPatch, apiPut, apiDelete } from "./client";
+export { ApiError } from "./client";
export { queryKeys } from "./keys";
diff --git a/apps/web/src/lib/api/keys.ts b/apps/web/src/lib/api/keys.ts
index 4138a1c3..cecc88c7 100644
--- a/apps/web/src/lib/api/keys.ts
+++ b/apps/web/src/lib/api/keys.ts
@@ -70,6 +70,11 @@ export const queryKeys = {
byProvider: (provider: string) =>
[...queryKeys.connections.all(), "provider", provider] as const,
},
+ projects: {
+ all: () => ["projects", ...scope()] as const,
+ detail: (projectId: string) =>
+ [...queryKeys.projects.all(), projectId] as const,
+ },
projectAccess: {
all: () => ["project-access", ...scope()] as const,
list: (projectId: string) =>
diff --git a/apps/web/src/lib/api/projects.ts b/apps/web/src/lib/api/projects.ts
index 5f946cb8..311e419e 100644
--- a/apps/web/src/lib/api/projects.ts
+++ b/apps/web/src/lib/api/projects.ts
@@ -1,6 +1,11 @@
-import { apiPatch, apiDelete } from "./client";
+import { apiGet, apiPatch, apiDelete } from "./client";
import type { Project } from "./types";
+// The project's own row. Nothing else in the API exposes a project's name
+// (`GET /v1/auth/session` returns only `projectId`), so the settings page reads
+// it here.
+export const get = (id: string) => apiGet(`/v1/projects/${id}`);
+
export const rename = (id: string, name: string) =>
apiPatch(`/v1/projects/${id}`, { name });
diff --git a/packages/api/src/lib/gateway-invalidate.ts b/packages/api/src/lib/gateway-invalidate.ts
index 334e8e13..6256031f 100644
--- a/packages/api/src/lib/gateway-invalidate.ts
+++ b/packages/api/src/lib/gateway-invalidate.ts
@@ -24,8 +24,13 @@ export const invalidateGatewayCache = (request: Request) => {
/**
* Flush the gateway's cached config for specific API keys directly. Use this
- * when the keys are about to be — or have just been — deleted, so they can no
- * longer be looked up from the database: capture them first, then flush.
+ * when the keys are about to be deleted, so they can no longer be looked up
+ * from the database: capture them, flush, THEN delete.
+ *
+ * The order is load-bearing. The gateway authenticates `/v1/cache/invalidate`
+ * by resolving the bearer through an uncached `find_api_key` query, so a key
+ * that has already been deleted cannot flush its own entry — the request just
+ * 401s and the rejection is swallowed.
*/
export const invalidateGatewayCacheForKeys = (keys: string[]) => {
for (const key of keys) {
diff --git a/packages/api/src/routes/org/groups.test.ts b/packages/api/src/routes/org/groups.test.ts
index d901f7a9..a2b64d21 100644
--- a/packages/api/src/routes/org/groups.test.ts
+++ b/packages/api/src/routes/org/groups.test.ts
@@ -7,14 +7,7 @@ import type { ApiEnv } from "../../types";
// RoleResolver, and `CAPS.rbac` on. Admin callers arrive with an org API key
// (whose key path re-checks admin through the resolver); the non-admin cases
// use a session, since a non-admin's org key fails key authentication
-// outright. (Same harness as invitations.test.ts / members.test.ts — cloned,
-// not shared.)
-//
-// Reconciliation Stage C ships USER GROUPS ONLY. Role automation
-// (group→org-role mappings) and policy-rule orphan neutralization are separate
-// later stages and are NOT exercised here — the OSS grants engine already
-// treats a rule identity orphaned by an FK cascade as inert, so a group delete
-// needs no explicit neutralization pass.
+// outright. (Same harness as invitations.test.ts — cloned, not shared.)
const ORG = "org-1";
const OTHER_ORG = "org-2";
@@ -34,6 +27,7 @@ vi.hoisted(() => {
interface MemberRow {
organizationId: string;
userId: string;
+ userEmail: string;
role: string;
status: string;
ssoExempt: boolean;
@@ -71,6 +65,17 @@ interface ProjectAccessRow {
groupId: string;
}
+/** Full shape since Slice 5: the membership writers re-resolve these. */
+interface RoleMappingRow {
+ id: string;
+ organizationId: string;
+ groupId: string;
+ role: string;
+ priority: number;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
interface AuditRow {
organizationId?: string;
userId: string;
@@ -86,6 +91,7 @@ const store = vi.hoisted(() => ({
groups: [] as GroupRow[],
groupMembers: [] as GroupMemberRow[],
projectAccess: [] as ProjectAccessRow[],
+ roleMappings: [] as RoleMappingRow[],
audits: [] as AuditRow[],
seq: 0,
txCount: 0,
@@ -126,6 +132,7 @@ vi.mock("@onecli/db", () => {
createdAt?: boolean;
updatedAt?: boolean;
_count?: { select: { members?: boolean; projectAccess?: boolean } };
+ roleMapping?: { select: { id: boolean } };
}
interface GroupMemberWhere {
groupId?: string | { in: string[] };
@@ -135,6 +142,12 @@ vi.mock("@onecli/db", () => {
};
AND?: { OR: KeysetClause[] }[];
}
+ /** The role-mapping seam's reads (Slice 5). */
+ interface MappingWhere {
+ id?: string;
+ organizationId?: string;
+ groupId?: string;
+ }
interface OrgMemberWhere {
organizationId?: string;
userId?: string | { in: string[] };
@@ -191,8 +204,8 @@ vi.mock("@onecli/db", () => {
return matchesKeyset(row, where.AND);
});
- // Mirror Prisma's `select` (incl. `_count`) so a route can't accidentally
- // leak a column the service didn't ask for.
+ // Mirror Prisma's `select` (incl. `_count` and the roleMapping relation) so
+ // a route can't accidentally leak a column the service didn't ask for.
const pickGroup = (row: GroupRow, select?: GroupSelect) => {
if (!select) return { ...row };
const picked: Record = {};
@@ -220,6 +233,10 @@ vi.mock("@onecli/db", () => {
}
picked._count = count;
}
+ if (select.roleMapping) {
+ const mapping = store.roleMappings.find((rm) => rm.groupId === row.id);
+ picked.roleMapping = mapping ? { id: mapping.id } : null;
+ }
return picked;
};
@@ -297,118 +314,14 @@ vi.mock("@onecli/db", () => {
return true;
});
- const dbGroup = {
- findFirst: async ({
- where,
- select,
- }: {
- where: GroupWhere;
- select?: GroupSelect;
- }) => {
- // Race simulation: the create pre-check (a name-keyed findFirst)
- // misses, so the create itself must surface the P2002.
- if (store.race && where.name !== undefined) return null;
- const row = filterGroups(where)[0];
- return row ? pickGroup(row, select) : null;
- },
- findMany: async ({
- where,
- select,
- take,
- }: {
- where: GroupWhere;
- select?: GroupSelect;
- take?: number;
- }) => {
- const rows = filterGroups(where)
- .slice()
- .sort(
- (a, b) =>
- a.createdAt.getTime() - b.createdAt.getTime() ||
- a.id.localeCompare(b.id),
- );
- const limited = take === undefined ? rows : rows.slice(0, take);
- return limited.map((row) => pickGroup(row, select));
- },
- create: async ({
- data,
- select,
- }: {
- data: {
- organizationId: string;
- name: string;
- source: string;
- externalId?: string | null;
- };
- select?: GroupSelect;
- }) => {
- const dupe = store.groups.some(
- (g) => g.organizationId === data.organizationId && g.name === data.name,
- );
- if (dupe) {
- throw new PrismaClientKnownRequestError(
- "Unique constraint failed",
- "P2002",
- );
- }
- const row: GroupRow = {
- id: `g-${++store.seq}`,
- organizationId: data.organizationId,
- name: data.name,
- source: data.source,
- externalId: data.externalId ?? null,
- createdAt: new Date(),
- updatedAt: new Date(),
- };
- store.groups.push(row);
- return pickGroup(row, select);
- },
- // Org-scoped conditional write (the rename path): unique violations
- // surface as P2002, a filter miss as count 0.
- updateMany: async ({
- where,
- data,
- }: {
- where: GroupWhere;
- data: { name: string };
- }) => {
- const rows = filterGroups(where);
- for (const row of rows) {
- const dupe = store.groups.some(
- (g) =>
- g.organizationId === row.organizationId &&
- g.name === data.name &&
- g.id !== row.id,
- );
- if (dupe) {
- throw new PrismaClientKnownRequestError(
- "Unique constraint failed",
- "P2002",
- );
- }
- row.name = data.name;
- row.updatedAt = new Date();
- }
- return { count: rows.length };
- },
- // Delete applies the DB cascades the shipped migration declares:
- // GroupMember and ProjectAccess group bindings go with the row (as do
- // GroupRoleMapping and PolicyRuleIdentity in stages that populate them).
- deleteMany: async ({ where }: { where: GroupWhere }) => {
- const rows = filterGroups(where);
- for (const row of rows) {
- store.groupMembers = store.groupMembers.filter(
- (m) => m.groupId !== row.id,
- );
- store.projectAccess = store.projectAccess.filter(
- (pa) => pa.groupId !== row.id,
- );
- }
- const ids = new Set(rows.map((r) => r.id));
- store.groups = store.groups.filter((g) => !ids.has(g.id));
- return { count: rows.length };
- },
- };
+ const filterMappings = (where: MappingWhere) =>
+ store.roleMappings.filter(
+ (row) =>
+ (where.id === undefined || row.id === where.id) &&
+ (where.organizationId === undefined ||
+ row.organizationId === where.organizationId) &&
+ (where.groupId === undefined || row.groupId === where.groupId),
+ );
return {
Prisma: { JsonNull: null, PrismaClientKnownRequestError },
@@ -489,24 +402,192 @@ vi.mock("@onecli/db", () => {
: where.status.not === undefined ||
row.status !== where.status.not)),
) ?? null,
- // THE membership-validation query: { organizationId, userId: { in } }.
+ // THE membership-validation query: { organizationId, userId: { in } }
+ // — plus the role-mapping apply's candidate read, which adds
+ // `role: { not: "owner" }` and selects role/userEmail.
findMany: async ({
where,
select,
}: {
where: OrgMemberWhere;
- select?: { userId?: boolean; role?: boolean };
+ select?: { userId?: boolean; role?: boolean; userEmail?: boolean };
}) =>
filterOrgMembers(where).map((row) => {
if (!select) return { ...row };
const picked: Record = {};
if (select.userId) picked.userId = row.userId;
if (select.role) picked.role = row.role;
+ if (select.userEmail) picked.userEmail = row.userEmail;
return picked;
}),
+ // The apply's role write. The `role: { not: "owner" }` predicate is
+ // honoured, or the "a mapping never demotes an owner" cases would be
+ // testing the mock rather than the service.
+ updateMany: async ({
+ where,
+ data,
+ }: {
+ where: OrgMemberWhere;
+ data: { role: string };
+ }) => {
+ const rows = filterOrgMembers(where);
+ for (const row of rows) row.role = data.role;
+ return { count: rows.length };
+ },
count: async () => 0,
},
- group: dbGroup,
+ // Slice 5: the membership writers re-resolve group→role mappings, so
+ // the seam needs the mapping table to read.
+ groupRoleMapping: {
+ findFirst: async ({ where }: { where: MappingWhere }) =>
+ filterMappings(where)[0] ?? null,
+ findMany: async ({
+ where,
+ select,
+ }: {
+ where: MappingWhere;
+ select?: Record;
+ }) =>
+ filterMappings(where)
+ .slice()
+ .sort(
+ (a, b) =>
+ a.priority - b.priority ||
+ a.createdAt.getTime() - b.createdAt.getTime() ||
+ a.id.localeCompare(b.id),
+ )
+ .map((row) => {
+ if (!select) return { ...row };
+ const picked: Record = {};
+ for (const key of [
+ "id",
+ "organizationId",
+ "groupId",
+ "role",
+ "priority",
+ "createdAt",
+ "updatedAt",
+ ] as const) {
+ if (select[key]) picked[key] = row[key];
+ }
+ return picked;
+ }),
+ },
+ group: {
+ findFirst: async ({
+ where,
+ select,
+ }: {
+ where: GroupWhere;
+ select?: GroupSelect;
+ }) => {
+ // Race simulation: the create pre-check (a name-keyed findFirst)
+ // misses, so the create itself must surface the P2002.
+ if (store.race && where.name !== undefined) return null;
+ const row = filterGroups(where)[0];
+ return row ? pickGroup(row, select) : null;
+ },
+ findMany: async ({
+ where,
+ select,
+ take,
+ }: {
+ where: GroupWhere;
+ select?: GroupSelect;
+ take?: number;
+ }) => {
+ const rows = filterGroups(where)
+ .slice()
+ .sort(
+ (a, b) =>
+ a.createdAt.getTime() - b.createdAt.getTime() ||
+ a.id.localeCompare(b.id),
+ );
+ const limited = take === undefined ? rows : rows.slice(0, take);
+ return limited.map((row) => pickGroup(row, select));
+ },
+ create: async ({
+ data,
+ select,
+ }: {
+ data: {
+ organizationId: string;
+ name: string;
+ source: string;
+ externalId?: string | null;
+ };
+ select?: GroupSelect;
+ }) => {
+ const dupe = store.groups.some(
+ (g) =>
+ g.organizationId === data.organizationId && g.name === data.name,
+ );
+ if (dupe) {
+ throw new PrismaClientKnownRequestError(
+ "Unique constraint failed",
+ "P2002",
+ );
+ }
+ const row: GroupRow = {
+ id: `g-${++store.seq}`,
+ organizationId: data.organizationId,
+ name: data.name,
+ source: data.source,
+ externalId: data.externalId ?? null,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+ store.groups.push(row);
+ return pickGroup(row, select);
+ },
+ // Org-scoped conditional write (the rename path): unique violations
+ // surface as P2002, a filter miss as count 0.
+ updateMany: async ({
+ where,
+ data,
+ }: {
+ where: GroupWhere;
+ data: { name: string };
+ }) => {
+ const rows = filterGroups(where);
+ for (const row of rows) {
+ const dupe = store.groups.some(
+ (g) =>
+ g.organizationId === row.organizationId &&
+ g.name === data.name &&
+ g.id !== row.id,
+ );
+ if (dupe) {
+ throw new PrismaClientKnownRequestError(
+ "Unique constraint failed",
+ "P2002",
+ );
+ }
+ row.name = data.name;
+ row.updatedAt = new Date();
+ }
+ return { count: rows.length };
+ },
+ // Delete applies the DB cascades the shipped migration declares:
+ // GroupMember, ProjectAccess group bindings, GroupRoleMapping.
+ deleteMany: async ({ where }: { where: GroupWhere }) => {
+ const rows = filterGroups(where);
+ for (const row of rows) {
+ store.groupMembers = store.groupMembers.filter(
+ (m) => m.groupId !== row.id,
+ );
+ store.projectAccess = store.projectAccess.filter(
+ (pa) => pa.groupId !== row.id,
+ );
+ store.roleMappings = store.roleMappings.filter(
+ (rm) => rm.groupId !== row.id,
+ );
+ }
+ const ids = new Set(rows.map((r) => r.id));
+ store.groups = store.groups.filter((g) => !ids.has(g.id));
+ return { count: rows.length };
+ },
+ },
groupMember: {
findUnique: async ({
where,
@@ -611,14 +692,9 @@ vi.mock("@onecli/db", () => {
return data;
},
},
- // The replace-set writer runs its delete+create under ONE array-form
- // transaction; the delete path is a plain conditional deleteMany.
- $transaction: async (arg: unknown) => {
+ $transaction: async (ops: Promise[]) => {
store.txCount++;
- if (typeof arg === "function") {
- return (arg as (tx: unknown) => Promise)({ group: dbGroup });
- }
- return Promise.all(arg as Promise[]);
+ return Promise.all(ops);
},
},
};
@@ -650,6 +726,7 @@ const member = (
): MemberRow => ({
organizationId,
userId,
+ userEmail: `${userId}@example.com`,
role,
status: "active",
ssoExempt: false,
@@ -745,6 +822,20 @@ beforeEach(() => {
},
];
store.projectAccess = [{ id: "pa-1", projectId: "proj-1", groupId: "g-a" }];
+ // A CONVERGED baseline: `member` is the weakest role, so this mapping can
+ // never raise anyone and every existing membership case stays a no-op on
+ // the Slice 5 seam. Cases that need a real apply promote it to "admin".
+ store.roleMappings = [
+ {
+ id: "rm-1",
+ organizationId: ORG,
+ groupId: "g-a",
+ role: "member",
+ priority: 0,
+ createdAt: at(30),
+ updatedAt: at(30),
+ },
+ ];
store.audits = [];
store.seq = 100;
store.txCount = 0;
@@ -1103,11 +1194,14 @@ describe("DELETE /v1/org/groups/:groupId", () => {
name: "Engineering",
removedMembers: 2,
removedProjectBindings: 1,
+ removedRoleMappings: 1,
});
- // Cascades applied: membership and project bindings went with the group.
+ // Cascades applied: membership, project bindings, and the role mapping
+ // went with the group.
expect(groupRow("g-a")).toBeUndefined();
expect(membersOf("g-a")).toEqual([]);
expect(store.projectAccess.some((pa) => pa.groupId === "g-a")).toBe(false);
+ expect(store.roleMappings.some((rm) => rm.groupId === "g-a")).toBe(false);
});
it("audits counts only — never id arrays", async () => {
@@ -1122,6 +1216,7 @@ describe("DELETE /v1/org/groups/:groupId", () => {
name: "Engineering",
removedMembers: 2,
removedProjectBindings: 1,
+ removedRoleMappings: 1,
},
});
for (const value of Object.values(store.audits[0]?.metadata ?? {})) {
@@ -1386,3 +1481,191 @@ describe("DELETE /v1/org/groups/:groupId/members/:userId (single remove)", () =>
expect(membersOf("g-scim")).toEqual([MEMBER]);
});
});
+
+// ── Slice 5 seam: group→role mappings ───────────────────────────────────
+//
+// The membership writers re-resolve mapped org roles after their write. The
+// contract they must honour is decision C: a mapping is a FLOOR — it can
+// RAISE a member's org role and can never lower one.
+describe("the role-mapping seam", () => {
+ const mapGroupToAdmin = (groupId: string) => {
+ store.roleMappings = [
+ {
+ id: "rm-admin",
+ organizationId: ORG,
+ groupId,
+ role: "admin",
+ priority: 0,
+ createdAt: at(30),
+ updatedAt: at(30),
+ },
+ ];
+ };
+
+ const roleOf = (userId: string) =>
+ store.members.find((m) => m.organizationId === ORG && m.userId === userId)
+ ?.role;
+
+ const memberAudits = () => store.audits.filter((a) => a.service === "member");
+
+ it("raises a user added to an admin-mapped group, alongside the GROUP audit", async () => {
+ mapGroupToAdmin("g-a");
+ const res = await putMembers("g-a", { userIds: [OWNER, ADMIN, MEMBER] });
+ expect(res.status).toBe(200);
+ expect(roleOf(MEMBER)).toBe("admin");
+ // The owner is untouchable and the acting admin is skipped.
+ expect(roleOf(OWNER)).toBe("owner");
+
+ expect(memberAudits()).toHaveLength(1);
+ expect(memberAudits()[0]).toMatchObject({
+ organizationId: ORG,
+ userId: ADMIN,
+ action: "update",
+ service: "member",
+ source: "api",
+ metadata: {
+ targetUserId: MEMBER,
+ role: "admin",
+ previousRole: "member",
+ via: "role-mapping",
+ mappingId: "rm-admin",
+ groupId: "g-a",
+ trigger: "membership",
+ },
+ });
+ expect(store.audits.filter((a) => a.service === "group")).toHaveLength(1);
+ });
+
+ it("changes no roles when the group has no mapping of its own", async () => {
+ mapGroupToAdmin("g-a");
+ const res = await putMembers("g-b", { userIds: [MEMBER] });
+ expect(res.status).toBe(200);
+ expect(roleOf(MEMBER)).toBe("member");
+ expect(memberAudits()).toHaveLength(0);
+ });
+
+ it("does NOT demote when a user is removed from an admin-mapped group", async () => {
+ mapGroupToAdmin("g-a");
+ expect((await putMember("g-a", MEMBER)).status).toBe(200);
+ expect(roleOf(MEMBER)).toBe("admin");
+
+ store.audits = [];
+ const res = await deleteMember("g-a", MEMBER);
+ expect(res.status).toBe(200);
+ // The grant sticks: only PATCH /v1/org/members/:userId can lower a role.
+ expect(roleOf(MEMBER)).toBe("admin");
+ expect(memberAudits()).toHaveLength(0);
+ });
+
+ // The removal paths feed the ids they just removed back into the apply, so a
+ // user who was being SHADOWED by this group's mapping is re-resolved against
+ // the mappings that still cover them. Without that they are absent from the
+ // post-write member read and stay under-privileged until some unrelated
+ // write happens to converge them.
+ const shadowThenAdmin = () => {
+ store.roleMappings = [
+ {
+ id: "rm-shadow",
+ organizationId: ORG,
+ groupId: "g-a",
+ role: "member",
+ priority: 0,
+ createdAt: at(30),
+ updatedAt: at(30),
+ },
+ {
+ id: "rm-admin",
+ organizationId: ORG,
+ groupId: "g-b",
+ role: "admin",
+ priority: 1,
+ createdAt: at(31),
+ updatedAt: at(31),
+ },
+ ];
+ store.groupMembers.push(
+ {
+ groupId: "g-a",
+ userId: MEMBER,
+ createdByUserId: ADMIN,
+ createdAt: at(24),
+ },
+ {
+ groupId: "g-b",
+ userId: MEMBER,
+ createdByUserId: ADMIN,
+ createdAt: at(25),
+ },
+ );
+ };
+
+ it("UNSHADOWS a single remove: leaving the member-mapped group raises to admin", async () => {
+ shadowThenAdmin();
+ expect(roleOf(MEMBER)).toBe("member");
+
+ const res = await deleteMember("g-a", MEMBER);
+ expect(res.status).toBe(200);
+ expect(roleOf(MEMBER)).toBe("admin");
+ expect(memberAudits()).toHaveLength(1);
+ expect(memberAudits()[0]).toMatchObject({
+ metadata: {
+ targetUserId: MEMBER,
+ role: "admin",
+ previousRole: "member",
+ via: "role-mapping",
+ mappingId: "rm-admin",
+ groupId: "g-b",
+ trigger: "membership",
+ },
+ });
+ });
+
+ it("UNSHADOWS a replace-set that DROPS a user, down to the last member", async () => {
+ shadowThenAdmin();
+ // g-a keeps OWNER + ADMIN, neither of which the apply may touch — the
+ // dropped MEMBER is the only candidate, and only because the writer hands
+ // their id over.
+ const res = await putMembers("g-a", { userIds: [OWNER, ADMIN] });
+ expect(res.status).toBe(200);
+ expect(await res.json()).toMatchObject({ removed: 1 });
+ expect(roleOf(MEMBER)).toBe("admin");
+
+ // Again with the group emptied entirely: the removed ids are what keep the
+ // apply from short-circuiting on "this group has no members left".
+ const row = store.members.find((m) => m.userId === MEMBER);
+ if (row) row.role = "member";
+ store.groupMembers.push({
+ groupId: "g-a",
+ userId: MEMBER,
+ createdByUserId: ADMIN,
+ createdAt: at(26),
+ });
+ const cleared = await putMembers("g-a", { userIds: [] });
+ expect(cleared.status).toBe(200);
+ expect(membersOf("g-a")).toEqual([]);
+ expect(roleOf(MEMBER)).toBe("admin");
+ });
+
+ it("a no-delta replace-set opens no transaction and changes no roles", async () => {
+ mapGroupToAdmin("g-a");
+ const res = await putMembers("g-a", { userIds: [OWNER, ADMIN] });
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({ added: 0, removed: 0 });
+ expect(store.txCount).toBe(0);
+ expect(memberAudits()).toHaveLength(0);
+ });
+
+ it("deleting a mapped group cascades the mapping but never reverts roles", async () => {
+ mapGroupToAdmin("g-a");
+ expect((await putMember("g-a", MEMBER)).status).toBe(200);
+ expect(roleOf(MEMBER)).toBe("admin");
+
+ store.audits = [];
+ const res = await remove("g-a");
+ expect(res.status).toBe(200);
+ expect(await res.json()).toMatchObject({ removedRoleMappings: 1 });
+ expect(store.roleMappings).toHaveLength(0);
+ expect(roleOf(MEMBER)).toBe("admin");
+ expect(memberAudits()).toHaveLength(0);
+ });
+});
diff --git a/packages/api/src/routes/org/groups.ts b/packages/api/src/routes/org/groups.ts
index 243a348f..f931e055 100644
--- a/packages/api/src/routes/org/groups.ts
+++ b/packages/api/src/routes/org/groups.ts
@@ -123,7 +123,7 @@ export const orgGroupRoutes = () => {
const groupId = c.req.param("groupId");
const result = await withAudit(
- () => deleteOrgGroup(auth.organizationId, groupId),
+ () => deleteOrgGroup(auth.organizationId, auth.userId, groupId),
(deleted) => ({
...auditBase(c),
action: AUDIT_ACTIONS.DELETE,
@@ -133,6 +133,7 @@ export const orgGroupRoutes = () => {
name: deleted.name,
removedMembers: deleted.removedMembers,
removedProjectBindings: deleted.removedProjectBindings,
+ removedRoleMappings: deleted.removedRoleMappings,
},
}),
);
@@ -205,7 +206,8 @@ export const orgGroupRoutes = () => {
const userId = c.req.param("userId");
const result = await withAudit(
- () => removeOrgGroupMember(auth.organizationId, groupId, userId),
+ () =>
+ removeOrgGroupMember(auth.organizationId, auth.userId, groupId, userId),
(r) => ({
...auditBase(c),
action: AUDIT_ACTIONS.UPDATE,
diff --git a/packages/api/src/routes/org/index.ts b/packages/api/src/routes/org/index.ts
index 57c3ccfc..d14c5b6c 100644
--- a/packages/api/src/routes/org/index.ts
+++ b/packages/api/src/routes/org/index.ts
@@ -3,9 +3,11 @@ import type { ApiEnv } from "../../types";
import { orgMemberRoutes } from "./members";
import { orgInvitationRoutes } from "./invitations";
import { orgGroupRoutes } from "./groups";
+import { orgRoleMappingRoutes } from "./role-mappings";
+import { ossProjectRoutes } from "./projects";
/**
- * The OSS edition's `/v1/org/*` surface.
+ * The OSS edition's EDITION SURFACE: `/v1/org/*` PLUS `/v1/projects/*`.
*
* OSS-ONLY BY CONSTRUCTION. This is never registered in the shared
* `createApiApp` route table: it is mounted through
@@ -14,13 +16,22 @@ import { orgGroupRoutes } from "./groups";
* replaces with its own org router. Registering here rather than in `app.ts`
* keeps the shared file free of edition-specific routes (upstream-merge
* collisions) and avoids Hono's first-registration-wins silently shadowing an
- * EE route with an OSS one.
+ * EE route with an OSS one — which is exactly why project administration is
+ * registered here too, even though its URL is not under `/org`. The exported
+ * name stays `registerOssOrgRoutes`: renaming it buys nothing and touches the
+ * init seam plus every route test.
*
- * Later org slices (invitations, groups, role mappings) append their
- * `app.route(...)` line here.
+ * Mounting a sub-app re-registers its `use("*")` guards under the mount path,
+ * so each sub-app's guard stack covers every path beneath it — and only those.
+ * `/projects` therefore owns the whole `/v1/projects/*` namespace in OSS; do
+ * not register a second router there.
+ *
+ * Later org slices (role mappings, …) append their `app.route(...)` line here.
*/
export const registerOssOrgRoutes = (app: Hono) => {
app.route("/org/members", orgMemberRoutes());
app.route("/org/invitations", orgInvitationRoutes());
app.route("/org/groups", orgGroupRoutes());
+ app.route("/org/role-mappings", orgRoleMappingRoutes());
+ app.route("/projects", ossProjectRoutes());
};
diff --git a/packages/api/src/routes/org/projects.test.ts b/packages/api/src/routes/org/projects.test.ts
new file mode 100644
index 00000000..9768de89
--- /dev/null
+++ b/packages/api/src/routes/org/projects.test.ts
@@ -0,0 +1,1747 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { Hono } from "hono";
+import type { ApiEnv } from "../../types";
+
+// `/v1/projects` end-to-end through the real app: the OSS routes mounted on
+// the `eeRoutes` seam, the OSS role resolver wired as the RoleResolver, and
+// `CAPS.rbac` on. Same harness shape as groups.test.ts — cloned, not shared —
+// except that `project_access` is a REAL table here rather than the
+// `findFirst: async () => null` stub the org suites use: these rows are the
+// authorization data three enforcement points read.
+//
+// Admin callers arrive with an org API key; the non-admin cases use a session,
+// since a non-admin's org key fails key authentication outright.
+
+const ORG = "org-1";
+const OTHER_ORG = "org-2";
+const OWNER = "user-owner";
+const ADMIN = "user-admin";
+const MEMBER = "user-member";
+const MEMBER2 = "user-member2";
+const OUTSIDER = "user-outsider";
+/** A real User row with NO membership in either org (Decision J's filter). */
+const STRANGER = "user-stranger";
+const ADMIN_KEY = "oc_org_admin-key";
+const PROJECT_KEY = "oc_project-key-of-owner";
+
+vi.hoisted(() => {
+ process.env.NEXT_PUBLIC_EDITION = "oss";
+ process.env.SECRET_ENCRYPTION_KEY = "test-secret";
+ process.env.OAUTH_STATE_SECRET = "test-secret";
+});
+
+interface MemberRow {
+ organizationId: string;
+ userId: string;
+ role: string;
+ status: string;
+ createdAt: Date;
+}
+
+interface UserRow {
+ id: string;
+ externalAuthId: string;
+ email: string;
+ name: string | null;
+}
+
+interface ProjectRow {
+ id: string;
+ organizationId: string;
+ name: string | null;
+ slug: string | null;
+ createdByUserId: string | null;
+ createdAt: Date;
+}
+
+interface GroupRow {
+ id: string;
+ organizationId: string;
+ name: string;
+ source: string;
+}
+
+interface GroupMemberRow {
+ groupId: string;
+ userId: string;
+}
+
+interface AccessRow {
+ id: string;
+ projectId: string;
+ userId: string | null;
+ groupId: string | null;
+ role: string;
+ createdByUserId: string | null;
+ createdAt: Date;
+}
+
+/** Every project-child table this suite exercises shares this shape. */
+interface ChildRow {
+ id: string;
+ projectId: string;
+}
+
+interface KeyRow extends ChildRow {
+ key: string;
+}
+
+interface AuditRow {
+ organizationId?: string;
+ projectId?: string;
+ userId: string;
+ action: string;
+ service: string;
+ source: string;
+ metadata: Record;
+}
+
+const store = vi.hoisted(() => ({
+ users: [] as UserRow[],
+ members: [] as MemberRow[],
+ projects: [] as ProjectRow[],
+ groups: [] as GroupRow[],
+ groupMembers: [] as GroupMemberRow[],
+ projectAccess: [] as AccessRow[],
+ agents: [] as ChildRow[],
+ apiKeys: [] as KeyRow[],
+ secrets: [] as ChildRow[],
+ appConnections: [] as ChildRow[],
+ appConfigs: [] as ChildRow[],
+ policyRules: [] as ChildRow[],
+ policyRulesV2: [] as ChildRow[],
+ vaultConnections: [] as ChildRow[],
+ budgets: [] as ChildRow[],
+ onboardingSurveys: [] as ChildRow[],
+ audits: [] as AuditRow[],
+ seq: 0,
+ txCount: 0,
+ /** Which user the session provider resolves to (null = no session). */
+ sessionUserId: null as string | null,
+}));
+
+/** Gateway flushes are spied, never fetched: the DELETE path must hand the
+ * keys it captured BEFORE the delete to invalidateGatewayCacheForKeys. */
+const flushes = vi.hoisted(() => ({
+ keys: [] as string[][],
+ orgs: [] as string[],
+ accounts: [] as string[],
+}));
+
+vi.mock("../../lib/gateway-invalidate", () => ({
+ invalidateGatewayCache: () => {},
+ invalidateGatewayCacheForKeys: (keys: string[]) => {
+ flushes.keys.push(keys);
+ },
+ invalidateGatewayCacheForAccount: (projectId: string) => {
+ flushes.accounts.push(projectId);
+ },
+ invalidateGatewayCacheForOrg: (organizationId: string) => {
+ flushes.orgs.push(organizationId);
+ },
+}));
+
+vi.mock("@onecli/db", () => {
+ // ── where shapes these routes actually build ────────────────────────────
+ interface StringFilter {
+ not?: string | null;
+ in?: string[];
+ }
+ interface BindingClause {
+ userId?: string;
+ group?: { members: { some: { userId: string } } };
+ }
+ interface ProjectWhere {
+ id?: string | StringFilter;
+ organizationId?: string;
+ createdByUserId?: string;
+ organization?: {
+ members: { some: { userId: string; status?: { not?: string } } };
+ };
+ accessBindings?: { some: { OR: BindingClause[] } };
+ OR?: ProjectWhere[];
+ }
+ interface AccessWhere {
+ projectId?: string;
+ userId?: string | StringFilter | null;
+ groupId?: string | StringFilter | null;
+ role?: string;
+ user?: { organizationMemberships: { some: { organizationId: string } } };
+ group?: { organizationId: string };
+ OR?: BindingClause[];
+ }
+
+ const matchesString = (
+ value: string | null,
+ filter: string | StringFilter | null | undefined,
+ ): boolean => {
+ if (filter === undefined) return true;
+ if (filter === null) return value === null;
+ if (typeof filter === "string") return value === filter;
+ if (filter.in !== undefined)
+ return value !== null && filter.in.includes(value);
+ if ("not" in filter) {
+ if (filter.not === null) return value !== null;
+ return value !== filter.not;
+ }
+ return true;
+ };
+
+ /** Does `projectId` carry a binding satisfying any of the OR clauses? */
+ const matchesBindingClause = (projectId: string, clauses: BindingClause[]) =>
+ clauses.some((clause) => {
+ if (clause.userId !== undefined) {
+ return store.projectAccess.some(
+ (pa) => pa.projectId === projectId && pa.userId === clause.userId,
+ );
+ }
+ const userId = clause.group?.members.some.userId;
+ if (userId === undefined) return false;
+ return store.projectAccess.some(
+ (pa) =>
+ pa.projectId === projectId &&
+ pa.groupId !== null &&
+ store.groupMembers.some(
+ (gm) => gm.groupId === pa.groupId && gm.userId === userId,
+ ),
+ );
+ });
+
+ const matchesProject = (row: ProjectRow, where: ProjectWhere): boolean => {
+ if (!matchesString(row.id, where.id)) return false;
+ if (
+ where.organizationId !== undefined &&
+ row.organizationId !== where.organizationId
+ )
+ return false;
+ if (
+ where.createdByUserId !== undefined &&
+ row.createdByUserId !== where.createdByUserId
+ )
+ return false;
+ if (where.organization) {
+ const { userId, status } = where.organization.members.some;
+ const membership = store.members.find(
+ (m) => m.organizationId === row.organizationId && m.userId === userId,
+ );
+ if (!membership) return false;
+ if (status?.not !== undefined && membership.status === status.not)
+ return false;
+ }
+ if (
+ where.accessBindings &&
+ !matchesBindingClause(row.id, where.accessBindings.some.OR)
+ )
+ return false;
+ if (where.OR && !where.OR.some((sub) => matchesProject(row, sub)))
+ return false;
+ return true;
+ };
+
+ const matchesAccess = (row: AccessRow, where: AccessWhere): boolean => {
+ if (where.projectId !== undefined && row.projectId !== where.projectId)
+ return false;
+ if (!matchesString(row.userId, where.userId)) return false;
+ if (!matchesString(row.groupId, where.groupId)) return false;
+ if (where.role !== undefined && row.role !== where.role) return false;
+ if (where.user) {
+ const organizationId =
+ where.user.organizationMemberships.some.organizationId;
+ const isMember = store.members.some(
+ (m) => m.userId === row.userId && m.organizationId === organizationId,
+ );
+ if (!isMember) return false;
+ }
+ if (where.group) {
+ const group = store.groups.find((g) => g.id === row.groupId);
+ if (!group || group.organizationId !== where.group.organizationId)
+ return false;
+ }
+ if (where.OR && !matchesBindingClause(row.projectId, where.OR))
+ return false;
+ return true;
+ };
+
+ interface AccessSelect {
+ id?: boolean;
+ userId?: boolean;
+ groupId?: boolean;
+ role?: boolean;
+ createdAt?: boolean;
+ user?: { select: { email?: boolean; name?: boolean } };
+ group?: {
+ select: {
+ name?: boolean;
+ _count?: { select: { members?: boolean } };
+ members?: { select: { userId?: boolean } };
+ };
+ };
+ }
+
+ const pickAccess = (row: AccessRow, select?: AccessSelect) => {
+ if (!select) return { ...row };
+ const picked: Record = {};
+ for (const key of [
+ "id",
+ "userId",
+ "groupId",
+ "role",
+ "createdAt",
+ ] as const) {
+ if (select[key]) picked[key] = row[key];
+ }
+ if (select.user) {
+ const user = store.users.find((u) => u.id === row.userId);
+ picked.user = user
+ ? { email: user.email, name: user.name }
+ : { email: "", name: null };
+ }
+ if (select.group) {
+ const group = store.groups.find((g) => g.id === row.groupId);
+ const members = store.groupMembers.filter(
+ (gm) => gm.groupId === row.groupId,
+ );
+ const value: Record = {};
+ if (select.group.select.name) value.name = group?.name ?? "";
+ if (select.group.select._count)
+ value._count = { members: members.length };
+ if (select.group.select.members)
+ value.members = members.map((m) => ({ userId: m.userId }));
+ picked.group = group ? value : null;
+ }
+ return picked;
+ };
+
+ /** Every `projects`-child table shares count/deleteMany, keyed by projectId. */
+ const childDelegate = (
+ read: () => T[],
+ write: (rows: T[]) => void,
+ ) => ({
+ count: async ({ where }: { where: { projectId: string } }) =>
+ read().filter((row) => row.projectId === where.projectId).length,
+ deleteMany: async ({ where }: { where: { projectId: string } }) => {
+ const before = read().length;
+ write(read().filter((row) => row.projectId !== where.projectId));
+ return { count: before - read().length };
+ },
+ });
+
+ const delegates = {
+ user: {
+ findUnique: async ({
+ where,
+ select,
+ }: {
+ where: { id?: string; externalAuthId?: string };
+ select?: Record;
+ }) => {
+ const user = store.users.find(
+ (u) =>
+ (where.id !== undefined && u.id === where.id) ||
+ (where.externalAuthId !== undefined &&
+ u.externalAuthId === where.externalAuthId),
+ );
+ if (!user) return null;
+ if (select?.organizationMemberships) {
+ return {
+ organizationMemberships: store.members
+ .filter((m) => m.userId === user.id)
+ .map((m) => ({ organizationId: m.organizationId })),
+ };
+ }
+ return user;
+ },
+ },
+ organizationMember: {
+ findUnique: async ({
+ where,
+ }: {
+ where: {
+ organizationId_userId: { organizationId: string; userId: string };
+ };
+ }) => {
+ const { organizationId, userId } = where.organizationId_userId;
+ return (
+ store.members.find(
+ (m) => m.organizationId === organizationId && m.userId === userId,
+ ) ?? null
+ );
+ },
+ findFirst: async ({
+ where,
+ }: {
+ where: {
+ organizationId?: string;
+ userId?: string;
+ status?: string | { not?: string };
+ };
+ }) =>
+ store.members
+ .slice()
+ .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
+ .find(
+ (row) =>
+ (where.organizationId === undefined ||
+ row.organizationId === where.organizationId) &&
+ (where.userId === undefined || row.userId === where.userId) &&
+ (where.status === undefined ||
+ (typeof where.status === "string"
+ ? row.status === where.status
+ : where.status.not === undefined ||
+ row.status !== where.status.not)),
+ ) ?? null,
+ findMany: async ({
+ where,
+ }: {
+ where: {
+ organizationId?: string;
+ userId?: { in: string[] };
+ status?: { not?: string };
+ };
+ }) =>
+ store.members
+ .filter(
+ (row) =>
+ (where.organizationId === undefined ||
+ row.organizationId === where.organizationId) &&
+ (where.userId === undefined ||
+ where.userId.in.includes(row.userId)) &&
+ (where.status?.not === undefined ||
+ row.status !== where.status.not),
+ )
+ .map((row) => ({ userId: row.userId })),
+ },
+ project: {
+ findFirst: async ({
+ where,
+ select,
+ }: {
+ where: ProjectWhere;
+ select?: Record;
+ }) => {
+ const row = store.projects
+ .slice()
+ .sort(
+ (a, b) =>
+ a.createdAt.getTime() - b.createdAt.getTime() ||
+ a.id.localeCompare(b.id),
+ )
+ .find((p) => matchesProject(p, where));
+ if (!row) return null;
+ if (!select) return { ...row };
+ const picked: Record = {};
+ for (const key of Object.keys(select)) {
+ if (select[key]) picked[key] = row[key as keyof ProjectRow];
+ }
+ return picked;
+ },
+ findUnique: async ({
+ where,
+ select,
+ }: {
+ where: { id: string };
+ select?: Record;
+ }) => {
+ const row = store.projects.find((p) => p.id === where.id);
+ if (!row) return null;
+ if (!select) return { ...row };
+ const picked: Record = {};
+ for (const key of Object.keys(select)) {
+ if (select[key]) picked[key] = row[key as keyof ProjectRow];
+ }
+ return picked;
+ },
+ count: async ({ where }: { where: ProjectWhere }) =>
+ store.projects.filter((p) => matchesProject(p, where)).length,
+ updateMany: async ({
+ where,
+ data,
+ }: {
+ where: ProjectWhere;
+ data: { name?: string };
+ }) => {
+ const rows = store.projects.filter((p) => matchesProject(p, where));
+ for (const row of rows) {
+ if (data.name !== undefined) row.name = data.name;
+ }
+ return { count: rows.length };
+ },
+ deleteMany: async ({ where }: { where: ProjectWhere }) => {
+ const rows = store.projects.filter((p) => matchesProject(p, where));
+ const ids = new Set(rows.map((r) => r.id));
+ store.projects = store.projects.filter((p) => !ids.has(p.id));
+ // The DB CASCADEs that ride the project row: project_access and
+ // policy_rules_v2.
+ store.projectAccess = store.projectAccess.filter(
+ (pa) => !ids.has(pa.projectId),
+ );
+ store.policyRulesV2 = store.policyRulesV2.filter(
+ (r) => !ids.has(r.projectId),
+ );
+ return { count: rows.length };
+ },
+ },
+ projectAccess: {
+ findFirst: async ({
+ where,
+ select,
+ }: {
+ where: AccessWhere;
+ select?: AccessSelect;
+ }) => {
+ const row = store.projectAccess.find((pa) => matchesAccess(pa, where));
+ return row ? pickAccess(row, select) : null;
+ },
+ findMany: async ({
+ where,
+ select,
+ take,
+ }: {
+ where: AccessWhere;
+ select?: AccessSelect;
+ take?: number;
+ }) => {
+ const rows = store.projectAccess
+ .filter((pa) => matchesAccess(pa, where))
+ .sort(
+ (a, b) =>
+ a.createdAt.getTime() - b.createdAt.getTime() ||
+ a.id.localeCompare(b.id),
+ );
+ const limited = take === undefined ? rows : rows.slice(0, take);
+ return limited.map((row) => pickAccess(row, select));
+ },
+ count: async ({ where }: { where: AccessWhere }) =>
+ store.projectAccess.filter((pa) => matchesAccess(pa, where)).length,
+ updateMany: async ({
+ where,
+ data,
+ }: {
+ where: AccessWhere;
+ data: { role: string };
+ }) => {
+ const rows = store.projectAccess.filter((pa) =>
+ matchesAccess(pa, where),
+ );
+ for (const row of rows) row.role = data.role;
+ return { count: rows.length };
+ },
+ deleteMany: async ({ where }: { where: AccessWhere }) => {
+ const rows = store.projectAccess.filter((pa) =>
+ matchesAccess(pa, where),
+ );
+ const ids = new Set(rows.map((r) => r.id));
+ store.projectAccess = store.projectAccess.filter(
+ (pa) => !ids.has(pa.id),
+ );
+ return { count: ids.size };
+ },
+ createMany: async ({
+ data,
+ }: {
+ data: {
+ projectId: string;
+ userId?: string;
+ groupId?: string;
+ role: string;
+ createdByUserId: string | null;
+ }[];
+ skipDuplicates?: boolean;
+ }) => {
+ let count = 0;
+ for (const row of data) {
+ const dupe = store.projectAccess.some(
+ (pa) =>
+ pa.projectId === row.projectId &&
+ ((row.userId !== undefined && pa.userId === row.userId) ||
+ (row.groupId !== undefined && pa.groupId === row.groupId)),
+ );
+ if (dupe) continue; // skipDuplicates
+ store.projectAccess.push({
+ id: `pa-${++store.seq}`,
+ projectId: row.projectId,
+ // Stored verbatim so the test can assert the exactly-one-of DB
+ // CHECK the mock itself cannot enforce.
+ userId: row.userId ?? null,
+ groupId: row.groupId ?? null,
+ role: row.role,
+ createdByUserId: row.createdByUserId,
+ createdAt: new Date(Date.UTC(2026, 1, 1, 0, store.seq)),
+ });
+ count++;
+ }
+ return { count };
+ },
+ },
+ group: {
+ findMany: async ({
+ where,
+ }: {
+ where: { organizationId: string; id: { in: string[] } };
+ }) =>
+ store.groups
+ .filter(
+ (g) =>
+ g.organizationId === where.organizationId &&
+ where.id.in.includes(g.id),
+ )
+ .map((g) => ({ id: g.id })),
+ },
+ apiKey: {
+ findUnique: async ({ where }: { where: { key?: string } }) => {
+ if (where.key === ADMIN_KEY)
+ return {
+ userId: ADMIN,
+ organizationId: ORG,
+ scope: "organization",
+ };
+ // A PROJECT-scoped key owned by the org's OWNER: it authenticates
+ // fine, which is exactly why the router needs its own scope guard.
+ if (where.key === PROJECT_KEY)
+ return { userId: OWNER, projectId: "proj-2" };
+ return null;
+ },
+ findFirst: async () => null,
+ findMany: async ({
+ where,
+ }: {
+ where: { projectId?: string; project?: { organizationId: string } };
+ }) =>
+ store.apiKeys
+ .filter(
+ (row) =>
+ where.projectId === undefined ||
+ row.projectId === where.projectId,
+ )
+ .map((row) => ({ key: row.key })),
+ ...childDelegate(
+ () => store.apiKeys,
+ (rows) => {
+ store.apiKeys = rows;
+ },
+ ),
+ },
+ agent: childDelegate(
+ () => store.agents,
+ (rows) => {
+ store.agents = rows;
+ },
+ ),
+ secret: childDelegate(
+ () => store.secrets,
+ (rows) => {
+ store.secrets = rows;
+ },
+ ),
+ appConnection: childDelegate(
+ () => store.appConnections,
+ (rows) => {
+ store.appConnections = rows;
+ },
+ ),
+ appConfig: childDelegate(
+ () => store.appConfigs,
+ (rows) => {
+ store.appConfigs = rows;
+ },
+ ),
+ policyRule: childDelegate(
+ () => store.policyRules,
+ (rows) => {
+ store.policyRules = rows;
+ },
+ ),
+ policyRuleV2: childDelegate(
+ () => store.policyRulesV2,
+ (rows) => {
+ store.policyRulesV2 = rows;
+ },
+ ),
+ vaultConnection: childDelegate(
+ () => store.vaultConnections,
+ (rows) => {
+ store.vaultConnections = rows;
+ },
+ ),
+ budget: childDelegate(
+ () => store.budgets,
+ (rows) => {
+ store.budgets = rows;
+ },
+ ),
+ onboardingSurvey: childDelegate(
+ () => store.onboardingSurveys,
+ (rows) => {
+ store.onboardingSurveys = rows;
+ },
+ ),
+ auditLog: {
+ create: async ({ data }: { data: AuditRow }) => {
+ store.audits.push(data);
+ return data;
+ },
+ },
+ };
+
+ return {
+ Prisma: { JsonNull: null },
+ db: {
+ ...delegates,
+ // Both forms: the access replace-set uses the array form, the delete
+ // cascade the interactive (callback) form.
+ $transaction: async (arg: unknown) => {
+ store.txCount++;
+ if (typeof arg === "function") {
+ return (arg as (tx: typeof delegates) => Promise)(delegates);
+ }
+ return Promise.all(arg as Promise[]);
+ },
+ },
+ };
+});
+
+import { createApiApp } from "../../app";
+import { registerOssOrgRoutes } from "./index";
+import { ossRoleResolver } from "../../services/org-role-resolver";
+
+const sessionProvider = {
+ getSession: async () => {
+ const user = store.users.find((u) => u.id === store.sessionUserId);
+ return user ? { id: user.externalAuthId, email: user.email } : null;
+ },
+};
+
+const app: Hono = createApiApp(sessionProvider, {
+ eeRoutes: registerOssOrgRoutes,
+ roleResolver: ossRoleResolver,
+});
+
+const at = (minutes: number) => new Date(Date.UTC(2026, 0, 1, 0, minutes));
+
+const member = (
+ userId: string,
+ role: string,
+ createdAt: Date,
+ organizationId = ORG,
+): MemberRow => ({
+ organizationId,
+ userId,
+ role,
+ status: "active",
+ createdAt,
+});
+
+const access = (
+ id: string,
+ projectId: string,
+ principal: { userId?: string; groupId?: string },
+ role: string,
+ createdAt: Date,
+): AccessRow => ({
+ id,
+ projectId,
+ userId: principal.userId ?? null,
+ groupId: principal.groupId ?? null,
+ role,
+ createdByUserId: null,
+ createdAt,
+});
+
+beforeEach(() => {
+ store.users = [
+ {
+ id: OWNER,
+ externalAuthId: "ext-owner",
+ email: "owner@example.com",
+ name: "Olive Owner",
+ },
+ {
+ id: ADMIN,
+ externalAuthId: "ext-admin",
+ email: "admin@example.com",
+ name: "Adam Admin",
+ },
+ {
+ id: MEMBER,
+ externalAuthId: "ext-member",
+ email: "member@example.com",
+ name: null,
+ },
+ {
+ id: MEMBER2,
+ externalAuthId: "ext-member2",
+ email: "member2@example.com",
+ name: "Mia Member",
+ },
+ {
+ id: OUTSIDER,
+ externalAuthId: "ext-outsider",
+ email: "outsider@other.test",
+ name: "Odette Outsider",
+ },
+ {
+ id: STRANGER,
+ externalAuthId: "ext-stranger",
+ email: "stranger@nowhere.test",
+ name: "Sam Stranger",
+ },
+ ];
+ store.members = [
+ member(OWNER, "owner", at(0)),
+ member(ADMIN, "admin", at(1)),
+ member(MEMBER, "member", at(2)),
+ member(MEMBER2, "member", at(3)),
+ member(OUTSIDER, "admin", at(4), OTHER_ORG),
+ ];
+ store.projects = [
+ {
+ id: "proj-1",
+ organizationId: ORG,
+ name: "Alpha",
+ slug: "alpha",
+ createdByUserId: MEMBER,
+ createdAt: at(0),
+ },
+ {
+ id: "proj-2",
+ organizationId: ORG,
+ name: "Beta",
+ slug: "beta",
+ createdByUserId: OWNER,
+ createdAt: at(1),
+ },
+ {
+ id: "proj-3",
+ organizationId: ORG,
+ name: "Gamma",
+ slug: "gamma",
+ createdByUserId: ADMIN,
+ createdAt: at(2),
+ },
+ // No bindings at all — the legacy zero-binding shape (L5).
+ {
+ id: "proj-4",
+ organizationId: ORG,
+ name: "Delta",
+ slug: "delta",
+ createdByUserId: OWNER,
+ createdAt: at(3),
+ },
+ {
+ id: "proj-x",
+ organizationId: OTHER_ORG,
+ name: "Foreign",
+ slug: "foreign",
+ createdByUserId: OUTSIDER,
+ createdAt: at(4),
+ },
+ ];
+ store.groups = [
+ { id: "g-a", organizationId: ORG, name: "Engineering", source: "manual" },
+ { id: "g-scim", organizationId: ORG, name: "Provisioned", source: "scim" },
+ { id: "g-x", organizationId: OTHER_ORG, name: "Foreign", source: "manual" },
+ ];
+ store.groupMembers = [
+ { groupId: "g-a", userId: MEMBER2 },
+ { groupId: "g-x", userId: OUTSIDER },
+ ];
+ store.projectAccess = [
+ access("pa-1", "proj-1", { userId: MEMBER }, "owner", at(10)),
+ // A GROUP row carrying role "owner" on purpose: group bindings must never
+ // confer management, whatever the column says.
+ access("pa-2", "proj-1", { groupId: "g-a" }, "owner", at(11)),
+ access("pa-3", "proj-1", { userId: ADMIN }, "member", at(12)),
+ access("pa-4", "proj-2", { userId: OWNER }, "owner", at(13)),
+ access("pa-5", "proj-2", { userId: MEMBER2 }, "member", at(14)),
+ access("pa-6", "proj-3", { userId: ADMIN }, "owner", at(15)),
+ // Inert rows, filtered out of GET /access (Decision J + the org fence).
+ access("pa-7", "proj-3", { userId: STRANGER }, "member", at(16)),
+ access("pa-8", "proj-3", { groupId: "g-x" }, "member", at(17)),
+ access("pa-9", "proj-x", { userId: OUTSIDER }, "owner", at(18)),
+ ];
+ store.agents = [
+ { id: "ag-1", projectId: "proj-1" },
+ { id: "ag-2", projectId: "proj-1" },
+ { id: "ag-3", projectId: "proj-2" },
+ ];
+ store.apiKeys = [
+ { id: "k-1", projectId: "proj-1", key: "oc_key-1" },
+ { id: "k-2", projectId: "proj-1", key: "oc_key-2" },
+ { id: "k-3", projectId: "proj-2", key: "oc_key-3" },
+ ];
+ store.secrets = [
+ { id: "s-1", projectId: "proj-1" },
+ { id: "s-2", projectId: "proj-2" },
+ ];
+ store.appConnections = [{ id: "ac-1", projectId: "proj-1" }];
+ store.appConfigs = [{ id: "cfg-1", projectId: "proj-1" }];
+ store.policyRules = [{ id: "pr-1", projectId: "proj-1" }];
+ store.policyRulesV2 = [{ id: "pv-1", projectId: "proj-1" }];
+ store.vaultConnections = [{ id: "vc-1", projectId: "proj-1" }];
+ store.budgets = [{ id: "b-1", projectId: "proj-1" }];
+ store.onboardingSurveys = [{ id: "os-1", projectId: "proj-1" }];
+ store.audits = [];
+ store.seq = 100;
+ store.txCount = 0;
+ store.sessionUserId = null;
+ flushes.keys = [];
+ flushes.orgs = [];
+ flushes.accounts = [];
+});
+
+const asAdmin = { headers: { Authorization: `Bearer ${ADMIN_KEY}` } };
+const asProjectKey = { headers: { Authorization: `Bearer ${PROJECT_KEY}` } };
+
+const projectRow = (id: string) => store.projects.find((p) => p.id === id);
+const bindings = (projectId: string) =>
+ store.projectAccess.filter((pa) => pa.projectId === projectId);
+const userBinding = (projectId: string, userId: string) =>
+ bindings(projectId).find((pa) => pa.userId === userId);
+
+interface ProjectBody {
+ id: string;
+ name: string | null;
+ slug: string | null;
+ createdAt: string;
+}
+
+interface AccessBody {
+ users: {
+ id: string;
+ userId: string;
+ name: string | null;
+ email: string;
+ role: string;
+ isOwner: boolean;
+ createdAt: string;
+ }[];
+ groups: {
+ id: string;
+ groupId: string;
+ name: string;
+ memberCount: number;
+ createdAt: string;
+ }[];
+}
+
+const get = (id: string, init: RequestInit = asAdmin) =>
+ app.request(`/v1/projects/${id}`, init);
+
+const patch = (id: string, body: unknown, init: RequestInit = asAdmin) =>
+ app.request(`/v1/projects/${id}`, {
+ ...init,
+ method: "PATCH",
+ body: JSON.stringify(body),
+ });
+
+const remove = (id: string, init: RequestInit = asAdmin) =>
+ app.request(`/v1/projects/${id}`, { ...init, method: "DELETE" });
+
+const getAccess = (id: string, init: RequestInit = asAdmin) =>
+ app.request(`/v1/projects/${id}/access`, init);
+
+const putAccess = (id: string, body: unknown, init: RequestInit = asAdmin) =>
+ app.request(`/v1/projects/${id}/access`, {
+ ...init,
+ method: "PUT",
+ body: JSON.stringify(body),
+ });
+
+describe("guard stack", () => {
+ it("401s an unauthenticated caller on every route", async () => {
+ expect((await get("proj-1", {})).status).toBe(401);
+ expect((await patch("proj-1", { name: "X" }, {})).status).toBe(401);
+ expect((await remove("proj-1", {})).status).toBe(401);
+ expect((await getAccess("proj-1", {})).status).toBe(401);
+ expect(
+ (await putAccess("proj-1", { users: [], groupIds: [] }, {})).status,
+ ).toBe(401);
+ expect(store.audits).toHaveLength(0);
+ });
+
+ it("403s a project-scoped key on every route, even when its user is an org owner", async () => {
+ expect((await get("proj-2", asProjectKey)).status).toBe(403);
+ expect((await patch("proj-2", { name: "X" }, asProjectKey)).status).toBe(
+ 403,
+ );
+ expect((await remove("proj-2", asProjectKey)).status).toBe(403);
+ expect((await getAccess("proj-2", asProjectKey)).status).toBe(403);
+ expect(
+ (
+ await putAccess(
+ "proj-2",
+ { users: [{ userId: OWNER, role: "owner" }], groupIds: [] },
+ asProjectKey,
+ )
+ ).status,
+ ).toBe(403);
+ expect(projectRow("proj-2")?.name).toBe("Beta");
+ expect(store.audits).toHaveLength(0);
+ });
+
+ it("200s a NON-ADMIN member holding an owner binding (the point of this stack)", async () => {
+ store.sessionUserId = MEMBER;
+ const res = await patch("proj-1", { name: "Renamed" }, {});
+ expect(res.status).toBe(200);
+ expect(projectRow("proj-1")?.name).toBe("Renamed");
+ });
+
+ it("403s an active member whose binding is a plain use grant", async () => {
+ store.sessionUserId = MEMBER2; // `member` binding on proj-2
+ const res = await patch("proj-2", { name: "Nope" }, {});
+ expect(res.status).toBe(403);
+ expect(projectRow("proj-2")?.name).toBe("Beta");
+ expect(store.audits).toHaveLength(0);
+ });
+
+ it("403s a member whose only binding is a GROUP row, even at role owner", async () => {
+ store.sessionUserId = MEMBER2; // in g-a, which is bound to proj-1 as "owner"
+ const res = await patch("proj-1", { name: "Nope" }, {});
+ expect(res.status).toBe(403);
+ expect(projectRow("proj-1")?.name).toBe("Alpha");
+ });
+
+ it("rejects a suspended admin's org key (suspended reads as no role)", async () => {
+ const row = store.members.find((m) => m.userId === ADMIN);
+ if (row) row.status = "suspended";
+ // The key path fails first, so this is a 401 rather than the 403 a
+ // suspended session would get — either way the stale binding on proj-3
+ // never rescues them.
+ expect((await patch("proj-3", { name: "X" })).status).toBe(401);
+ expect(projectRow("proj-3")?.name).toBe("Gamma");
+ });
+
+ it("rejects a suspended owner-binding holder's session", async () => {
+ const row = store.members.find((m) => m.userId === MEMBER);
+ if (row) row.status = "suspended";
+ store.sessionUserId = MEMBER;
+ expect((await patch("proj-1", { name: "X" }, {})).status).toBe(401);
+ expect(projectRow("proj-1")?.name).toBe("Alpha");
+ });
+});
+
+describe("GET /v1/projects/:projectId", () => {
+ it("returns the project row with createdAt as an ISO string", async () => {
+ const res = await get("proj-1");
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({
+ id: "proj-1",
+ name: "Alpha",
+ slug: "alpha",
+ createdAt: at(0).toISOString(),
+ });
+ });
+
+ it("404s a project of another organization and an unknown id", async () => {
+ expect((await get("proj-x")).status).toBe(404);
+ expect((await get("proj-nope")).status).toBe(404);
+ });
+
+ it("200s a plain member holding a use-only binding", async () => {
+ store.sessionUserId = MEMBER2; // group binding on proj-1
+ const res = await get("proj-1", {});
+ expect(res.status).toBe(200);
+ expect(((await res.json()) as ProjectBody).id).toBe("proj-1");
+ });
+
+ it("200s an org admin with no binding at all", async () => {
+ const res = await get("proj-4"); // zero bindings
+ expect(res.status).toBe(200);
+ });
+
+ it("403s an active member with no binding on the project", async () => {
+ store.sessionUserId = MEMBER2;
+ expect((await get("proj-3", {})).status).toBe(403);
+ });
+
+ it("never audits a read", async () => {
+ await get("proj-1");
+ expect(store.audits).toHaveLength(0);
+ });
+});
+
+describe("PATCH /v1/projects/:projectId", () => {
+ it("renames, returns the row, and audits with organizationId AND projectId", async () => {
+ const res = await patch("proj-1", { name: "Alpha Prime" });
+ expect(res.status).toBe(200);
+ expect(await res.json()).toMatchObject({
+ id: "proj-1",
+ name: "Alpha Prime",
+ slug: "alpha",
+ });
+ expect(projectRow("proj-1")?.name).toBe("Alpha Prime");
+ expect(store.audits).toHaveLength(1);
+ expect(store.audits[0]).toMatchObject({
+ organizationId: ORG,
+ projectId: "proj-1",
+ userId: ADMIN,
+ action: "update",
+ service: "project",
+ source: "api",
+ metadata: { projectId: "proj-1", change: "name", name: "Alpha Prime" },
+ });
+ });
+
+ it("trims the name before storing", async () => {
+ const res = await patch("proj-1", { name: " Padded " });
+ expect(res.status).toBe(200);
+ expect(projectRow("proj-1")?.name).toBe("Padded");
+ });
+
+ it("422s an empty / whitespace-only / overlong / missing name", async () => {
+ for (const body of [
+ { name: "" },
+ { name: " " },
+ { name: "x".repeat(101) },
+ {},
+ ]) {
+ expect((await patch("proj-1", body)).status).toBe(422);
+ }
+ expect(projectRow("proj-1")?.name).toBe("Alpha");
+ expect(store.audits).toHaveLength(0);
+ });
+
+ it("422s an unparseable body rather than 500ing", async () => {
+ const res = await app.request("/v1/projects/proj-1", {
+ ...asAdmin,
+ method: "PATCH",
+ });
+ expect(res.status).toBe(422);
+ });
+
+ it("permits a rename-to-self as a 200 no-op", async () => {
+ const res = await patch("proj-1", { name: "Alpha" });
+ expect(res.status).toBe(200);
+ expect(projectRow("proj-1")?.name).toBe("Alpha");
+ });
+
+ it("lets two projects in the same org share a name (the 'Default' reality)", async () => {
+ const res = await patch("proj-2", { name: "Alpha" });
+ expect(res.status).toBe(200);
+ expect(projectRow("proj-1")?.name).toBe("Alpha");
+ expect(projectRow("proj-2")?.name).toBe("Alpha");
+ });
+
+ it("never writes slug", async () => {
+ await patch("proj-1", { name: "Alpha Prime", slug: "hijacked" });
+ expect(projectRow("proj-1")?.slug).toBe("alpha");
+ });
+
+ it("404s a project of another organization and audits nothing", async () => {
+ const res = await patch("proj-x", { name: "Captured" });
+ expect(res.status).toBe(404);
+ expect(projectRow("proj-x")?.name).toBe("Foreign");
+ expect(store.audits).toHaveLength(0);
+ });
+
+ it("403s a non-manager and writes/audits nothing", async () => {
+ store.sessionUserId = MEMBER2;
+ const res = await patch("proj-2", { name: "Nope" }, {});
+ expect(res.status).toBe(403);
+ expect(projectRow("proj-2")?.name).toBe("Beta");
+ expect(store.audits).toHaveLength(0);
+ });
+});
+
+describe("GET /v1/projects/:projectId/access", () => {
+ it("returns users and groups in the client's exact shape, createdAt asc", async () => {
+ const res = await getAccess("proj-1");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as AccessBody;
+ expect(body.users).toEqual([
+ {
+ id: "pa-1",
+ userId: MEMBER,
+ name: null,
+ email: "member@example.com",
+ role: "owner",
+ isOwner: true,
+ createdAt: at(10).toISOString(),
+ },
+ {
+ id: "pa-3",
+ userId: ADMIN,
+ name: "Adam Admin",
+ email: "admin@example.com",
+ role: "member",
+ isOwner: false,
+ createdAt: at(12).toISOString(),
+ },
+ ]);
+ expect(body.groups).toEqual([
+ {
+ id: "pa-2",
+ groupId: "g-a",
+ name: "Engineering",
+ memberCount: 1,
+ createdAt: at(11).toISOString(),
+ },
+ ]);
+ });
+
+ it("normalizes a garbage role string to member instead of casting it", async () => {
+ const row = store.projectAccess.find((pa) => pa.id === "pa-3");
+ if (row) row.role = "superuser";
+ const body = (await (await getAccess("proj-1")).json()) as AccessBody;
+ expect(body.users.find((u) => u.userId === ADMIN)?.role).toBe("member");
+ });
+
+ it("keeps isOwner as creator provenance, independent of the management role", async () => {
+ // Creator demoted, a non-creator promoted: the badge follows creation.
+ const creatorRow = store.projectAccess.find((pa) => pa.id === "pa-1");
+ if (creatorRow) creatorRow.role = "member";
+ const otherRow = store.projectAccess.find((pa) => pa.id === "pa-3");
+ if (otherRow) otherRow.role = "owner";
+
+ const body = (await (await getAccess("proj-1")).json()) as AccessBody;
+ expect(body.users.find((u) => u.userId === MEMBER)).toMatchObject({
+ role: "member",
+ isOwner: true,
+ });
+ expect(body.users.find((u) => u.userId === ADMIN)).toMatchObject({
+ role: "owner",
+ isOwner: false,
+ });
+ });
+
+ it("excludes a user who is not a member of the org, and a group of another org", async () => {
+ const body = (await (await getAccess("proj-3")).json()) as AccessBody;
+ expect(body.users.map((u) => u.userId)).toEqual([ADMIN]);
+ expect(body.groups).toEqual([]);
+ });
+
+ it("includes a SUSPENDED member's row (suspension is an auth-time gate)", async () => {
+ const row = store.members.find((m) => m.userId === ADMIN);
+ if (row) row.status = "suspended";
+ store.sessionUserId = MEMBER; // the suspended admin can no longer call in
+ const body = (await (await getAccess("proj-1", {})).json()) as AccessBody;
+ expect(body.users.map((u) => u.userId)).toEqual([MEMBER, ADMIN]);
+ });
+
+ it("returns empty arrays for a project with no bindings, not a 404", async () => {
+ const res = await getAccess("proj-4");
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({ users: [], groups: [] });
+ });
+
+ it("404s a project of another organization", async () => {
+ expect((await getAccess("proj-x")).status).toBe(404);
+ });
+});
+
+describe("PUT /v1/projects/:projectId/access (replace-set)", () => {
+ it("applies the exact set and returns the aggregated delta", async () => {
+ // proj-1 currently: users {MEMBER owner, ADMIN member}, groups {g-a}.
+ const res = await putAccess("proj-1", {
+ users: [
+ { userId: MEMBER, role: "owner" },
+ { userId: MEMBER2, role: "member" },
+ ],
+ groupIds: ["g-scim"],
+ });
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({ added: 2, removed: 2, roleChanged: 0 });
+
+ expect(
+ bindings("proj-1")
+ .map((pa) => pa.userId ?? `group:${pa.groupId}`)
+ .sort(),
+ ).toEqual([MEMBER, MEMBER2, "group:g-scim"].sort());
+ expect(store.txCount).toBe(1);
+ });
+
+ it("changes a role in place without recreating the row", async () => {
+ const before = userBinding("proj-1", MEMBER)?.id;
+ const res = await putAccess("proj-1", {
+ users: [
+ { userId: MEMBER, role: "member" },
+ { userId: ADMIN, role: "owner" },
+ ],
+ groupIds: ["g-a"],
+ });
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({ added: 0, removed: 0, roleChanged: 2 });
+ // The row id (and therefore its createdAt provenance) survives.
+ expect(userBinding("proj-1", MEMBER)?.id).toBe(before);
+ expect(userBinding("proj-1", MEMBER)?.role).toBe("member");
+ expect(userBinding("proj-1", ADMIN)?.role).toBe("owner");
+ });
+
+ it("returns {0,0,0} for a no-op set WITHOUT opening a transaction", async () => {
+ const res = await putAccess("proj-1", {
+ users: [
+ { userId: MEMBER, role: "owner" },
+ { userId: ADMIN, role: "member" },
+ ],
+ groupIds: ["g-a"],
+ });
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({ added: 0, removed: 0, roleChanged: 0 });
+ expect(store.txCount).toBe(0);
+ expect(bindings("proj-1")).toHaveLength(3);
+ });
+
+ it("deduplicates repeated groupIds silently", async () => {
+ const res = await putAccess("proj-1", {
+ users: [
+ { userId: MEMBER, role: "owner" },
+ { userId: ADMIN, role: "member" },
+ ],
+ groupIds: ["g-a", "g-a"],
+ });
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({ added: 0, removed: 0, roleChanged: 0 });
+ });
+
+ it("422s a duplicate userId (ambiguous role), not 'last wins'", async () => {
+ const res = await putAccess("proj-1", {
+ users: [
+ { userId: MEMBER, role: "owner" },
+ { userId: MEMBER, role: "member" },
+ ],
+ groupIds: [],
+ });
+ expect(res.status).toBe(422);
+ expect(userBinding("proj-1", MEMBER)?.role).toBe("owner");
+ });
+
+ it("422s a body missing either key — never a half-wipe", async () => {
+ expect(
+ (
+ await putAccess("proj-1", {
+ users: [{ userId: MEMBER, role: "owner" }],
+ })
+ ).status,
+ ).toBe(422);
+ expect((await putAccess("proj-1", { groupIds: [] })).status).toBe(422);
+ expect(bindings("proj-1")).toHaveLength(3);
+ expect(store.audits).toHaveLength(0);
+ });
+
+ it("422s arrays beyond the caps", async () => {
+ const users = Array.from({ length: 1001 }, (_, i) => ({
+ userId: `u-${i}`,
+ role: "member" as const,
+ }));
+ expect((await putAccess("proj-1", { users, groupIds: [] })).status).toBe(
+ 422,
+ );
+ const groupIds = Array.from({ length: 201 }, (_, i) => `g-${i}`);
+ expect(
+ (
+ await putAccess("proj-1", {
+ users: [{ userId: MEMBER, role: "owner" }],
+ groupIds,
+ })
+ ).status,
+ ).toBe(422);
+ });
+
+ it("400s when ANY userId is not a member of this org — the security core", async () => {
+ const res = await putAccess("proj-1", {
+ users: [
+ { userId: MEMBER, role: "owner" },
+ { userId: OUTSIDER, role: "member" },
+ ],
+ groupIds: ["g-a"],
+ });
+ expect(res.status).toBe(400);
+ expect(bindings("proj-1")).toHaveLength(3);
+ expect(store.txCount).toBe(0);
+ expect(store.audits).toHaveLength(0);
+ });
+
+ it("400s when a groupId belongs to another organization", async () => {
+ const res = await putAccess("proj-1", {
+ users: [{ userId: MEMBER, role: "owner" }],
+ groupIds: ["g-x"],
+ });
+ expect(res.status).toBe(400);
+ expect(bindings("proj-1")).toHaveLength(3);
+ expect(store.audits).toHaveLength(0);
+ });
+
+ it("accepts a scim group as a grantee (a project grant is OneCLI-owned)", async () => {
+ const res = await putAccess("proj-1", {
+ users: [
+ { userId: MEMBER, role: "owner" },
+ { userId: ADMIN, role: "member" },
+ ],
+ groupIds: ["g-a", "g-scim"],
+ });
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({ added: 1, removed: 0, roleChanged: 0 });
+ });
+
+ it("allows granting a suspended member (auth-time gate)", async () => {
+ const row = store.members.find((m) => m.userId === MEMBER2);
+ if (row) row.status = "suspended";
+ const res = await putAccess("proj-1", {
+ users: [
+ { userId: MEMBER, role: "owner" },
+ { userId: ADMIN, role: "member" },
+ { userId: MEMBER2, role: "member" },
+ ],
+ groupIds: ["g-a"],
+ });
+ expect(res.status).toBe(200);
+ expect(userBinding("proj-1", MEMBER2)).toBeTruthy();
+ });
+
+ it("400s when the resulting set has no owner (demoted, or cleared)", async () => {
+ const demoted = await putAccess("proj-1", {
+ users: [
+ { userId: MEMBER, role: "member" },
+ { userId: ADMIN, role: "member" },
+ ],
+ groupIds: ["g-a"],
+ });
+ expect(demoted.status).toBe(400);
+
+ const cleared = await putAccess("proj-1", { users: [], groupIds: [] });
+ expect(cleared.status).toBe(400);
+
+ expect(bindings("proj-1")).toHaveLength(3);
+ expect(userBinding("proj-1", MEMBER)?.role).toBe("owner");
+ expect(store.audits).toHaveLength(0);
+ });
+
+ it("400s a NON-ADMIN actor removing or demoting their own binding", async () => {
+ store.sessionUserId = MEMBER;
+ const removed = await putAccess(
+ "proj-1",
+ { users: [{ userId: ADMIN, role: "owner" }], groupIds: ["g-a"] },
+ {},
+ );
+ expect(removed.status).toBe(400);
+
+ const demoted = await putAccess(
+ "proj-1",
+ {
+ users: [
+ { userId: MEMBER, role: "member" },
+ { userId: ADMIN, role: "owner" },
+ ],
+ groupIds: ["g-a"],
+ },
+ {},
+ );
+ expect(demoted.status).toBe(400);
+
+ expect(userBinding("proj-1", MEMBER)?.role).toBe("owner");
+ expect(store.audits).toHaveLength(0);
+ });
+
+ it("lets an ORG ADMIN remove their own binding (the hand-off exemption)", async () => {
+ const res = await putAccess("proj-1", {
+ users: [{ userId: MEMBER, role: "owner" }],
+ groupIds: ["g-a"],
+ });
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({ added: 0, removed: 1, roleChanged: 0 });
+ expect(userBinding("proj-1", ADMIN)).toBeUndefined();
+ });
+
+ it("400s an ORG ADMIN whose own removal would leave them no project", async () => {
+ // Strip every path ADMIN has outside proj-1: the binding under the knife
+ // becomes their ONLY route to any project, so removing it would 401 them
+ // out of the dashboard — including out of the endpoint that re-grants.
+ const gamma = store.projects.find((p) => p.id === "proj-3");
+ if (gamma) gamma.createdByUserId = OWNER;
+ store.projectAccess = store.projectAccess.filter((pa) => pa.id !== "pa-6");
+
+ const res = await putAccess("proj-1", {
+ users: [{ userId: MEMBER, role: "owner" }],
+ groupIds: ["g-a"],
+ });
+ expect(res.status).toBe(400);
+ expect(JSON.stringify(await res.json())).toContain(
+ "leave you with no project",
+ );
+ expect(userBinding("proj-1", ADMIN)).toBeTruthy();
+ expect(store.txCount).toBe(0);
+ expect(store.audits).toHaveLength(0);
+ });
+
+ it("lets an ORG ADMIN drop their own binding on a project they CREATED", async () => {
+ // proj-3 is ADMIN's own project, so the created-by arm survives the write
+ // even with no binding left anywhere (their proj-1 row is removed here).
+ store.projectAccess = store.projectAccess.filter((pa) => pa.id !== "pa-3");
+
+ const res = await putAccess("proj-3", {
+ users: [{ userId: MEMBER, role: "owner" }],
+ groupIds: [],
+ });
+ expect(res.status).toBe(200);
+ expect(userBinding("proj-3", ADMIN)).toBeUndefined();
+ });
+
+ it("writes exactly one of userId/groupId per created row (the DB CHECK)", async () => {
+ const res = await putAccess("proj-1", {
+ users: [
+ { userId: MEMBER, role: "owner" },
+ { userId: MEMBER2, role: "member" },
+ ],
+ groupIds: ["g-a", "g-scim"],
+ });
+ expect(res.status).toBe(200);
+ for (const row of store.projectAccess) {
+ const principals = [row.userId, row.groupId].filter((v) => v !== null);
+ expect(principals).toHaveLength(1);
+ }
+ });
+
+ it("always stores group rows with role member", async () => {
+ await putAccess("proj-1", {
+ users: [{ userId: MEMBER, role: "owner" }],
+ groupIds: ["g-scim"],
+ });
+ const groupRows = bindings("proj-1").filter((pa) => pa.groupId !== null);
+ expect(groupRows.map((pa) => pa.groupId)).toEqual(["g-scim"]);
+ expect(groupRows.every((pa) => pa.role === "member")).toBe(true);
+ });
+
+ it("audits counts only — never id arrays", async () => {
+ const res = await putAccess("proj-1", {
+ users: [
+ { userId: MEMBER, role: "owner" },
+ { userId: MEMBER2, role: "member" },
+ ],
+ groupIds: [],
+ });
+ expect(res.status).toBe(200);
+ expect(store.audits).toHaveLength(1);
+ expect(store.audits[0]).toMatchObject({
+ organizationId: ORG,
+ projectId: "proj-1",
+ action: "update",
+ service: "project",
+ source: "api",
+ metadata: {
+ projectId: "proj-1",
+ change: "access",
+ added: 1,
+ removed: 2,
+ roleChanged: 0,
+ },
+ });
+ for (const value of Object.values(store.audits[0]?.metadata ?? {})) {
+ expect(Array.isArray(value)).toBe(false);
+ }
+ });
+
+ it("404s a cross-org project BEFORE validating the payload (no existence oracle)", async () => {
+ const res = await putAccess("proj-x", {
+ users: [{ userId: OUTSIDER, role: "owner" }],
+ groupIds: ["g-x"],
+ });
+ expect(res.status).toBe(404);
+ expect(bindings("proj-x")).toHaveLength(1);
+ });
+
+ it("403s a non-manager and writes nothing", async () => {
+ store.sessionUserId = MEMBER2;
+ const res = await putAccess(
+ "proj-2",
+ { users: [{ userId: MEMBER2, role: "owner" }], groupIds: [] },
+ {},
+ );
+ expect(res.status).toBe(403);
+ expect(userBinding("proj-2", MEMBER2)?.role).toBe("member");
+ expect(store.audits).toHaveLength(0);
+ });
+});
+
+describe("DELETE /v1/projects/:projectId", () => {
+ it("deletes the project and every child table, in ONE transaction", async () => {
+ // proj-4 has no bindings and its creator (OWNER) still has proj-2.
+ // Give it children so the pinned cascade has something to remove — with a
+ // DISTINCT count per table, so a mis-ordered `Promise.all` destructure in
+ // the audit metadata cannot pass unnoticed.
+ const seed = (n: number, push: (id: string) => void) => {
+ for (let i = 0; i < n; i++) push(`x-${i}`);
+ };
+ seed(1, (id) => store.agents.push({ id: `ag-${id}`, projectId: "proj-4" }));
+ seed(2, (id) =>
+ store.apiKeys.push({
+ id: `k-${id}`,
+ projectId: "proj-4",
+ key: `oc_key-4-${id}`,
+ }),
+ );
+ seed(3, (id) => store.secrets.push({ id: `s-${id}`, projectId: "proj-4" }));
+ seed(4, (id) =>
+ store.policyRules.push({ id: `pr-${id}`, projectId: "proj-4" }),
+ );
+ seed(5, (id) =>
+ store.policyRulesV2.push({ id: `pv-${id}`, projectId: "proj-4" }),
+ );
+ seed(6, (id) =>
+ store.appConnections.push({ id: `ac-${id}`, projectId: "proj-4" }),
+ );
+ seed(7, (id) =>
+ store.appConfigs.push({ id: `cfg-${id}`, projectId: "proj-4" }),
+ );
+ seed(8, (id) =>
+ store.vaultConnections.push({ id: `vc-${id}`, projectId: "proj-4" }),
+ );
+ seed(9, (id) => store.budgets.push({ id: `b-${id}`, projectId: "proj-4" }));
+ seed(10, (id) =>
+ store.onboardingSurveys.push({ id: `os-${id}`, projectId: "proj-4" }),
+ );
+
+ const res = await remove("proj-4");
+ expect(res.status).toBe(200);
+ expect(await res.json()).toMatchObject({
+ id: "proj-4",
+ name: "Delta",
+ removed: {
+ agents: 1,
+ apiKeys: 2,
+ secrets: 3,
+ policyRules: 4,
+ policyRulesV2: 5,
+ appConnections: 6,
+ appConfigs: 7,
+ vaultConnections: 8,
+ budgets: 9,
+ onboardingSurvey: 10,
+ accessBindings: 0,
+ },
+ });
+
+ expect(projectRow("proj-4")).toBeUndefined();
+ for (const rows of [
+ store.agents,
+ store.apiKeys,
+ store.secrets,
+ store.appConnections,
+ store.appConfigs,
+ store.policyRules,
+ store.policyRulesV2,
+ store.vaultConnections,
+ store.budgets,
+ store.onboardingSurveys,
+ ]) {
+ expect(rows.some((r) => r.projectId === "proj-4")).toBe(false);
+ }
+ // Other projects' rows are untouched.
+ expect(store.agents.filter((a) => a.projectId === "proj-1")).toHaveLength(
+ 2,
+ );
+ expect(bindings("proj-1")).toHaveLength(3);
+ expect(store.txCount).toBe(1);
+ });
+
+ it("hands the keys captured BEFORE the delete to the gateway flush", async () => {
+ store.apiKeys.push({ id: "k-4", projectId: "proj-4", key: "oc_key-4" });
+ const res = await remove("proj-4");
+ expect(res.status).toBe(200);
+ expect(flushes.keys).toContainEqual(["oc_key-4"]);
+ });
+
+ it("audits with organizationId and NO projectId (the FK would drop the row)", async () => {
+ const res = await remove("proj-4");
+ expect(res.status).toBe(200);
+ expect(store.audits).toHaveLength(1);
+ expect(store.audits[0]).toMatchObject({
+ organizationId: ORG,
+ userId: ADMIN,
+ action: "delete",
+ service: "project",
+ source: "api",
+ metadata: { projectId: "proj-4", name: "Delta" },
+ });
+ expect(store.audits[0]?.projectId).toBeUndefined();
+ });
+
+ it("409s the organization's last project and deletes nothing", async () => {
+ store.projects = store.projects.filter(
+ (p) => p.id === "proj-1" || p.organizationId === OTHER_ORG,
+ );
+ const res = await remove("proj-1");
+ expect(res.status).toBe(409);
+ expect(await res.text()).toContain("at least one project");
+ expect(projectRow("proj-1")).toBeTruthy();
+ expect(store.audits).toHaveLength(0);
+ });
+
+ it("409s when a directly-bound member would be left with no project", async () => {
+ // proj-1 is MEMBER's only project (created + bound).
+ const res = await remove("proj-1");
+ expect(res.status).toBe(409);
+ expect(await res.text()).toContain("1 member(s) with no project");
+ expect(projectRow("proj-1")).toBeTruthy();
+ expect(store.agents.some((a) => a.projectId === "proj-1")).toBe(true);
+ expect(store.audits).toHaveLength(0);
+ });
+
+ it("409s when the only path is a GROUP binding", async () => {
+ // Give MEMBER another project so only the group-bound MEMBER2 is stranded.
+ store.projectAccess.push(
+ access("pa-20", "proj-3", { userId: MEMBER }, "member", at(20)),
+ );
+ // ...and take away MEMBER2's direct binding elsewhere.
+ store.projectAccess = store.projectAccess.filter((pa) => pa.id !== "pa-5");
+
+ const res = await remove("proj-1");
+ expect(res.status).toBe(409);
+ expect(await res.text()).toContain("1 member(s) with no project");
+ expect(projectRow("proj-1")).toBeTruthy();
+ });
+
+ it("409s with the sharper message when the ACTOR would be stranded", async () => {
+ store.sessionUserId = MEMBER; // owner binding on proj-1, their only project
+ const res = await remove("proj-1", {});
+ expect(res.status).toBe(409);
+ expect(await res.text()).toContain("leave you with no project");
+ expect(projectRow("proj-1")).toBeTruthy();
+ });
+
+ it("200s when the bound users resolve another project through a BINDING", async () => {
+ // proj-2's candidates: OWNER (also created proj-4) and MEMBER2 (bound to
+ // proj-1 through g-a) — `hasResolvableProjectExcluding`'s binding arm.
+ const res = await remove("proj-2");
+ expect(res.status).toBe(200);
+ expect(projectRow("proj-2")).toBeUndefined();
+ });
+
+ it("200s when a bound user resolves a project they CREATED", async () => {
+ // The other arm: MEMBER's only path was proj-1 until they create proj-5.
+ store.projects.push({
+ id: "proj-5",
+ organizationId: ORG,
+ name: "Epsilon",
+ slug: "epsilon",
+ createdByUserId: MEMBER,
+ createdAt: at(30),
+ });
+ const res = await remove("proj-1");
+ expect(res.status).toBe(200);
+ expect(projectRow("proj-1")).toBeUndefined();
+ });
+
+ it("does not let a SUSPENDED member's binding block the delete", async () => {
+ const row = store.members.find((m) => m.userId === MEMBER);
+ if (row) row.status = "suspended";
+ // MEMBER (suspended) is the only otherwise-stranded candidate on proj-1.
+ const res = await remove("proj-1");
+ expect(res.status).toBe(200);
+ expect(projectRow("proj-1")).toBeUndefined();
+ });
+
+ it("404s a project of another organization", async () => {
+ const res = await remove("proj-x");
+ expect(res.status).toBe(404);
+ expect(projectRow("proj-x")).toBeTruthy();
+ expect(store.audits).toHaveLength(0);
+ });
+
+ it("403s a non-manager and deletes nothing", async () => {
+ store.sessionUserId = MEMBER2;
+ const res = await remove("proj-2", {});
+ expect(res.status).toBe(403);
+ expect(projectRow("proj-2")).toBeTruthy();
+ expect(store.audits).toHaveLength(0);
+ });
+});
diff --git a/packages/api/src/routes/org/projects.ts b/packages/api/src/routes/org/projects.ts
new file mode 100644
index 00000000..c6569691
--- /dev/null
+++ b/packages/api/src/routes/org/projects.ts
@@ -0,0 +1,211 @@
+import { Hono } from "hono";
+import type { Context } from "hono";
+import type { ApiEnv } from "../../types";
+import { auth } from "../../middleware/auth";
+import { ServiceError } from "../../services/errors";
+import { parse } from "./parse";
+import { canAccessProjectAsUser } from "../../middleware/auth/resolve";
+import {
+ deleteProject,
+ getProject,
+ renameProject,
+ requireManageableProject,
+ requireProject,
+} from "../../services/project-service";
+import {
+ listProjectAccess,
+ setProjectAccess,
+} from "../../services/project-access-service";
+import {
+ renameProjectSchema,
+ setProjectAccessSchema,
+} from "../../validations/project";
+import {
+ withAudit,
+ AUDIT_ACTIONS,
+ AUDIT_SERVICES,
+ AUDIT_SOURCE,
+} from "../../services/audit-service";
+
+/**
+ * `/v1/projects/*` — project administration (rename, delete, sharing).
+ *
+ * Lives under `routes/org/` because that is the OSS-owned, package-exported
+ * route folder — the URL is `/v1/projects/...`, NOT `/v1/org/projects`.
+ *
+ * The guard stack deliberately DIFFERS from `/v1/org/*`:
+ *
+ * `requireProject: false` — the project is named in the path. Demanding an
+ * `X-Project-Id` header would 401 the OSS web (which sends no headers at all)
+ * and would introduce a second, conflicting project scope on every request.
+ *
+ * NO `role: "admin"` — unlike the org directory, this surface is legitimately
+ * reachable by a plain member who holds an `owner` binding (13c: the project
+ * owner may rename / share / delete). Authorization is therefore PER-RESOURCE,
+ * in the service (`requireManageableProject` / `canAccessProjectAsUser`), never
+ * in the middleware.
+ *
+ * The `scope === "project"` fence is kept for exactly the org routers' reason:
+ * a project-scoped key is the credential an AGENT carries, and a leaked agent
+ * key must never be able to rename, delete or re-share the project it lives in.
+ */
+export const ossProjectRoutes = () => {
+ const app = new Hono();
+ app.use("*", auth({ requireProject: false }));
+ app.use("*", async (c, next) => {
+ if (c.get("auth").scope === "project") {
+ throw new ServiceError(
+ "FORBIDDEN",
+ "Managing a project requires a session or an organization-scoped credential.",
+ );
+ }
+ return next();
+ });
+
+ // `organizationId` on every write is load-bearing, not decoration: besides
+ // scoping the audit row it flushes the gateway's org cache
+ // (invalidateGatewayCacheForOrg). ProjectAccess IS the `PrincipalSet` the
+ // Rust policy engine resolves, so a missed flush is a stale AUTHORIZATION
+ // decision for as long as the cache window lasts.
+ const auditBase = (c: Context) => ({
+ organizationId: c.get("auth").organizationId,
+ userId: c.get("auth").userId,
+ userEmail: c.get("auth").userEmail,
+ service: AUDIT_SERVICES.PROJECT,
+ source: AUDIT_SOURCE.API,
+ });
+
+ /** Read authorization: anyone who may USE the project may read it. Resolve
+ * first (404 for unknown/cross-org), then authorize (403). */
+ const requireReadableProject = async (
+ organizationId: string,
+ userId: string,
+ projectId: string,
+ ) => {
+ const project = await requireProject(organizationId, projectId);
+ if (
+ !(await canAccessProjectAsUser(userId, {
+ id: project.id,
+ organizationId,
+ }))
+ ) {
+ throw new ServiceError(
+ "FORBIDDEN",
+ "You do not have access to this project.",
+ );
+ }
+ return project;
+ };
+
+ // GET /projects/:projectId — the sharing page's name/slug source. Nothing
+ // else in the API exposes a project's name (the session route returns only
+ // `projectId`).
+ app.get("/:projectId", async (c) => {
+ const auth = c.get("auth");
+ const projectId = c.req.param("projectId");
+ await requireReadableProject(auth.organizationId, auth.userId, projectId);
+ return c.json(await getProject(auth.organizationId, projectId));
+ });
+
+ // PATCH /projects/:projectId — rename (name only; `slug` is immutable).
+ app.patch("/:projectId", async (c) => {
+ const auth = c.get("auth");
+ const projectId = c.req.param("projectId");
+ await requireManageableProject(auth.organizationId, auth.userId, projectId);
+ const body = await c.req.json().catch(() => null);
+ const input = parse(renameProjectSchema, body);
+
+ const project = await withAudit(
+ () => renameProject(auth.organizationId, projectId, input.name),
+ (renamed) => ({
+ ...auditBase(c),
+ projectId,
+ action: AUDIT_ACTIONS.UPDATE,
+ metadata: { projectId, change: "name", name: renamed.name },
+ }),
+ );
+ return c.json(project);
+ });
+
+ // DELETE /projects/:projectId — explicit pinned cascade, three refusals.
+ app.delete("/:projectId", async (c) => {
+ const auth = c.get("auth");
+ const projectId = c.req.param("projectId");
+ await requireManageableProject(auth.organizationId, auth.userId, projectId);
+
+ const result = await withAudit(
+ () => deleteProject(auth.organizationId, auth.userId, projectId),
+ (deleted) => ({
+ ...auditBase(c),
+ // NO `projectId` here, deliberately: withAudit writes the audit row
+ // AFTER the delete resolves, so an audit_logs row pointing at the
+ // just-deleted project violates audit_logs_project_id_fkey — and
+ // logAuditEvent SWALLOWS its own errors, so the delete would end up
+ // completely unaudited. `organizationId` keeps it attributable.
+ action: AUDIT_ACTIONS.DELETE,
+ // Counts only, never id arrays — audit metadata must stay bounded.
+ metadata: { projectId, name: deleted.name, removed: deleted.removed },
+ }),
+ );
+
+ // No flush here: withAudit's `invalidateGatewayCacheForAccount` cannot work
+ // (it looks keys up by projectId, and they are gone) and a by-key flush is
+ // impossible once the keys no longer authenticate — so `deleteProject`
+ // flushes them itself, before the cascade. `organizationId` on the audit
+ // still flushes every SURVIVING project in the org.
+ return c.json({
+ id: result.id,
+ name: result.name,
+ removed: result.removed,
+ });
+ });
+
+ // GET /projects/:projectId/access — the sharing surface's current bindings.
+ app.get("/:projectId/access", async (c) => {
+ const auth = c.get("auth");
+ const projectId = c.req.param("projectId");
+ await requireReadableProject(auth.organizationId, auth.userId, projectId);
+ return c.json(await listProjectAccess(auth.organizationId, projectId));
+ });
+
+ // PUT /projects/:projectId/access — bulk replace-set (the dialog's save).
+ // Returns a JSON body: the client's apiPut ALWAYS parses, so a 204 here
+ // would throw in the browser.
+ app.put("/:projectId/access", async (c) => {
+ const auth = c.get("auth");
+ const projectId = c.req.param("projectId");
+ const { isOrgAdmin } = await requireManageableProject(
+ auth.organizationId,
+ auth.userId,
+ projectId,
+ );
+ const body = await c.req.json().catch(() => null);
+ const input = parse(setProjectAccessSchema, body);
+
+ const result = await withAudit(
+ () =>
+ setProjectAccess(
+ auth.organizationId,
+ auth.userId,
+ isOrgAdmin,
+ projectId,
+ input,
+ ),
+ (delta) => ({
+ ...auditBase(c),
+ projectId,
+ action: AUDIT_ACTIONS.UPDATE,
+ metadata: {
+ projectId,
+ change: "access",
+ added: delta.added,
+ removed: delta.removed,
+ roleChanged: delta.roleChanged,
+ },
+ }),
+ );
+ return c.json(result);
+ });
+
+ return app;
+};
diff --git a/packages/api/src/routes/org/role-mappings.test.ts b/packages/api/src/routes/org/role-mappings.test.ts
new file mode 100644
index 00000000..9a17198e
--- /dev/null
+++ b/packages/api/src/routes/org/role-mappings.test.ts
@@ -0,0 +1,1248 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { Hono } from "hono";
+import type { ApiEnv } from "../../types";
+
+// `/v1/org/role-mappings` end-to-end through the real app: the OSS org routes
+// mounted on the `eeRoutes` seam, the OSS role resolver wired as the
+// RoleResolver, and `CAPS.rbac` on. Admin callers arrive with an org API key
+// (whose key path re-checks admin through the resolver); the non-admin cases
+// use a session, since a non-admin's org key fails key authentication
+// outright. (Same harness as groups.test.ts — cloned, not shared.)
+//
+// The apply engine is exercised THROUGH these routes, so the mock has to be
+// honest about two things or the security tests prove nothing: the
+// `role: { not: "owner" }` predicate on `organizationMember.updateMany`, and
+// the difference between the two `$transaction` forms (see the counters).
+
+const ORG = "org-1";
+const OTHER_ORG = "org-2";
+const OWNER = "user-owner";
+const ADMIN = "user-admin";
+const MEMBER = "user-member";
+const MEMBER2 = "user-member-2";
+const SUSPENDED = "user-suspended";
+const OUTSIDER = "user-outsider";
+const ADMIN_KEY = "oc_org_admin-key";
+const PROJECT_KEY = "oc_project-key-of-owner";
+
+vi.hoisted(() => {
+ process.env.NEXT_PUBLIC_EDITION = "oss";
+ process.env.SECRET_ENCRYPTION_KEY = "test-secret";
+ process.env.OAUTH_STATE_SECRET = "test-secret";
+});
+
+interface MemberRow {
+ organizationId: string;
+ userId: string;
+ userEmail: string;
+ role: string;
+ status: string;
+ ssoExempt: boolean;
+ suspendedAt: Date | null;
+ createdAt: Date;
+}
+
+interface UserRow {
+ id: string;
+ externalAuthId: string;
+ email: string;
+ name: string | null;
+}
+
+interface GroupRow {
+ id: string;
+ organizationId: string;
+ name: string;
+ source: string;
+ externalId: string | null;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+interface GroupMemberRow {
+ groupId: string;
+ userId: string;
+ createdAt: Date;
+}
+
+interface MappingRow {
+ id: string;
+ organizationId: string;
+ groupId: string;
+ role: string;
+ priority: number;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+interface AuditRow {
+ organizationId?: string;
+ userId: string;
+ userEmail: string;
+ action: string;
+ service: string;
+ source: string;
+ metadata: Record;
+}
+
+const store = vi.hoisted(() => ({
+ members: [] as MemberRow[],
+ users: [] as UserRow[],
+ groups: [] as GroupRow[],
+ groupMembers: [] as GroupMemberRow[],
+ roleMappings: [] as MappingRow[],
+ audits: [] as AuditRow[],
+ seq: 0,
+ /** Array-form `$transaction` — the APPLY's role writes. */
+ txCount: 0,
+ /** Interactive `$transaction` — the advisory-locked create/reorder. */
+ lockedTxCount: 0,
+ /** Simulate a create-create race: the pre-check misses, create P2002s. */
+ race: false,
+ /** Which user the session provider resolves to (null = no session). */
+ sessionUserId: null as string | null,
+}));
+
+vi.mock("@onecli/db", () => {
+ class PrismaClientKnownRequestError extends Error {
+ code: string;
+ constructor(message: string, code: string) {
+ super(message);
+ this.code = code;
+ }
+ }
+
+ interface GroupWhere {
+ id?: string;
+ organizationId?: string;
+ }
+ interface GroupMemberWhere {
+ groupId?: string | { in: string[] };
+ userId?: { in: string[] };
+ }
+ interface MappingWhere {
+ id?: string;
+ organizationId?: string;
+ groupId?: string;
+ }
+ interface MappingSelect {
+ id?: boolean;
+ organizationId?: boolean;
+ groupId?: boolean;
+ role?: boolean;
+ priority?: boolean;
+ createdAt?: boolean;
+ updatedAt?: boolean;
+ group?: {
+ select: { name?: boolean; _count?: { select: { members?: boolean } } };
+ };
+ }
+ interface MemberWhere {
+ organizationId?: string;
+ userId?: string | { in: string[] };
+ role?: string | { not?: string };
+ status?: string | { not?: string };
+ }
+
+ const matchesRole = (row: MemberRow, where: MemberWhere) => {
+ if (where.role === undefined) return true;
+ if (typeof where.role === "string") return row.role === where.role;
+ return where.role.not === undefined || row.role !== where.role.not;
+ };
+
+ const filterMembers = (where: MemberWhere) =>
+ store.members.filter((row) => {
+ if (
+ where.organizationId !== undefined &&
+ row.organizationId !== where.organizationId
+ )
+ return false;
+ if (typeof where.userId === "string" && row.userId !== where.userId)
+ return false;
+ if (
+ typeof where.userId === "object" &&
+ where.userId !== null &&
+ !where.userId.in.includes(row.userId)
+ )
+ return false;
+ if (where.status !== undefined) {
+ const ok =
+ typeof where.status === "string"
+ ? row.status === where.status
+ : where.status.not === undefined || row.status !== where.status.not;
+ if (!ok) return false;
+ }
+ return matchesRole(row, where);
+ });
+
+ const pickMember = (
+ row: MemberRow,
+ select?: { userId?: boolean; role?: boolean; userEmail?: boolean },
+ ) => {
+ if (!select) return { ...row };
+ const picked: Record = {};
+ if (select.userId) picked.userId = row.userId;
+ if (select.role) picked.role = row.role;
+ if (select.userEmail) picked.userEmail = row.userEmail;
+ return picked;
+ };
+
+ const filterGroupMembers = (where: GroupMemberWhere) =>
+ store.groupMembers.filter((row) => {
+ if (typeof where.groupId === "string" && row.groupId !== where.groupId)
+ return false;
+ if (
+ typeof where.groupId === "object" &&
+ where.groupId !== null &&
+ !where.groupId.in.includes(row.groupId)
+ )
+ return false;
+ if (where.userId !== undefined && !where.userId.in.includes(row.userId))
+ return false;
+ return true;
+ });
+
+ const filterMappings = (where: MappingWhere = {}) =>
+ store.roleMappings.filter((row) => {
+ if (where.id !== undefined && row.id !== where.id) return false;
+ if (
+ where.organizationId !== undefined &&
+ row.organizationId !== where.organizationId
+ )
+ return false;
+ if (where.groupId !== undefined && row.groupId !== where.groupId)
+ return false;
+ return true;
+ });
+
+ // The ONE resolution order: priority asc, createdAt asc, id asc.
+ const sortMappings = (rows: MappingRow[]) =>
+ rows
+ .slice()
+ .sort(
+ (a, b) =>
+ a.priority - b.priority ||
+ a.createdAt.getTime() - b.createdAt.getTime() ||
+ a.id.localeCompare(b.id),
+ );
+
+ const pickMapping = (row: MappingRow, select?: MappingSelect) => {
+ if (!select) return { ...row };
+ const picked: Record = {};
+ for (const key of [
+ "id",
+ "organizationId",
+ "groupId",
+ "role",
+ "priority",
+ "createdAt",
+ "updatedAt",
+ ] as const) {
+ if (select[key]) picked[key] = row[key];
+ }
+ if (select.group) {
+ const group = store.groups.find((g) => g.id === row.groupId);
+ const relation: Record = {};
+ if (select.group.select.name) relation.name = group?.name ?? "";
+ if (select.group.select._count) {
+ relation._count = {
+ members: store.groupMembers.filter((m) => m.groupId === row.groupId)
+ .length,
+ };
+ }
+ picked.group = relation;
+ }
+ return picked;
+ };
+
+ const dbClient = {
+ apiKey: {
+ findUnique: async ({ where }: { where: { key?: string } }) => {
+ if (where.key === "oc_org_admin-key")
+ return {
+ userId: "user-admin",
+ organizationId: "org-1",
+ scope: "organization",
+ };
+ // A PROJECT-scoped key owned by the org's OWNER: it authenticates
+ // fine, which is exactly why the router needs its own scope guard.
+ if (where.key === "oc_project-key-of-owner")
+ return { userId: "user-owner", projectId: "proj-1" };
+ return null;
+ },
+ findFirst: async () => null,
+ findMany: async () => [],
+ },
+ user: {
+ findUnique: async ({
+ where,
+ select,
+ }: {
+ where: { id?: string; externalAuthId?: string; email?: string };
+ select?: Record;
+ }) => {
+ if (select?.organizationMemberships) {
+ return {
+ organizationMemberships: store.members
+ .filter((m) => m.userId === where.id)
+ .map((m) => ({ organizationId: m.organizationId })),
+ };
+ }
+ return (
+ store.users.find(
+ (u) =>
+ (where.id !== undefined && u.id === where.id) ||
+ (where.externalAuthId !== undefined &&
+ u.externalAuthId === where.externalAuthId) ||
+ (where.email !== undefined && u.email === where.email),
+ ) ?? null
+ );
+ },
+ },
+ organizationMember: {
+ findUnique: async ({
+ where,
+ select,
+ }: {
+ where: {
+ organizationId_userId: { organizationId: string; userId: string };
+ };
+ select?: { userId?: boolean; role?: boolean; userEmail?: boolean };
+ }) => {
+ const { organizationId, userId } = where.organizationId_userId;
+ const row = store.members.find(
+ (m) => m.organizationId === organizationId && m.userId === userId,
+ );
+ // The role resolver reads role+status off the whole row.
+ return row ? (select ? pickMember(row, select) : { ...row }) : null;
+ },
+ findFirst: async ({ where }: { where: MemberWhere }) =>
+ filterMembers(where)[0] ?? null,
+ findMany: async ({
+ where,
+ select,
+ }: {
+ where: MemberWhere;
+ select?: { userId?: boolean; role?: boolean; userEmail?: boolean };
+ }) => filterMembers(where).map((row) => pickMember(row, select)),
+ // THE last-owner invariant at the write layer: the predicate is honoured
+ // here, or the "a mapping can never strip the last owner" tests would be
+ // testing the mock rather than the service.
+ updateMany: async ({
+ where,
+ data,
+ }: {
+ where: MemberWhere;
+ data: { role: string };
+ }) => {
+ const rows = filterMembers(where);
+ for (const row of rows) row.role = data.role;
+ return { count: rows.length };
+ },
+ count: async () => 0,
+ },
+ group: {
+ findFirst: async ({
+ where,
+ select,
+ }: {
+ where: GroupWhere;
+ select?: { id?: boolean; name?: boolean };
+ }) => {
+ const row = store.groups.find(
+ (g) =>
+ (where.id === undefined || g.id === where.id) &&
+ (where.organizationId === undefined ||
+ g.organizationId === where.organizationId),
+ );
+ if (!row) return null;
+ if (!select) return { ...row };
+ const picked: Record = {};
+ if (select.id) picked.id = row.id;
+ if (select.name) picked.name = row.name;
+ return picked;
+ },
+ findMany: async () => [],
+ },
+ groupMember: {
+ findMany: async ({
+ where,
+ select,
+ }: {
+ where: GroupMemberWhere;
+ select?: { groupId?: boolean; userId?: boolean };
+ }) =>
+ filterGroupMembers(where).map((row) => {
+ if (!select) return { ...row };
+ const picked: Record = {};
+ if (select.groupId) picked.groupId = row.groupId;
+ if (select.userId) picked.userId = row.userId;
+ return picked;
+ }),
+ },
+ groupRoleMapping: {
+ findFirst: async ({
+ where,
+ select,
+ }: {
+ where: MappingWhere;
+ select?: MappingSelect;
+ }) => {
+ // Race simulation: the create pre-check (a groupId-keyed findFirst
+ // with no id) misses, so the create itself must surface the P2002.
+ if (store.race && where.groupId !== undefined && where.id === undefined)
+ return null;
+ const row = sortMappings(filterMappings(where))[0];
+ return row ? pickMapping(row, select) : null;
+ },
+ findMany: async ({
+ where,
+ select,
+ }: {
+ where: MappingWhere;
+ select?: MappingSelect;
+ }) =>
+ sortMappings(filterMappings(where)).map((row) =>
+ pickMapping(row, select),
+ ),
+ count: async ({ where }: { where: MappingWhere }) =>
+ filterMappings(where).length,
+ aggregate: async ({ where }: { where: MappingWhere }) => {
+ const rows = filterMappings(where);
+ return {
+ _max: {
+ priority: rows.length
+ ? Math.max(...rows.map((r) => r.priority))
+ : null,
+ },
+ };
+ },
+ create: async ({
+ data,
+ select,
+ }: {
+ data: {
+ organizationId: string;
+ groupId: string;
+ role: string;
+ priority: number;
+ };
+ select?: MappingSelect;
+ }) => {
+ // `groupId` is @unique — at most one mapping per group.
+ if (store.roleMappings.some((m) => m.groupId === data.groupId)) {
+ throw new PrismaClientKnownRequestError(
+ "Unique constraint failed",
+ "P2002",
+ );
+ }
+ const row: MappingRow = {
+ id: `rm-${++store.seq}`,
+ organizationId: data.organizationId,
+ groupId: data.groupId,
+ role: data.role,
+ priority: data.priority,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+ store.roleMappings.push(row);
+ return pickMapping(row, select);
+ },
+ update: async ({
+ where,
+ data,
+ }: {
+ where: { id: string };
+ data: { priority?: number; role?: string };
+ }) => {
+ const row = store.roleMappings.find((m) => m.id === where.id);
+ if (!row)
+ throw new PrismaClientKnownRequestError("Record not found", "P2025");
+ if (data.priority !== undefined) row.priority = data.priority;
+ if (data.role !== undefined) row.role = data.role;
+ row.updatedAt = new Date();
+ return { ...row };
+ },
+ updateMany: async ({
+ where,
+ data,
+ }: {
+ where: MappingWhere;
+ data: { role?: string; priority?: number };
+ }) => {
+ const rows = filterMappings(where);
+ for (const row of rows) {
+ if (data.role !== undefined) row.role = data.role;
+ if (data.priority !== undefined) row.priority = data.priority;
+ row.updatedAt = new Date();
+ }
+ return { count: rows.length };
+ },
+ deleteMany: async ({ where }: { where: MappingWhere }) => {
+ const rows = filterMappings(where);
+ const ids = new Set(rows.map((r) => r.id));
+ store.roleMappings = store.roleMappings.filter((m) => !ids.has(m.id));
+ return { count: rows.length };
+ },
+ },
+ project: {
+ findFirst: async () => ({ id: "proj-1", organizationId: "org-1" }),
+ findUnique: async () => ({ id: "proj-1", organizationId: "org-1" }),
+ },
+ projectAccess: { findFirst: async () => null },
+ auditLog: {
+ create: async ({ data }: { data: AuditRow }) => {
+ store.audits.push(data);
+ return data;
+ },
+ },
+ // Both forms, counted SEPARATELY: the apply batches its role writes as an
+ // array, while create/reorder open an interactive one to take the
+ // advisory lock. "No apply transaction was opened" is `txCount`.
+ $transaction: async (arg: unknown) => {
+ if (typeof arg === "function") {
+ store.lockedTxCount++;
+ return (arg as (tx: unknown) => Promise)(dbClient);
+ }
+ store.txCount++;
+ return Promise.all(arg as Promise[]);
+ },
+ /** The advisory lock — a no-op tagged-template stub. */
+ $executeRaw: async () => 1,
+ };
+
+ return {
+ Prisma: { JsonNull: null, PrismaClientKnownRequestError },
+ db: dbClient,
+ };
+});
+
+import { createApiApp } from "../../app";
+import { registerOssOrgRoutes } from "./index";
+import { ossRoleResolver } from "../../services/org-role-resolver";
+
+const sessionProvider = {
+ getSession: async () => {
+ const user = store.users.find((u) => u.id === store.sessionUserId);
+ return user ? { id: user.externalAuthId, email: user.email } : null;
+ },
+};
+
+const app: Hono = createApiApp(sessionProvider, {
+ eeRoutes: registerOssOrgRoutes,
+ roleResolver: ossRoleResolver,
+});
+
+const at = (minutes: number) => new Date(Date.UTC(2026, 0, 1, 0, minutes));
+
+const member = (
+ userId: string,
+ role: string,
+ createdAt: Date,
+ organizationId = ORG,
+): MemberRow => ({
+ organizationId,
+ userId,
+ userEmail: `${userId}@example.com`,
+ role,
+ status: "active",
+ ssoExempt: false,
+ suspendedAt: null,
+ createdAt,
+});
+
+const group = (
+ id: string,
+ name: string,
+ overrides: Partial = {},
+): GroupRow => ({
+ id,
+ organizationId: ORG,
+ name,
+ source: "manual",
+ externalId: null,
+ createdAt: at(10),
+ updatedAt: at(10),
+ ...overrides,
+});
+
+const mapping = (
+ id: string,
+ groupId: string,
+ role: string,
+ priority: number,
+ organizationId = ORG,
+): MappingRow => ({
+ id,
+ organizationId,
+ groupId,
+ role,
+ priority,
+ createdAt: at(30 + priority),
+ updatedAt: at(30 + priority),
+});
+
+beforeEach(() => {
+ store.users = [
+ {
+ id: OWNER,
+ externalAuthId: "ext-owner",
+ email: "owner@example.com",
+ name: "Olive Owner",
+ },
+ {
+ id: ADMIN,
+ externalAuthId: "ext-admin",
+ email: "admin@example.com",
+ name: "Adam Admin",
+ },
+ {
+ id: MEMBER,
+ externalAuthId: "ext-member",
+ email: "member@example.com",
+ name: null,
+ },
+ {
+ id: MEMBER2,
+ externalAuthId: "ext-member-2",
+ email: "member2@example.com",
+ name: null,
+ },
+ {
+ id: SUSPENDED,
+ externalAuthId: "ext-suspended",
+ email: "suspended@example.com",
+ name: null,
+ },
+ {
+ id: OUTSIDER,
+ externalAuthId: "ext-outsider",
+ email: "outsider@other.test",
+ name: "Odette Outsider",
+ },
+ ];
+ store.members = [
+ member(OWNER, "owner", at(0)),
+ member(ADMIN, "admin", at(1)),
+ member(MEMBER, "member", at(2)),
+ member(MEMBER2, "member", at(3)),
+ { ...member(SUSPENDED, "member", at(4)), status: "suspended" },
+ member(OUTSIDER, "admin", at(5), OTHER_ORG),
+ ];
+ store.groups = [
+ group("g-a", "Engineering"),
+ group("g-b", "Design", { createdAt: at(11), updatedAt: at(11) }),
+ group("g-c", "Everyone", { createdAt: at(12), updatedAt: at(12) }),
+ group("g-scim", "Provisioned", {
+ source: "scim",
+ externalId: "idp-77",
+ createdAt: at(13),
+ updatedAt: at(13),
+ }),
+ // A group in a DIFFERENT org — never visible through this org's routes.
+ group("g-x", "Foreign", { organizationId: OTHER_ORG, createdAt: at(14) }),
+ ];
+ store.groupMembers = [
+ { groupId: "g-a", userId: OWNER, createdAt: at(20) },
+ { groupId: "g-a", userId: ADMIN, createdAt: at(21) },
+ { groupId: "g-a", userId: MEMBER, createdAt: at(22) },
+ { groupId: "g-b", userId: MEMBER2, createdAt: at(23) },
+ { groupId: "g-b", userId: SUSPENDED, createdAt: at(24) },
+ { groupId: "g-c", userId: MEMBER, createdAt: at(25) },
+ { groupId: "g-c", userId: MEMBER2, createdAt: at(26) },
+ { groupId: "g-scim", userId: MEMBER, createdAt: at(27) },
+ { groupId: "g-x", userId: OUTSIDER, createdAt: at(28) },
+ ];
+ // A CONVERGED baseline: the only mapping grants `member`, which nobody can
+ // be raised to, so any apply over the untouched fixture is a no-op.
+ store.roleMappings = [
+ mapping("rm-1", "g-b", "member", 0),
+ mapping("rm-x", "g-x", "admin", 0, OTHER_ORG),
+ ];
+ store.audits = [];
+ store.seq = 100;
+ store.txCount = 0;
+ store.lockedTxCount = 0;
+ store.race = false;
+ store.sessionUserId = null;
+});
+
+const asAdmin = { headers: { Authorization: `Bearer ${ADMIN_KEY}` } };
+const asProjectKey = { headers: { Authorization: `Bearer ${PROJECT_KEY}` } };
+
+interface RoleMappingBody {
+ id: string;
+ groupId: string;
+ groupName: string;
+ role: string;
+ priority: number;
+ memberCount: number;
+ createdAt: string;
+ updatedAt: string;
+}
+
+const base = "/v1/org/role-mappings";
+
+const list = async (init: RequestInit = asAdmin) =>
+ app.request(base, init) as Promise;
+
+const listRows = async (): Promise => {
+ const res = await list();
+ expect(res.status).toBe(200);
+ return (await res.json()) as RoleMappingBody[];
+};
+
+const create = (body: unknown, init: RequestInit = asAdmin) =>
+ app.request(base, { ...init, method: "POST", body: JSON.stringify(body) });
+
+const update = (id: string, body: unknown, init: RequestInit = asAdmin) =>
+ app.request(`${base}/${id}`, {
+ ...init,
+ method: "PATCH",
+ body: JSON.stringify(body),
+ });
+
+const remove = (id: string, init: RequestInit = asAdmin) =>
+ app.request(`${base}/${id}`, { ...init, method: "DELETE" });
+
+const reorder = (orderedIds: string[], init: RequestInit = asAdmin) =>
+ app.request(`${base}/order`, {
+ ...init,
+ method: "PUT",
+ body: JSON.stringify({ orderedIds }),
+ });
+
+const preview = (body: unknown, init: RequestInit = asAdmin) =>
+ app.request(`${base}/preview`, {
+ ...init,
+ method: "POST",
+ body: JSON.stringify(body),
+ });
+
+const roleOf = (userId: string) =>
+ store.members.find((m) => m.organizationId === ORG && m.userId === userId)
+ ?.role;
+
+const activeOwners = () =>
+ store.members.filter(
+ (m) =>
+ m.organizationId === ORG &&
+ m.role === "owner" &&
+ m.status !== "suspended",
+ );
+
+const memberAudits = () => store.audits.filter((a) => a.service === "member");
+const mappingAudits = () =>
+ store.audits.filter((a) => a.service === "role-mapping");
+
+describe("the guard stack", () => {
+ it("401s an unauthenticated caller", async () => {
+ const res = await app.request(base);
+ expect(res.status).toBe(401);
+ });
+
+ it("403s a non-admin member (deterministic, not a 401)", async () => {
+ store.sessionUserId = MEMBER;
+ const res = await app.request(base);
+ expect(res.status).toBe(403);
+ });
+
+ it("403s a project-scoped key on EVERY verb, even for an org owner", async () => {
+ const responses = await Promise.all([
+ list(asProjectKey),
+ create({ groupId: "g-a", role: "admin" }, asProjectKey),
+ update("rm-1", { role: "admin" }, asProjectKey),
+ remove("rm-1", asProjectKey),
+ reorder(["rm-1"], asProjectKey),
+ preview({ groupId: "g-a", role: "admin" }, asProjectKey),
+ ]);
+ for (const res of responses) expect(res.status).toBe(403);
+ expect(store.audits).toHaveLength(0);
+ expect(roleOf(MEMBER)).toBe("member");
+ });
+});
+
+describe("GET /v1/org/role-mappings", () => {
+ it("returns a BARE ARRAY (not a page envelope) — the client contract", async () => {
+ const res = await list();
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(Array.isArray(body)).toBe(true);
+ });
+
+ it("orders by priority ascending and carries groupName + memberCount", async () => {
+ store.roleMappings.push(mapping("rm-2", "g-a", "admin", 5));
+ store.roleMappings.push(mapping("rm-3", "g-c", "member", 2));
+ const rows = await listRows();
+ expect(rows.map((r) => r.id)).toEqual(["rm-1", "rm-3", "rm-2"]);
+ expect(rows[0]).toEqual({
+ id: "rm-1",
+ groupId: "g-b",
+ groupName: "Design",
+ role: "member",
+ priority: 0,
+ memberCount: 2,
+ createdAt: at(30).toISOString(),
+ updatedAt: at(30).toISOString(),
+ });
+ });
+
+ it("never leaks another organization's mappings", async () => {
+ const rows = await listRows();
+ expect(rows.some((r) => r.id === "rm-x")).toBe(false);
+ });
+});
+
+describe("POST /v1/org/role-mappings", () => {
+ it("creates a mapping, appends at max+1, and audits it", async () => {
+ const res = await create({ groupId: "g-c", role: "admin" });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as RoleMappingBody;
+ expect(body).toMatchObject({
+ groupId: "g-c",
+ groupName: "Everyone",
+ role: "admin",
+ priority: 1, // rm-1 sits at 0
+ memberCount: 2,
+ });
+ expect(mappingAudits()).toHaveLength(1);
+ expect(mappingAudits()[0]).toMatchObject({
+ organizationId: ORG,
+ userId: ADMIN,
+ action: "create",
+ service: "role-mapping",
+ source: "api",
+ metadata: {
+ mappingId: body.id,
+ groupId: "g-c",
+ groupName: "Everyone",
+ role: "admin",
+ priority: 1,
+ // g-c is {MEMBER, MEMBER2}, but MEMBER2 is also in g-b whose `member`
+ // mapping sits at priority 0 and shadows this one.
+ rolesChanged: 1,
+ },
+ });
+ expect(roleOf(MEMBER)).toBe("admin");
+ expect(roleOf(MEMBER2)).toBe("member");
+ });
+
+ it("uses priority 0 for the org's FIRST mapping", async () => {
+ store.roleMappings = store.roleMappings.filter((m) => m.id === "rm-x");
+ const res = await create({ groupId: "g-c", role: "member" });
+ expect(res.status).toBe(200);
+ expect(((await res.json()) as RoleMappingBody).priority).toBe(0);
+ });
+
+ it("honours an explicit priority", async () => {
+ const res = await create({ groupId: "g-c", role: "member", priority: 7 });
+ expect(res.status).toBe(200);
+ expect(((await res.json()) as RoleMappingBody).priority).toBe(7);
+ });
+
+ // `priority` is NOT coerced: a body that meant "no priority" must 422, not
+ // land in slot 0 — the highest-precedence slot, which shadows everything.
+ it("422s a non-numeric priority instead of reading it as 0", async () => {
+ for (const priority of [null, "", false, [], "3"]) {
+ const res = await create({ groupId: "g-c", role: "admin", priority });
+ expect(res.status).toBe(422);
+ }
+ expect(store.roleMappings.some((m) => m.groupId === "g-c")).toBe(false);
+ expect(store.audits).toHaveLength(0);
+ });
+
+ it("409s once the org is at the mapping ceiling, keeping PUT /order reachable", async () => {
+ store.roleMappings = Array.from({ length: 500 }, (_, i) =>
+ mapping(`rm-bulk-${i}`, `g-bulk-${i}`, "member", i),
+ );
+ const res = await create({ groupId: "g-a", role: "admin" });
+ expect(res.status).toBe(409);
+ expect(store.roleMappings).toHaveLength(500);
+ expect(store.audits).toHaveLength(0);
+ });
+
+ it("409s a second mapping for the same group (groupId is unique)", async () => {
+ const res = await create({ groupId: "g-b", role: "admin" });
+ expect(res.status).toBe(409);
+ expect(store.audits).toHaveLength(0);
+ expect(store.roleMappings.filter((m) => m.groupId === "g-b")).toHaveLength(
+ 1,
+ );
+ });
+
+ it("409s a create-create race surfaced as P2002", async () => {
+ store.race = true;
+ const res = await create({ groupId: "g-b", role: "admin" });
+ expect(res.status).toBe(409);
+ expect(store.audits).toHaveLength(0);
+ });
+
+ it("422s role: owner — mappings can never mint an owner", async () => {
+ const res = await create({ groupId: "g-c", role: "owner" });
+ expect(res.status).toBe(422);
+ expect(store.audits).toHaveLength(0);
+ expect(store.roleMappings.some((m) => m.groupId === "g-c")).toBe(false);
+ });
+
+ it("422s a malformed body", async () => {
+ for (const body of [{}, { groupId: "g-c" }, { role: "admin" }, null]) {
+ expect((await create(body)).status).toBe(422);
+ }
+ });
+
+ it("404s an unknown group and a group of another organization", async () => {
+ expect((await create({ groupId: "g-nope", role: "admin" })).status).toBe(
+ 404,
+ );
+ expect((await create({ groupId: "g-x", role: "admin" })).status).toBe(404);
+ expect(store.roleMappings.filter((m) => m.groupId === "g-x")).toHaveLength(
+ 1,
+ );
+ });
+
+ it("MAPS a scim-provisioned group: a mapping is a OneCLI artifact, not an IdP one", async () => {
+ const res = await create({ groupId: "g-scim", role: "admin" });
+ expect(res.status).toBe(200);
+ expect(((await res.json()) as RoleMappingBody).groupId).toBe("g-scim");
+ expect(roleOf(MEMBER)).toBe("admin");
+ });
+});
+
+describe("PATCH /v1/org/role-mappings/:id", () => {
+ it("changes the role, audits the discriminator, and re-applies", async () => {
+ const res = await update("rm-1", { role: "admin" });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as RoleMappingBody;
+ expect(body).toMatchObject({ id: "rm-1", role: "admin", priority: 0 });
+ expect(mappingAudits()[0]).toMatchObject({
+ action: "update",
+ service: "role-mapping",
+ metadata: {
+ mappingId: "rm-1",
+ groupId: "g-b",
+ change: "role",
+ role: "admin",
+ rolesChanged: 2,
+ },
+ });
+ });
+
+ it("accepts an optional priority and records change: role+priority", async () => {
+ const res = await update("rm-1", { role: "member", priority: 9 });
+ expect(res.status).toBe(200);
+ expect(((await res.json()) as RoleMappingBody).priority).toBe(9);
+ expect(mappingAudits()[0]?.metadata).toMatchObject({
+ change: "role+priority",
+ priority: 9,
+ });
+ });
+
+ it("404s an unknown id and another org's mapping", async () => {
+ expect((await update("rm-nope", { role: "admin" })).status).toBe(404);
+ expect((await update("rm-x", { role: "member" })).status).toBe(404);
+ expect(store.roleMappings.find((m) => m.id === "rm-x")?.role).toBe("admin");
+ });
+
+ it("422s role: owner", async () => {
+ expect((await update("rm-1", { role: "owner" })).status).toBe(422);
+ expect(store.roleMappings.find((m) => m.id === "rm-1")?.role).toBe(
+ "member",
+ );
+ });
+});
+
+describe("DELETE /v1/org/role-mappings/:id", () => {
+ it("removes the row, returns a JSON body, and audits", async () => {
+ const res = await remove("rm-1");
+ expect(res.status).toBe(200);
+ expect(res.headers.get("content-type")).toContain("application/json");
+ expect(await res.json()).toEqual({
+ id: "rm-1",
+ groupId: "g-b",
+ role: "member",
+ rolesChanged: 0,
+ });
+ expect(store.roleMappings.some((m) => m.id === "rm-1")).toBe(false);
+ expect(mappingAudits()[0]).toMatchObject({
+ action: "delete",
+ service: "role-mapping",
+ source: "api",
+ metadata: { mappingId: "rm-1", groupId: "g-b", role: "member" },
+ });
+ });
+
+ it("404s an unknown id and another org's mapping", async () => {
+ expect((await remove("rm-nope")).status).toBe(404);
+ expect((await remove("rm-x")).status).toBe(404);
+ expect(store.roleMappings.some((m) => m.id === "rm-x")).toBe(true);
+ });
+
+ it("UNSHADOWS: deleting the priority-0 member mapping promotes the suppressed", async () => {
+ // g-c (Everyone → member) at 0 shadows g-a (Engineering → admin) at 1.
+ store.roleMappings = [
+ mapping("rm-shadow", "g-c", "member", 0),
+ mapping("rm-eng", "g-a", "admin", 1),
+ ];
+ expect(roleOf(MEMBER)).toBe("member");
+
+ const res = await remove("rm-shadow");
+ expect(res.status).toBe(200);
+ expect(await res.json()).toMatchObject({ rolesChanged: 1 });
+ // MEMBER is in both groups; with the shadow gone the admin mapping wins.
+ expect(roleOf(MEMBER)).toBe("admin");
+ // MEMBER2 is only in g-c, which no longer has a mapping.
+ expect(roleOf(MEMBER2)).toBe("member");
+ });
+});
+
+describe("PUT /v1/org/role-mappings/order", () => {
+ beforeEach(() => {
+ store.roleMappings = [
+ mapping("rm-shadow", "g-c", "member", 0),
+ mapping("rm-eng", "g-a", "admin", 1),
+ ];
+ });
+
+ it("reassigns 0..n-1, flips the list, and promotes the unshadowed", async () => {
+ const res = await reorder(["rm-eng", "rm-shadow"]);
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as RoleMappingBody[];
+ expect(body.map((r) => r.id)).toEqual(["rm-eng", "rm-shadow"]);
+ expect(body.map((r) => r.priority)).toEqual([0, 1]);
+ expect(roleOf(MEMBER)).toBe("admin");
+ expect(mappingAudits()[0]).toMatchObject({
+ action: "update",
+ service: "role-mapping",
+ source: "api",
+ organizationId: ORG,
+ metadata: { change: "order", count: 2, rolesChanged: 1 },
+ });
+ });
+
+ it("409s a body that does not name every mapping exactly once", async () => {
+ expect((await reorder(["rm-eng"])).status).toBe(409);
+ expect((await reorder(["rm-eng", "rm-shadow", "rm-x"])).status).toBe(409);
+ // Priorities untouched.
+ expect(store.roleMappings.find((m) => m.id === "rm-eng")?.priority).toBe(1);
+ expect(store.audits).toHaveLength(0);
+ });
+
+ it("422s duplicate ids (malformed, not stale)", async () => {
+ const res = await reorder(["rm-eng", "rm-eng"]);
+ expect(res.status).toBe(422);
+ expect(store.audits).toHaveLength(0);
+ });
+
+ it("accepts [] for an org with no mappings", async () => {
+ store.roleMappings = store.roleMappings.filter(
+ (m) => m.organizationId !== ORG,
+ );
+ const res = await reorder([]);
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual([]);
+ });
+
+ it("submitting the identical settled order writes nothing", async () => {
+ const before = store.roleMappings.map((m) => ({ ...m }));
+ const res = await reorder(["rm-shadow", "rm-eng"]);
+ expect(res.status).toBe(200);
+ expect((await res.json()).length).toBe(2);
+ // No update was issued (updatedAt is byte-identical) and no apply
+ // transaction was opened.
+ expect(store.roleMappings).toEqual(before);
+ expect(store.txCount).toBe(0);
+ expect(memberAudits()).toHaveLength(0);
+ });
+});
+
+describe("POST /v1/org/role-mappings/preview", () => {
+ it("counts the raises a new admin mapping would make", async () => {
+ const res = await preview({ groupId: "g-a", role: "admin" });
+ expect(res.status).toBe(200);
+ // g-a is {OWNER, ADMIN, MEMBER}: the owner is skipped and ADMIN is the
+ // caller, so only MEMBER is counted.
+ expect(await res.json()).toEqual({ affectedCount: 1 });
+ });
+
+ it("returns 0 for a member mapping (its effect is shadowing, not demotion)", async () => {
+ const res = await preview({ groupId: "g-a", role: "member" });
+ expect(await res.json()).toEqual({ affectedCount: 0 });
+ });
+
+ it("previews an EXISTING mapping at its current priority, so a shadowed group reads 0", async () => {
+ // g-c (Everyone → member) sits at priority 0; g-b's mapping is below it,
+ // and both cover MEMBER2.
+ store.roleMappings = [
+ mapping("rm-shadow", "g-c", "member", 0),
+ mapping("rm-1", "g-b", "member", 1),
+ ];
+ const shadowed = await preview({ groupId: "g-b", role: "admin" });
+ // MEMBER2 is shadowed by g-c; SUSPENDED is only in g-b, so it still counts.
+ expect(await shadowed.json()).toEqual({ affectedCount: 1 });
+ });
+
+ it("includes suspended members (their stored role is their reinstate shape)", async () => {
+ const res = await preview({ groupId: "g-b", role: "admin" });
+ expect(await res.json()).toEqual({ affectedCount: 2 });
+ });
+
+ it("writes nothing and audits nothing", async () => {
+ const mappingsBefore = store.roleMappings.map((m) => ({ ...m }));
+ const membersBefore = store.members.map((m) => ({ ...m }));
+ const res = await preview({ groupId: "g-a", role: "admin" });
+ expect(res.status).toBe(200);
+ expect(store.roleMappings).toEqual(mappingsBefore);
+ expect(store.members).toEqual(membersBefore);
+ expect(store.audits).toHaveLength(0);
+ expect(store.txCount).toBe(0);
+ });
+
+ it("404s a group of another organization (no existence oracle)", async () => {
+ expect((await preview({ groupId: "g-x", role: "admin" })).status).toBe(404);
+ expect((await preview({ groupId: "g-nope", role: "admin" })).status).toBe(
+ 404,
+ );
+ });
+
+ it("422s role: owner", async () => {
+ expect((await preview({ groupId: "g-a", role: "owner" })).status).toBe(422);
+ });
+});
+
+describe("applying the mappings", () => {
+ it("raises every non-owner, non-self member and audits one MEMBER row each", async () => {
+ const res = await create({ groupId: "g-a", role: "admin" });
+ expect(res.status).toBe(200);
+ expect(roleOf(MEMBER)).toBe("admin");
+ // Untouched: the owner (C1) and the acting admin (C2, already admin).
+ expect(roleOf(OWNER)).toBe("owner");
+ expect(roleOf(MEMBER2)).toBe("member");
+
+ expect(memberAudits()).toHaveLength(1);
+ expect(memberAudits()[0]).toMatchObject({
+ organizationId: ORG,
+ userId: ADMIN,
+ userEmail: `${ADMIN}@example.com`,
+ action: "update",
+ service: "member",
+ source: "api",
+ metadata: {
+ targetUserId: MEMBER,
+ role: "admin",
+ previousRole: "member",
+ via: "role-mapping",
+ groupId: "g-a",
+ trigger: "mapping",
+ },
+ });
+ });
+
+ it("is idempotent: re-applying changes nothing and writes no extra audits", async () => {
+ const first = await create({ groupId: "g-a", role: "admin" });
+ expect(first.status).toBe(200);
+ const created = (await first.json()) as RoleMappingBody;
+ expect(memberAudits()).toHaveLength(1);
+
+ store.txCount = 0;
+ store.audits = [];
+ const again = await update(created.id, { role: "admin" });
+ expect(again.status).toBe(200);
+ expect(memberAudits()).toHaveLength(0);
+ // No apply transaction was opened at all.
+ expect(store.txCount).toBe(0);
+ expect(mappingAudits()[0]?.metadata).toMatchObject({ rolesChanged: 0 });
+ });
+
+ it("NEVER strips the last owner — create, reorder, or delete", async () => {
+ const before = store.members.find((m) => m.userId === OWNER);
+ expect(activeOwners()).toHaveLength(1);
+
+ // The owner is a member of g-a; map it to the weakest role there is.
+ const created = await create({ groupId: "g-a", role: "member" });
+ expect(created.status).toBe(200);
+ const id = ((await created.json()) as RoleMappingBody).id;
+ expect(activeOwners()).toHaveLength(1);
+
+ expect((await reorder([id, "rm-1"])).status).toBe(200);
+ expect(activeOwners()).toHaveLength(1);
+
+ expect((await remove(id)).status).toBe(200);
+ expect(activeOwners()).toHaveLength(1);
+ // The owner's row is byte-identical throughout.
+ expect(store.members.find((m) => m.userId === OWNER)).toEqual(before);
+ });
+
+ it("never demotes: a hand-promoted admin in a member-mapped group keeps admin", async () => {
+ const row = store.members.find((m) => m.userId === MEMBER2);
+ if (row) row.role = "admin";
+ const res = await create({ groupId: "g-c", role: "member" });
+ expect(res.status).toBe(200);
+ expect(await res.json()).toMatchObject({ role: "member" });
+ expect(roleOf(MEMBER2)).toBe("admin");
+ expect(memberAudits()).toHaveLength(0);
+ });
+
+ // The other face of "a mapping is a FLOOR": a hand demotion is NOT a durable
+ // undo while the mapping is still live. Pinned deliberately — the UI copy and
+ // the roadmap's decision C both have to say so.
+ it("re-raises a hand-demoted member who is still in an admin-mapped group", async () => {
+ const created = await create({ groupId: "g-a", role: "admin" });
+ expect(created.status).toBe(200);
+ const id = ((await created.json()) as RoleMappingBody).id;
+ expect(roleOf(MEMBER)).toBe("admin");
+
+ // As `PATCH /v1/org/members/:userId` would: the admin demotes them by hand.
+ const row = store.members.find((m) => m.userId === MEMBER);
+ if (row) row.role = "member";
+ store.audits = [];
+
+ // ANY later apply — here a reorder — puts the role straight back, because
+ // MEMBER is still a member of the still-mapped group.
+ expect((await reorder([id, "rm-1"])).status).toBe(200);
+ expect(roleOf(MEMBER)).toBe("admin");
+ expect(memberAudits()).toHaveLength(1);
+ expect(memberAudits()[0]).toMatchObject({
+ metadata: { targetUserId: MEMBER, role: "admin", via: "role-mapping" },
+ });
+
+ // The durable remedy: drop the mapping FIRST, then demote.
+ expect((await remove(id)).status).toBe(200);
+ if (row) row.role = "member";
+ expect((await reorder(["rm-1"])).status).toBe(200);
+ expect(roleOf(MEMBER)).toBe("member");
+ });
+
+ it("raises a SUSPENDED member too (their stored role is their reinstate shape)", async () => {
+ const res = await update("rm-1", { role: "admin" });
+ expect(res.status).toBe(200);
+ expect(roleOf(SUSPENDED)).toBe("admin");
+ expect(store.members.find((m) => m.userId === SUSPENDED)?.status).toBe(
+ "suspended",
+ );
+ expect(
+ memberAudits()
+ .map((a) => a.metadata.targetUserId)
+ .sort(),
+ ).toEqual([MEMBER2, SUSPENDED].sort());
+ });
+
+ it("keeps mapping audit metadata bounded — counts only, never id arrays", async () => {
+ const res = await create({ groupId: "g-a", role: "admin" });
+ expect(res.status).toBe(200);
+ for (const audit of mappingAudits()) {
+ expect(audit.organizationId).toBe(ORG);
+ expect(audit.source).toBe("api");
+ for (const value of Object.values(audit.metadata)) {
+ expect(Array.isArray(value)).toBe(false);
+ }
+ }
+ });
+
+ it("never touches another organization's members", async () => {
+ const res = await create({ groupId: "g-a", role: "admin" });
+ expect(res.status).toBe(200);
+ expect(
+ store.members.find((m) => m.organizationId === OTHER_ORG)?.role,
+ ).toBe("admin");
+ });
+});
diff --git a/packages/api/src/routes/org/role-mappings.ts b/packages/api/src/routes/org/role-mappings.ts
new file mode 100644
index 00000000..76ab4c2b
--- /dev/null
+++ b/packages/api/src/routes/org/role-mappings.ts
@@ -0,0 +1,200 @@
+import { Hono } from "hono";
+import type { Context } from "hono";
+import type { ApiEnv } from "../../types";
+import { auth } from "../../middleware/auth";
+import { ServiceError } from "../../services/errors";
+import { parse } from "./parse";
+import {
+ createOrgRoleMapping,
+ deleteOrgRoleMapping,
+ listOrgRoleMappings,
+ previewOrgRoleMapping,
+ reorderOrgRoleMappings,
+ updateOrgRoleMapping,
+} from "../../services/org-role-mapping-service";
+import {
+ createRoleMappingSchema,
+ previewRoleMappingSchema,
+ reorderRoleMappingsSchema,
+ updateRoleMappingSchema,
+} from "../../validations/org";
+import {
+ withAudit,
+ AUDIT_ACTIONS,
+ AUDIT_SERVICES,
+ AUDIT_SOURCE,
+} from "../../services/audit-service";
+
+/**
+ * `/v1/org/role-mappings` — group → org-role mappings.
+ *
+ * Same guard stack as `/v1/org/groups`, for the same reasons:
+ *
+ * `requireProject: false`: these are ORG-scoped routes, so a caller with no
+ * project context (an org API key without `X-Project-Id`) must still get
+ * through. `role: "admin"` makes the whole router admin-only — a plain member
+ * gets a deterministic 403, which is exactly what the web client expects
+ * (directory queries are not retried on 403).
+ *
+ * `role` alone is SCOPE-BLIND, so it is not sufficient on its own: a
+ * project-scoped key (the credential an agent carries) resolves to its owning
+ * user, and if that user happens to be an org admin the role check passes.
+ * Here that is at its sharpest — a leaked agent key would be able to rewrite
+ * WHO IS AN ORG ADMIN, minting the very authority the guard exists to
+ * protect. Org-wide authority requires an org-wide credential, so
+ * project-scoped callers are rejected outright.
+ */
+export const orgRoleMappingRoutes = () => {
+ const app = new Hono();
+ app.use("*", auth({ requireProject: false, role: "admin" }));
+ app.use("*", async (c, next) => {
+ if (c.get("auth").scope === "project") {
+ throw new ServiceError(
+ "FORBIDDEN",
+ "Organization management requires an organization-scoped credential.",
+ );
+ }
+ return next();
+ });
+
+ // `organizationId` in every audit params below is deliberate: besides
+ // scoping the audit row it flushes the gateway's org cache
+ // (invalidateGatewayCacheForOrg). A mapping write can change a member's org
+ // role, which is exactly what authorization reads — a missed flush becomes a
+ // stale authorization decision. The MEMBER audit rows the apply writes
+ // deliberately do NOT flush again; this one flush covers the request.
+ const auditBase = (c: Context) => ({
+ organizationId: c.get("auth").organizationId,
+ userId: c.get("auth").userId,
+ userEmail: c.get("auth").userEmail,
+ service: AUDIT_SERVICES.ROLE_MAPPING,
+ source: AUDIT_SOURCE.API,
+ });
+
+ // GET /org/role-mappings — the WHOLE ordered set as a BARE ARRAY (not a
+ // DirectoryPage): `groupId` is unique so the set is bounded by group count,
+ // the client types it as `RoleMappingRow[]`, and resolution needs all of it.
+ app.get("/", async (c) => {
+ const auth = c.get("auth");
+ return c.json(await listOrgRoleMappings(auth.organizationId));
+ });
+
+ // Literal paths are registered BEFORE any parameterized path of the same
+ // method (`/preview` before `/`, `/order` ahead of a future `PUT /:id`).
+ // Nothing collides by method today; keeping the order is the convention
+ // that stops the first such addition from being silently shadowed.
+
+ // POST /org/role-mappings/preview — dry run. Deliberately NOT wrapped in
+ // withAudit: it writes nothing, so auditing it would both log a phantom
+ // change and wrongly flush the gateway org cache.
+ app.post("/preview", async (c) => {
+ const auth = c.get("auth");
+ const body = await c.req.json().catch(() => null);
+ const input = parse(previewRoleMappingSchema, body);
+ return c.json(
+ await previewOrgRoleMapping(auth.organizationId, auth.userId, input),
+ );
+ });
+
+ // PUT /org/role-mappings/order — reassign priorities from the FULL ordered
+ // id set. Returns the new order as a JSON body: the client's apiPut ALWAYS
+ // parses, so a 204 here would throw in the browser.
+ app.put("/order", async (c) => {
+ const auth = c.get("auth");
+ const body = await c.req.json().catch(() => null);
+ const input = parse(reorderRoleMappingsSchema, body);
+
+ const result = await withAudit(
+ () =>
+ reorderOrgRoleMappings(
+ auth.organizationId,
+ auth.userId,
+ input.orderedIds,
+ ),
+ (r) => ({
+ ...auditBase(c),
+ action: AUDIT_ACTIONS.UPDATE,
+ // Counts only, never id arrays — audit metadata must stay bounded.
+ metadata: {
+ change: "order",
+ count: r.mappings.length,
+ rolesChanged: r.rolesChanged,
+ },
+ }),
+ );
+ return c.json(result.mappings);
+ });
+
+ // POST /org/role-mappings — create (at most one per group: 409 otherwise).
+ app.post("/", async (c) => {
+ const auth = c.get("auth");
+ const body = await c.req.json().catch(() => null);
+ const input = parse(createRoleMappingSchema, body);
+
+ const result = await withAudit(
+ () => createOrgRoleMapping(auth.organizationId, auth.userId, input),
+ (r) => ({
+ ...auditBase(c),
+ action: AUDIT_ACTIONS.CREATE,
+ metadata: {
+ mappingId: r.mapping.id,
+ groupId: r.mapping.groupId,
+ groupName: r.mapping.groupName,
+ role: r.mapping.role,
+ priority: r.mapping.priority,
+ rolesChanged: r.rolesChanged,
+ },
+ }),
+ );
+ return c.json(result.mapping);
+ });
+
+ // PATCH /org/role-mappings/:id — change the role and/or the priority.
+ app.patch("/:id", async (c) => {
+ const auth = c.get("auth");
+ const id = c.req.param("id");
+ const body = await c.req.json().catch(() => null);
+ const input = parse(updateRoleMappingSchema, body);
+
+ const result = await withAudit(
+ () => updateOrgRoleMapping(auth.organizationId, auth.userId, id, input),
+ (r) => ({
+ ...auditBase(c),
+ action: AUDIT_ACTIONS.UPDATE,
+ metadata: {
+ mappingId: r.mapping.id,
+ groupId: r.mapping.groupId,
+ change: input.priority !== undefined ? "role+priority" : "role",
+ role: r.mapping.role,
+ priority: r.mapping.priority,
+ rolesChanged: r.rolesChanged,
+ },
+ }),
+ );
+ return c.json(result.mapping);
+ });
+
+ // DELETE /org/role-mappings/:id — the response carries a JSON body like
+ // every other route here (apiDelete discards it; the error path parses).
+ app.delete("/:id", async (c) => {
+ const auth = c.get("auth");
+ const id = c.req.param("id");
+
+ const result = await withAudit(
+ () => deleteOrgRoleMapping(auth.organizationId, auth.userId, id),
+ (r) => ({
+ ...auditBase(c),
+ action: AUDIT_ACTIONS.DELETE,
+ metadata: {
+ mappingId: r.id,
+ groupId: r.groupId,
+ role: r.role,
+ rolesChanged: r.rolesChanged,
+ },
+ }),
+ );
+ return c.json(result);
+ });
+
+ return app;
+};
diff --git a/packages/api/src/services/org-group-service.ts b/packages/api/src/services/org-group-service.ts
index aa4ff9b8..f82409d5 100644
--- a/packages/api/src/services/org-group-service.ts
+++ b/packages/api/src/services/org-group-service.ts
@@ -7,6 +7,10 @@ import {
type DirectoryPage,
} from "../lib/cursor";
import type { GroupListQuery } from "../validations/org";
+import {
+ applyOrgRoleMappings,
+ applyRoleMappingsForGroup,
+} from "./org-role-mapping-service";
// The org's human-group directory: list/create/rename/delete plus the three
// membership writers. Scoped to ONE organization on every call — the caller's
@@ -42,6 +46,7 @@ export interface GroupDeleteResult {
name: string;
removedMembers: number;
removedProjectBindings: number;
+ removedRoleMappings: number;
}
export type ListOrgGroupsParams = Partial;
@@ -283,11 +288,15 @@ export const renameOrgGroup = async (
* widened to "any principal" (see grants-service). So a group delete needs no
* explicit orphan-neutralization pass here; the identity rows simply cascade
* away and the rules that referenced them lose one target. Role automation
- * (group→org-role mappings) is likewise not a live OSS concept, so there is no
- * mapping re-resolution to run. Both integrations belong to later stages.
+ * (group→org-role mappings), re-added in Stage D, IS re-resolved here: the
+ * cascade takes this group's GroupRoleMapping with it, which can unshadow a
+ * lower-priority mapping, so a mapping re-resolution runs after the delete —
+ * guarded on the selected roleMapping, org-wide, trigger "group-deleted", and
+ * raise-only, so it is never a demotion (see below).
*/
export const deleteOrgGroup = async (
organizationId: string,
+ actorUserId: string,
groupId: string,
): Promise => {
const group = await db.group.findFirst({
@@ -297,6 +306,7 @@ export const deleteOrgGroup = async (
name: true,
source: true,
_count: { select: { members: true, projectAccess: true } },
+ roleMapping: { select: { id: true } },
},
});
if (!group) throw new ServiceError("NOT_FOUND", "Group not found.");
@@ -316,11 +326,26 @@ export const deleteOrgGroup = async (
});
if (count === 0) throw new ServiceError("NOT_FOUND", "Group not found.");
+ // The cascade took this group's mapping with it, which can UNSHADOW a
+ // lower-priority mapping (e.g. the deleted group was the priority-0 `member`
+ // mapping suppressing an `admin` one), so the whole org is re-resolved —
+ // group-scoped would be wrong here, the group is gone. Guarded on the
+ // mapping the impact read already selected, so the overwhelmingly common
+ // "delete an unmapped group" path costs nothing extra. Roles are never
+ // REVERTED by a delete: nothing in this system lowers a role (decision C).
+ // The trigger is its own value: the cause is a mapping cascade, not a
+ // membership edit, and the whole point of the discriminator is letting an
+ // operator split those apart in the MEMBER audit rows.
+ if (group.roleMapping) {
+ await applyOrgRoleMappings(organizationId, actorUserId, "group-deleted");
+ }
+
return {
id: group.id,
name: group.name,
removedMembers: group._count.members,
removedProjectBindings: group._count.projectAccess,
+ removedRoleMappings: group.roleMapping ? 1 : 0,
};
};
@@ -401,6 +426,27 @@ const assertOrgMembers = async (organizationId: string, userIds: string[]) => {
}
};
+// Group→role mappings are re-resolved after every membership write (cloud
+// applies them at SSO login; OSS has no SSO, so the write IS the trigger).
+// They are a FLOOR: a mapping can only ever RAISE a member's org role (see
+// org-role-mapping-service.ts, decision C), so a removal almost never changes
+// a role — but the remove paths call the same hook anyway. Keeping the seam
+// one uniform shape is the point: an edition that flips to two-way sync would
+// otherwise silently skip the path that matters most. The cost is one indexed
+// lookup on `group_role_mappings`.
+//
+// REMOVALS ARE UNSHADOWED. The group-scoped apply resolves the group's members
+// as they are AFTER the write, so the removal paths hand it the ids they just
+// removed (`applyRoleMappingsForGroup(orgId, groupId, actorId, removedIds)`).
+// Without that a user held down by a high-priority `member` mapping on this
+// group, while a lower-priority `admin` mapping also covered them, would stay
+// under-privileged after leaving it — the same unshadow decision E handles
+// org-wide for `deleteOrgGroup`. Adders pass nothing: their ids are already in
+// the group.
+//
+// Each call happens AFTER its own write commits, never inside the
+// transaction — the apply writes member rows and audit rows of its own.
+
/**
* Replace the group's member set. Returns the honest delta; a no-delta call
* returns `{ added: 0, removed: 0 }` WITHOUT opening a transaction (and the
@@ -448,6 +494,13 @@ export const setOrgGroupMembers = async (
}),
]);
+ await applyRoleMappingsForGroup(
+ organizationId,
+ groupId,
+ actorUserId,
+ toRemove,
+ );
+
return { added: toAdd.length, removed: toRemove.length };
};
@@ -475,6 +528,8 @@ export const addOrgGroupMember = async (
update: {},
});
+ await applyRoleMappingsForGroup(organizationId, groupId, actorUserId);
+
return { added: !existing };
};
@@ -484,6 +539,7 @@ export const addOrgGroupMember = async (
*/
export const removeOrgGroupMember = async (
organizationId: string,
+ actorUserId: string,
groupId: string,
userId: string,
): Promise<{ removed: boolean }> => {
@@ -493,5 +549,9 @@ export const removeOrgGroupMember = async (
where: { groupId, userId },
});
+ await applyRoleMappingsForGroup(organizationId, groupId, actorUserId, [
+ userId,
+ ]);
+
return { removed: count > 0 };
};
diff --git a/packages/api/src/services/org-role-mapping-service.test.ts b/packages/api/src/services/org-role-mapping-service.test.ts
new file mode 100644
index 00000000..5fca0c61
--- /dev/null
+++ b/packages/api/src/services/org-role-mapping-service.test.ts
@@ -0,0 +1,254 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+// The pure resolver behind group→role mappings. No DB mock at all (the
+// org-role-resolver.test.ts / policy-target.test.ts precedent): this function
+// decides who gets promoted, so a wrong answer here is a privilege escalation
+// or a silent demotion. Every decision letter it encodes is pinned below.
+
+vi.hoisted(() => {
+ process.env.NEXT_PUBLIC_EDITION = "oss";
+});
+
+const warn = vi.hoisted(() => vi.fn());
+
+vi.mock("../lib/logger", () => ({
+ logger: { child: () => ({ warn }) },
+}));
+
+vi.mock("@onecli/db", () => ({ Prisma: {}, db: {} }));
+
+import {
+ resolveRoleMappingChanges,
+ type MappingRule,
+ type MemberState,
+ type RoleChange,
+} from "./org-role-mapping-service";
+
+const ACTOR = "user-actor";
+
+const at = (minutes: number) => new Date(Date.UTC(2026, 0, 1, 0, minutes));
+
+const rule = (
+ id: string,
+ groupId: string,
+ role: string,
+ priority: number,
+ createdAt = at(priority),
+): MappingRule => ({ id, groupId, role, priority, createdAt });
+
+const member = (userId: string, role: string): MemberState => ({
+ userId,
+ role,
+ userEmail: `${userId}@example.com`,
+});
+
+const groups = (entries: Record) =>
+ new Map(Object.entries(entries).map(([g, ids]) => [g, new Set(ids)]));
+
+const resolve = (input: {
+ mappings: MappingRule[];
+ membersByGroup: Map>;
+ candidates: MemberState[];
+ actorUserId?: string;
+}): RoleChange[] =>
+ resolveRoleMappingChanges({
+ mappings: input.mappings,
+ membersByGroup: input.membersByGroup,
+ candidates: input.candidates,
+ actorUserId: input.actorUserId ?? ACTOR,
+ });
+
+beforeEach(() => {
+ warn.mockClear();
+});
+
+describe("resolveRoleMappingChanges", () => {
+ it("returns nothing when the org has no mappings", () => {
+ expect(
+ resolve({
+ mappings: [],
+ membersByGroup: groups({ "g-a": ["u-1"] }),
+ candidates: [member("u-1", "member")],
+ }),
+ ).toEqual([]);
+ });
+
+ it("raises a member through a single admin mapping", () => {
+ const changes = resolve({
+ mappings: [rule("rm-1", "g-a", "admin", 0)],
+ membersByGroup: groups({ "g-a": ["u-1"] }),
+ candidates: [member("u-1", "member")],
+ });
+ expect(changes).toEqual([
+ {
+ userId: "u-1",
+ userEmail: "u-1@example.com",
+ from: "member",
+ to: "admin",
+ mappingId: "rm-1",
+ groupId: "g-a",
+ },
+ ]);
+ });
+
+ it("raises nobody through a member mapping (everyone is already >= member)", () => {
+ expect(
+ resolve({
+ mappings: [rule("rm-1", "g-a", "member", 0)],
+ membersByGroup: groups({ "g-a": ["u-1", "u-2"] }),
+ candidates: [member("u-1", "member"), member("u-2", "member")],
+ }),
+ ).toEqual([]);
+ });
+
+ it("lets a member mapping at priority 0 SHADOW an admin mapping at priority 1", () => {
+ // The load-bearing case: role strength plays no part in picking the
+ // winner, only explicit order does (decision A3/C5).
+ expect(
+ resolve({
+ mappings: [
+ rule("rm-shadow", "g-everyone", "member", 0),
+ rule("rm-eng", "g-eng", "admin", 1),
+ ],
+ membersByGroup: groups({
+ "g-everyone": ["u-1"],
+ "g-eng": ["u-1"],
+ }),
+ candidates: [member("u-1", "member")],
+ }),
+ ).toEqual([]);
+ });
+
+ it("...and reordering the admin mapping to priority 0 raises them", () => {
+ const changes = resolve({
+ mappings: [
+ rule("rm-eng", "g-eng", "admin", 0),
+ rule("rm-shadow", "g-everyone", "member", 1),
+ ],
+ membersByGroup: groups({ "g-everyone": ["u-1"], "g-eng": ["u-1"] }),
+ candidates: [member("u-1", "member")],
+ });
+ expect(changes).toHaveLength(1);
+ expect(changes[0]).toMatchObject({ to: "admin", mappingId: "rm-eng" });
+ });
+
+ it("breaks a priority tie by createdAt asc, then id asc", () => {
+ const older = resolve({
+ mappings: [
+ rule("rm-b", "g-b", "admin", 0, at(9)),
+ rule("rm-a", "g-a", "member", 0, at(5)),
+ ],
+ membersByGroup: groups({ "g-a": ["u-1"], "g-b": ["u-1"] }),
+ candidates: [member("u-1", "member")],
+ });
+ // rm-a is older, so its `member` wins and nothing is raised.
+ expect(older).toEqual([]);
+
+ const sameInstant = resolve({
+ mappings: [
+ rule("rm-z", "g-b", "admin", 0, at(5)),
+ rule("rm-a", "g-a", "member", 0, at(5)),
+ ],
+ membersByGroup: groups({ "g-a": ["u-1"], "g-b": ["u-1"] }),
+ candidates: [member("u-1", "member")],
+ });
+ // Same instant: id asc decides, and "rm-a" sorts first.
+ expect(sameInstant).toEqual([]);
+ });
+
+ it("NEVER demotes: an admin under a winning member mapping is untouched", () => {
+ expect(
+ resolve({
+ mappings: [rule("rm-1", "g-a", "member", 0)],
+ membersByGroup: groups({ "g-a": ["u-1"] }),
+ candidates: [member("u-1", "admin")],
+ }),
+ ).toEqual([]);
+ });
+
+ it("never touches an owner, under either kind of mapping", () => {
+ for (const role of ["admin", "member"] as const) {
+ expect(
+ resolve({
+ mappings: [rule("rm-1", "g-a", role, 0)],
+ membersByGroup: groups({ "g-a": ["u-owner"] }),
+ candidates: [member("u-owner", "owner")],
+ }),
+ ).toEqual([]);
+ }
+ });
+
+ it("skips the acting user (mirrors 'you cannot change your own role')", () => {
+ expect(
+ resolve({
+ mappings: [rule("rm-1", "g-a", "admin", 0)],
+ membersByGroup: groups({ "g-a": [ACTOR] }),
+ candidates: [member(ACTOR, "member")],
+ }),
+ ).toEqual([]);
+ });
+
+ it("skips — and warns about — a current role it does not understand", () => {
+ expect(
+ resolve({
+ mappings: [rule("rm-1", "g-a", "admin", 0)],
+ membersByGroup: groups({ "g-a": ["u-1"] }),
+ candidates: [member("u-1", "superadmin")],
+ }),
+ ).toEqual([]);
+ expect(warn).toHaveBeenCalledTimes(1);
+ });
+
+ it("skips — and warns about — an unrecognized role ON THE MAPPING", () => {
+ // Fail closed on the winner rather than falling through to the next
+ // mapping, which would grant more than the config says.
+ expect(
+ resolve({
+ mappings: [
+ rule("rm-bad", "g-a", "superadmin", 0),
+ rule("rm-ok", "g-b", "admin", 1),
+ ],
+ membersByGroup: groups({ "g-a": ["u-1"], "g-b": ["u-1"] }),
+ candidates: [member("u-1", "member")],
+ }),
+ ).toEqual([]);
+ expect(warn).toHaveBeenCalledTimes(1);
+ });
+
+ it("leaves a user who is in no mapped group alone", () => {
+ expect(
+ resolve({
+ mappings: [rule("rm-1", "g-a", "admin", 0)],
+ membersByGroup: groups({ "g-a": ["u-1"] }),
+ candidates: [member("u-1", "member"), member("u-2", "member")],
+ }).map((c) => c.userId),
+ ).toEqual(["u-1"]);
+ });
+
+ it("is idempotent: feeding the result back as current state changes nothing", () => {
+ const mappings = [
+ rule("rm-1", "g-a", "admin", 0),
+ rule("rm-2", "g-b", "member", 1),
+ ];
+ const membersByGroup = groups({
+ "g-a": ["u-1", "u-2"],
+ "g-b": ["u-2", "u-3"],
+ });
+ const candidates = [
+ member("u-1", "member"),
+ member("u-2", "member"),
+ member("u-3", "member"),
+ ];
+
+ const first = resolve({ mappings, membersByGroup, candidates });
+ expect(first.map((c) => c.userId).sort()).toEqual(["u-1", "u-2"]);
+
+ const applied = candidates.map((c) => {
+ const change = first.find((ch) => ch.userId === c.userId);
+ return change ? { ...c, role: change.to } : c;
+ });
+ expect(resolve({ mappings, membersByGroup, candidates: applied })).toEqual(
+ [],
+ );
+ });
+});
diff --git a/packages/api/src/services/org-role-mapping-service.ts b/packages/api/src/services/org-role-mapping-service.ts
new file mode 100644
index 00000000..044658b6
--- /dev/null
+++ b/packages/api/src/services/org-role-mapping-service.ts
@@ -0,0 +1,789 @@
+import { db, Prisma } from "@onecli/db";
+import { ServiceError } from "./errors";
+import { logger } from "../lib/logger";
+import { ROLE_HIERARCHY } from "../providers";
+import type { OrgRole } from "../providers";
+import {
+ recordAuditEvent,
+ AUDIT_ACTIONS,
+ AUDIT_SERVICES,
+ AUDIT_SOURCE,
+} from "./audit-service";
+import { MAX_ROLE_MAPPINGS } from "../validations/org";
+import type {
+ CreateRoleMappingInput,
+ PreviewRoleMappingInput,
+ UpdateRoleMappingInput,
+} from "../validations/org";
+
+/**
+ * Group → org-role mappings: the mapping CONFIG (list/create/update/delete/
+ * reorder/preview) plus the engine that APPLIES it to `OrganizationMember.role`.
+ * Scoped to ONE organization on every call — the caller's
+ * `auth.organizationId`, never a body parameter — so this can never read or
+ * write across orgs.
+ *
+ * WHEN IT APPLIES. In the cloud edition mappings are re-resolved at SSO login.
+ * OSS has no SSO, so the apply is driven by writes instead: (a) every
+ * membership write in `org-group-service.ts` calls
+ * `applyRoleMappingsForGroup`, and (b) every mapping-config write in
+ * `routes/org/role-mappings.ts` calls `applyOrgRoleMappings`.
+ *
+ * PRECEDENCE. `priority` is an ASCENDING rank — 0 wins. The FIRST mapping (by
+ * `priority asc, createdAt asc, id asc`) whose group contains the user
+ * decides that user's mapped role. Role strength plays NO part in choosing
+ * the winner, which is what makes ordering load-bearing: a `member` mapping at
+ * priority 0 SHADOWS an `admin` mapping at priority 3 for anyone in both
+ * groups. That is the documented way to carve an exception out of a broad
+ * grant.
+ *
+ * ── DECISION C: a mapping is a FLOOR, never a ceiling ────────────────────
+ *
+ * `OrganizationMember` has NO provenance column (`organizationId, userId,
+ * userEmail, role, status, suspendedAt, ssoExempt, createdAt`), so the system
+ * cannot tell a mapping-assigned `admin` from a hand-promoted one. Deriving
+ * provenance from the audit log was rejected: audit writes are best-effort by
+ * design (`logAuditEvent` swallows its own errors), and an authorization
+ * decision must never hang off a log that is allowed to drop rows.
+ *
+ * The apply is therefore MONOTONIC — it may only ever RAISE a role:
+ *
+ * target(U) = strongest(current(U), mappedRole(U))
+ * write iff target(U) !== current(U) // i.e. iff it is a strict raise
+ *
+ * Consequences, all deliberate:
+ * - a `member` mapping demotes nobody; its only effect is shadowing;
+ * - removing a user from an `admin`-mapped group does not demote them — the
+ * grant sticks;
+ * - a mapping is a FLOOR, so `PATCH /v1/org/members/:userId` does NOT durably
+ * undo one. Hand-demoting a user who is STILL in a live `admin`-mapped
+ * group lasts only until the next apply, which re-raises them (the apply is
+ * a `max`, and it has no way to know the demotion was deliberate). The
+ * durable remedy is to remove the user from the mapped group, or to
+ * delete/reorder the mapping — THEN demote. Route-pinned in
+ * role-mappings.test.ts ("a hand-demoted member in an admin-mapped group is
+ * re-raised on the next apply");
+ * - the org's active-owner count is structurally unable to fall as a result
+ * of a mapping: owners are skipped in the resolver AND excluded by the
+ * `role: { not: "owner" }` predicate on both the candidate read and the
+ * write, so the apply never issues a statement that touches an owner row.
+ *
+ * IF YOU EVER ADD A PROVENANCE COLUMN to `OrganizationMember` (a `roleSource`
+ * discriminator), `resolveRoleMappingChanges` below is the function to
+ * revisit — it is the only place the raise-only rule is expressed.
+ */
+
+const log = logger.child({ component: "org-role-mappings" });
+
+/** One row of the mappings list (matches the client's `RoleMappingRow`). */
+export interface RoleMappingListRow {
+ id: string;
+ groupId: string;
+ groupName: string;
+ role: string;
+ priority: number;
+ memberCount: number;
+ createdAt: string;
+ updatedAt: string;
+}
+
+/** A stored mapping, reduced to what resolution actually needs. */
+export interface MappingRule {
+ id: string;
+ groupId: string;
+ role: string;
+ priority: number;
+ createdAt: Date;
+}
+
+/** A candidate member's current state. */
+export interface MemberState {
+ userId: string;
+ role: string;
+ userEmail: string;
+}
+
+/** A single strict RAISE the apply will perform. */
+export interface RoleChange {
+ userId: string;
+ userEmail: string;
+ from: string;
+ to: string;
+ mappingId: string;
+ groupId: string;
+}
+
+/**
+ * Which seam fired the apply — carried into the MEMBER audit rows so an
+ * operator can tell "someone joined a mapped group" from "someone edited the
+ * mapping config" from "a group delete cascaded its mapping away".
+ */
+export type ApplyTrigger = "membership" | "mapping" | "group-deleted";
+
+const ROW_SELECT = {
+ id: true,
+ groupId: true,
+ role: true,
+ priority: true,
+ createdAt: true,
+ updatedAt: true,
+ group: { select: { name: true, _count: { select: { members: true } } } },
+} as const;
+
+const RULE_SELECT = {
+ id: true,
+ groupId: true,
+ role: true,
+ priority: true,
+ createdAt: true,
+} as const;
+
+/** `priority asc, createdAt asc, id asc` — the ONE resolution order. */
+const LIST_ORDER = [
+ { priority: "asc" as const },
+ { createdAt: "asc" as const },
+ { id: "asc" as const },
+];
+
+interface MappingRowShape {
+ id: string;
+ groupId: string;
+ role: string;
+ priority: number;
+ createdAt: Date;
+ updatedAt: Date;
+ group: { name: string; _count: { members: number } };
+}
+
+const toRow = (row: MappingRowShape): RoleMappingListRow => ({
+ id: row.id,
+ groupId: row.groupId,
+ groupName: row.group.name,
+ role: row.role,
+ priority: row.priority,
+ memberCount: row.group._count.members,
+ createdAt: row.createdAt.toISOString(),
+ updatedAt: row.updatedAt.toISOString(),
+});
+
+/**
+ * The list is UNPAGED, by contract: `groupId` is unique so mappings are
+ * bounded by group count, the client types the response as a bare array (not
+ * a `DirectoryPage`), and resolution needs the whole ordered set anyway.
+ */
+export const listOrgRoleMappings = async (
+ organizationId: string,
+): Promise => {
+ const rows = await db.groupRoleMapping.findMany({
+ where: { organizationId },
+ select: ROW_SELECT,
+ orderBy: LIST_ORDER,
+ });
+ return rows.map(toRow);
+};
+
+/**
+ * Resolve a mapping WITHIN the caller's org — always
+ * `findFirst({ id, organizationId })`, NEVER `findUnique({ where: { id } })`:
+ * a cross-org id must read as absent (404), not leak another org's row.
+ */
+const requireMapping = async (organizationId: string, id: string) => {
+ const mapping = await db.groupRoleMapping.findFirst({
+ where: { id, organizationId },
+ select: { id: true, groupId: true, role: true, priority: true },
+ });
+ if (!mapping) throw new ServiceError("NOT_FOUND", "Role mapping not found.");
+ return mapping;
+};
+
+/**
+ * Resolve the mapped group, org-scoped. Deliberately NOT the groups service's
+ * `requireManualGroup`: a `GroupRoleMapping` is an admin-authored OneCLI
+ * artifact, not an IdP-owned object, so mapping a `scim` group to a role is
+ * the canonical use case rather than a conflict.
+ */
+const requireGroup = async (organizationId: string, groupId: string) => {
+ const group = await db.group.findFirst({
+ where: { id: groupId, organizationId },
+ select: { id: true, name: true },
+ });
+ if (!group) throw new ServiceError("NOT_FOUND", "Group not found.");
+ return group;
+};
+
+const readRow = async (organizationId: string, id: string) => {
+ const row = await db.groupRoleMapping.findFirst({
+ where: { id, organizationId },
+ select: ROW_SELECT,
+ });
+ if (!row) throw new ServiceError("NOT_FOUND", "Role mapping not found.");
+ return toRow(row);
+};
+
+const isUniqueViolation = (err: unknown) =>
+ err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002";
+
+/**
+ * Per-org advisory lock, cloned in spirit from `policy-service.ts`'s
+ * `lockScope` (whose comment spells out why: an unlocked read-then-append can
+ * mint duplicate priorities under concurrency). Not imported from there —
+ * that helper is policy-scope-shaped.
+ */
+const lockOrgRoleMappings = (
+ tx: Prisma.TransactionClient,
+ organizationId: string,
+) =>
+ tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${`role-mappings:${organizationId}`}))`;
+
+// ── Resolution ────────────────────────────────────────────────────────────
+
+const isOrgRole = (role: string): role is OrgRole =>
+ Object.prototype.hasOwnProperty.call(ROLE_HIERARCHY, role);
+
+const byPrecedence = (a: MappingRule, b: MappingRule) =>
+ a.priority - b.priority ||
+ a.createdAt.getTime() - b.createdAt.getTime() ||
+ a.id.localeCompare(b.id);
+
+/**
+ * PURE. No DB, no clock. Given the org's mapping set, who is in which mapped
+ * group, and each candidate's current role, return the strict RAISES to
+ * apply. Preview and apply both call this — which is what makes the preview
+ * structurally incapable of lying about what the apply will do.
+ *
+ * `O(candidates × mappings)` with set lookups. Mappings are bounded by group
+ * count on a single-instance OSS surface, so no index gymnastics are needed.
+ */
+export const resolveRoleMappingChanges = (input: {
+ mappings: readonly MappingRule[];
+ /** groupId → userIds, restricted to mapped groups. */
+ membersByGroup: ReadonlyMap>;
+ candidates: readonly MemberState[];
+ actorUserId: string;
+}): RoleChange[] => {
+ const { mappings, membersByGroup, candidates, actorUserId } = input;
+ if (mappings.length === 0) return [];
+
+ // Defensive: the loaders already order by this, but resolution must never
+ // depend on the caller having done so.
+ const ordered = [...mappings].sort(byPrecedence);
+ const changes: RoleChange[] = [];
+
+ for (const candidate of candidates) {
+ // C2 — the acting user is skipped, mirroring `updateOrgMemberRole`'s "you
+ // cannot change your own role". Under raise-only this can only ever fail
+ // to promote the actor (fail-closed); anyone else's apply converges them.
+ if (candidate.userId === actorUserId) continue;
+ // C1 — owners are untouchable, so the org can never lose its last owner.
+ if (candidate.role === "owner") continue;
+ // C3 — never overwrite a role the system does not understand (the same
+ // fail-closed stance `ossRoleResolver` takes for garbage role strings).
+ if (!isOrgRole(candidate.role)) {
+ log.warn(
+ { userId: candidate.userId, role: candidate.role },
+ "unrecognized organization member role — skipping role mapping",
+ );
+ continue;
+ }
+
+ const winner = ordered.find(
+ (mapping) =>
+ membersByGroup.get(mapping.groupId)?.has(candidate.userId) ?? false,
+ );
+ // Unmapped: the apply never touches this user.
+ if (!winner) continue;
+ if (!isOrgRole(winner.role)) {
+ log.warn(
+ { mappingId: winner.id, role: winner.role },
+ "unrecognized role on a group role mapping — skipping",
+ );
+ continue;
+ }
+
+ // THE raise-only rule (decision C): write only on a strict raise.
+ if (ROLE_HIERARCHY[winner.role] <= ROLE_HIERARCHY[candidate.role]) continue;
+
+ changes.push({
+ userId: candidate.userId,
+ userEmail: candidate.userEmail,
+ from: candidate.role,
+ to: winner.role,
+ mappingId: winner.id,
+ groupId: winner.groupId,
+ });
+ }
+
+ return changes;
+};
+
+// ── Applying ──────────────────────────────────────────────────────────────
+
+type ApplyScope =
+ | { kind: "org" }
+ | {
+ kind: "group";
+ groupId: string;
+ /** Ids the writer just REMOVED from the group (see below). */
+ extraUserIds: readonly string[];
+ };
+
+const loadMappingRules = (organizationId: string): Promise =>
+ db.groupRoleMapping.findMany({
+ where: { organizationId },
+ select: RULE_SELECT,
+ orderBy: LIST_ORDER,
+ });
+
+/**
+ * Load the candidate members and the mapped groups' membership.
+ * `scopedUserIds` narrows both reads to one group's members; `undefined`
+ * means "every member of the org".
+ */
+const loadResolverState = async (
+ organizationId: string,
+ mappings: readonly MappingRule[],
+ scopedUserIds: string[] | undefined,
+) => {
+ const scoped = scopedUserIds ? { userId: { in: scopedUserIds } } : {};
+
+ // `role: { not: "owner" }` is decision C1 at the query layer; the resolver
+ // repeats the skip so it stays correct in isolation (and under unit test).
+ const candidates = await db.organizationMember.findMany({
+ where: { organizationId, role: { not: "owner" }, ...scoped },
+ select: { userId: true, role: true, userEmail: true },
+ });
+
+ const mappedGroupIds = [...new Set(mappings.map((m) => m.groupId))];
+ const memberships = await db.groupMember.findMany({
+ where: { groupId: { in: mappedGroupIds }, ...scoped },
+ select: { groupId: true, userId: true },
+ });
+
+ const membersByGroup = new Map>();
+ for (const row of memberships) {
+ const set = membersByGroup.get(row.groupId);
+ if (set) set.add(row.userId);
+ else membersByGroup.set(row.groupId, new Set([row.userId]));
+ }
+
+ return { candidates, membersByGroup };
+};
+
+/**
+ * The actor's email for the MEMBER audit rows (`AuditEventParams.userEmail` is
+ * required). Resolved lazily — only on the path that already writes N rows.
+ */
+const resolveActorEmail = async (
+ organizationId: string,
+ actorUserId: string,
+): Promise => {
+ const member = await db.organizationMember.findUnique({
+ where: { organizationId_userId: { organizationId, userId: actorUserId } },
+ select: { userEmail: true },
+ });
+ if (member?.userEmail) return member.userEmail;
+ const user = await db.user.findUnique({
+ where: { id: actorUserId },
+ select: { email: true },
+ });
+ return user?.email ?? "";
+};
+
+const applyRoleMappings = async (
+ organizationId: string,
+ actorUserId: string,
+ scope: ApplyScope,
+ trigger: ApplyTrigger,
+): Promise<{ changed: number }> => {
+ const mappings = await loadMappingRules(organizationId);
+ // Short-circuit before touching membership: an org with no mappings has
+ // nothing to resolve.
+ if (mappings.length === 0) return { changed: 0 };
+
+ let scopedUserIds: string[] | undefined;
+ if (scope.kind === "group") {
+ // Adding to (or removing from) an UNMAPPED group cannot change anybody's
+ // mapped set — the winner is chosen only among mapped groups.
+ if (!mappings.some((m) => m.groupId === scope.groupId))
+ return { changed: 0 };
+ const rows = await db.groupMember.findMany({
+ where: { groupId: scope.groupId },
+ select: { userId: true },
+ });
+ // The candidate set is this group's members AFTER the write, UNIONED with
+ // the ids the writer just removed. The union is what makes the removal
+ // paths converge: a user dropped from a high-priority `member` group that
+ // was SHADOWING a lower-priority `admin` mapping must be re-resolved
+ // against the mappings that still cover them (the unshadow case decision E
+ // handles org-wide for `deleteOrgGroup`), and the post-write read can no
+ // longer see them. The membership load below is filtered on the same id
+ // list, so a removed user simply has no row for this group and falls
+ // through to the next mapping — the resolver stays exactly as correct.
+ scopedUserIds = [
+ ...new Set([...rows.map((row) => row.userId), ...scope.extraUserIds]),
+ ];
+ // Nothing to resolve only when the union is empty — removing the LAST
+ // member of a mapped group still has that member as a candidate.
+ if (scopedUserIds.length === 0) return { changed: 0 };
+ }
+
+ const { candidates, membersByGroup } = await loadResolverState(
+ organizationId,
+ mappings,
+ scopedUserIds,
+ );
+
+ const changes = resolveRoleMappingChanges({
+ mappings,
+ membersByGroup,
+ candidates,
+ actorUserId,
+ });
+ // Idempotence made observable: `strongest(current, mapped)` is a max over a
+ // total order, so a converged org produces an empty change set — and we
+ // return BEFORE opening any transaction (the `setOrgGroupMembers` no-delta
+ // precedent), writing zero rows and zero audit events.
+ if (changes.length === 0) return { changed: 0 };
+
+ const byRole = new Map();
+ for (const change of changes) {
+ const ids = byRole.get(change.to);
+ if (ids) ids.push(change.userId);
+ else byRole.set(change.to, [change.userId]);
+ }
+
+ await db.$transaction(
+ [...byRole].map(([role, userIds]) =>
+ db.organizationMember.updateMany({
+ // `role: { not: "owner" }` again: the last-owner invariant enforced at
+ // the WRITE layer, so even a resolver bug cannot lower an owner.
+ where: {
+ organizationId,
+ userId: { in: userIds },
+ role: { not: "owner" },
+ },
+ data: { role },
+ }),
+ ),
+ );
+
+ // Audits are written AFTER the transaction commits, never inside it: a
+ // swallowed audit error must not roll back a role change. `recordAuditEvent`
+ // deliberately does NOT flush the gateway cache — every caller of the apply
+ // is already inside a `withAudit` carrying `organizationId`, which flushes
+ // once at the end of the request. Do not "fix" this into N flushes.
+ const actorEmail = await resolveActorEmail(organizationId, actorUserId);
+ for (const change of changes) {
+ await recordAuditEvent({
+ organizationId,
+ userId: actorUserId,
+ userEmail: actorEmail,
+ action: AUDIT_ACTIONS.UPDATE,
+ service: AUDIT_SERVICES.MEMBER,
+ source: AUDIT_SOURCE.API,
+ metadata: {
+ targetUserId: change.userId,
+ role: change.to,
+ previousRole: change.from,
+ // THE discriminator: what lets an operator answer "why is this person
+ // suddenly an admin?" from the audit log alone (the `via:
+ // "invitation"` precedent in audit-service.ts).
+ via: "role-mapping",
+ mappingId: change.mappingId,
+ groupId: change.groupId,
+ trigger,
+ },
+ });
+ }
+
+ return { changed: changes.length };
+};
+
+/**
+ * Re-resolve mapped org roles after a change to ONE group's membership.
+ * Called by the three membership writers in `org-group-service.ts`.
+ *
+ * `extraUserIds` are the ids the caller just REMOVED from the group: they are
+ * gone from the group by the time this runs, so the caller is the only thing
+ * that still knows they need re-resolving. Adders pass nothing.
+ */
+export const applyRoleMappingsForGroup = (
+ organizationId: string,
+ groupId: string,
+ actorUserId: string,
+ extraUserIds: readonly string[] = [],
+): Promise<{ changed: number }> =>
+ applyRoleMappings(
+ organizationId,
+ actorUserId,
+ { kind: "group", groupId, extraUserIds },
+ "membership",
+ );
+
+/**
+ * Re-resolve every member's mapped role. Called by the mapping-config routes
+ * (a create/update/reorder/delete can change who wins for any group) and by
+ * `deleteOrgGroup`, whose cascade can UNSHADOW a lower-priority mapping.
+ */
+export const applyOrgRoleMappings = (
+ organizationId: string,
+ actorUserId: string,
+ trigger: ApplyTrigger = "mapping",
+): Promise<{ changed: number }> =>
+ applyRoleMappings(organizationId, actorUserId, { kind: "org" }, trigger);
+
+// ── CRUD ──────────────────────────────────────────────────────────────────
+
+export const createOrgRoleMapping = async (
+ organizationId: string,
+ actorUserId: string,
+ input: CreateRoleMappingInput,
+): Promise<{ mapping: RoleMappingListRow; rolesChanged: number }> => {
+ await requireGroup(organizationId, input.groupId);
+
+ // `groupId` is UNIQUE — at most one mapping per group. Friendly pre-check
+ // for the common case; the P2002 catch below covers the create-create race
+ // the pre-check cannot see (the `createOrgGroup` shape).
+ const dupe = await db.groupRoleMapping.findFirst({
+ where: { groupId: input.groupId },
+ select: { id: true },
+ });
+ if (dupe) {
+ throw new ServiceError(
+ "CONFLICT",
+ "This group already has a role mapping.",
+ );
+ }
+
+ let createdId: string;
+ try {
+ const created = await db.$transaction(async (tx) => {
+ await lockOrgRoleMappings(tx, organizationId);
+ // The set ceiling, checked under the same lock as the append so a race
+ // cannot slip past it. This is what keeps `PUT /order` reachable: the
+ // reorder body must be able to name every mapping at once
+ // (MAX_ROLE_MAPPINGS in validations/org.ts spells out the invariant).
+ const total = await tx.groupRoleMapping.count({
+ where: { organizationId },
+ });
+ if (total >= MAX_ROLE_MAPPINGS) {
+ throw new ServiceError(
+ "CONFLICT",
+ `An organization can have at most ${MAX_ROLE_MAPPINGS} role mappings.`,
+ );
+ }
+ // Append at max+1 (0 for the first mapping). Priorities are a RELATIVE
+ // rank and need not be dense — a cascade delete leaves holes, and
+ // resolution only ever compares them.
+ const agg = await tx.groupRoleMapping.aggregate({
+ where: { organizationId },
+ _max: { priority: true },
+ });
+ const priority = input.priority ?? (agg._max.priority ?? -1) + 1;
+ return tx.groupRoleMapping.create({
+ data: {
+ organizationId,
+ groupId: input.groupId,
+ role: input.role,
+ priority,
+ },
+ select: { id: true },
+ });
+ });
+ createdId = created.id;
+ } catch (err) {
+ if (isUniqueViolation(err)) {
+ throw new ServiceError(
+ "CONFLICT",
+ "This group already has a role mapping.",
+ );
+ }
+ throw err;
+ }
+
+ // Outside the transaction: the apply writes member rows and audit rows, and
+ // must not hold the mapping lock while it does.
+ const { changed } = await applyOrgRoleMappings(organizationId, actorUserId);
+ return {
+ mapping: await readRow(organizationId, createdId),
+ rolesChanged: changed,
+ };
+};
+
+export const updateOrgRoleMapping = async (
+ organizationId: string,
+ actorUserId: string,
+ id: string,
+ input: UpdateRoleMappingInput,
+): Promise<{ mapping: RoleMappingListRow; rolesChanged: number }> => {
+ await requireMapping(organizationId, id);
+
+ // Org-scoped conditional write (the `renameOrgGroup` pattern): a count of 0
+ // means the row vanished between the check and the write — a 404, not the
+ // P2025 500 a bare `update()` would surface.
+ const { count } = await db.groupRoleMapping.updateMany({
+ where: { id, organizationId },
+ data: {
+ role: input.role,
+ ...(input.priority !== undefined ? { priority: input.priority } : {}),
+ },
+ });
+ if (count === 0)
+ throw new ServiceError("NOT_FOUND", "Role mapping not found.");
+
+ const { changed } = await applyOrgRoleMappings(organizationId, actorUserId);
+ return { mapping: await readRow(organizationId, id), rolesChanged: changed };
+};
+
+export const deleteOrgRoleMapping = async (
+ organizationId: string,
+ actorUserId: string,
+ id: string,
+): Promise<{
+ id: string;
+ groupId: string;
+ role: string;
+ rolesChanged: number;
+}> => {
+ const mapping = await requireMapping(organizationId, id);
+
+ const { count } = await db.groupRoleMapping.deleteMany({
+ where: { id, organizationId },
+ });
+ if (count === 0)
+ throw new ServiceError("NOT_FOUND", "Role mapping not found.");
+
+ // The UNSHADOW case: removing a high-priority `member` mapping can promote
+ // everyone it was suppressing, so the delete re-resolves the whole org.
+ const { changed } = await applyOrgRoleMappings(organizationId, actorUserId);
+ return {
+ id: mapping.id,
+ groupId: mapping.groupId,
+ role: mapping.role,
+ rolesChanged: changed,
+ };
+};
+
+export const reorderOrgRoleMappings = async (
+ organizationId: string,
+ actorUserId: string,
+ orderedIds: string[],
+): Promise<{ mappings: RoleMappingListRow[]; rolesChanged: number }> => {
+ try {
+ await db.$transaction(async (tx) => {
+ // Validate + write under the same per-org lock `create` takes, so a
+ // reorder cannot interleave with a concurrent append.
+ await lockOrgRoleMappings(tx, organizationId);
+ const current = await tx.groupRoleMapping.findMany({
+ where: { organizationId },
+ select: { id: true, priority: true },
+ orderBy: LIST_ORDER,
+ });
+ const ids = new Set(current.map((row) => row.id));
+ const namesEveryMappingOnce =
+ orderedIds.length === ids.size &&
+ new Set(orderedIds).size === orderedIds.length &&
+ orderedIds.every((id) => ids.has(id));
+ if (!namesEveryMappingOnce) {
+ throw new ServiceError(
+ "CONFLICT",
+ "Role mapping set changed — refresh and try again.",
+ );
+ }
+
+ // No-delta early return (the `setOrgGroupMembers` convention): the
+ // stored order already IS the requested one and priorities are already
+ // dense `0..n-1`, so there is nothing to write.
+ const settled = current.every(
+ (row, i) => row.id === orderedIds[i] && row.priority === i,
+ );
+ if (settled) return;
+
+ // Ascending, 0-based: index 0 → priority 0 (highest precedence).
+ for (const [i, id] of orderedIds.entries()) {
+ await tx.groupRoleMapping.update({
+ where: { id },
+ data: { priority: i },
+ });
+ }
+ });
+ } catch (err) {
+ // A delete committed between the in-tx read and an update (deletes don't
+ // take the lock) surfaces as P2025 — same staleness, same 409.
+ if (
+ err instanceof Prisma.PrismaClientKnownRequestError &&
+ err.code === "P2025"
+ ) {
+ throw new ServiceError(
+ "CONFLICT",
+ "Role mapping set changed — refresh and try again.",
+ );
+ }
+ throw err;
+ }
+
+ // Runs even on the settled path: it is the only thing that converges an org
+ // whose mapping set was edited out of band, and it is a no-op when nothing
+ // changed.
+ const { changed } = await applyOrgRoleMappings(organizationId, actorUserId);
+ return {
+ mappings: await listOrgRoleMappings(organizationId),
+ rolesChanged: changed,
+ };
+};
+
+/**
+ * Dry run for the create/edit dialog: how many members the proposed mapping
+ * would RAISE. Zero writes, zero audit rows — it is a POST only because the
+ * client made it one.
+ *
+ * Because the count only ever counts raises, a `member` mapping (and an
+ * `admin → member` edit) previews as `0`. That is truthful: such a mapping's
+ * effect is shadowing, not demotion.
+ */
+export const previewOrgRoleMapping = async (
+ organizationId: string,
+ actorUserId: string,
+ input: PreviewRoleMappingInput,
+): Promise<{ affectedCount: number }> => {
+ // Org-scoped: a preview against a foreign group must not leak its existence.
+ await requireGroup(organizationId, input.groupId);
+
+ const stored = await loadMappingRules(organizationId);
+ const existing = stored.find((m) => m.groupId === input.groupId);
+ const proposed: MappingRule[] = existing
+ ? // An existing mapping keeps its SLOT — only the role is proposed, so a
+ // shadowed group correctly previews as 0.
+ stored.map((m) =>
+ m.groupId === input.groupId ? { ...m, role: input.role } : m,
+ )
+ : [
+ ...stored,
+ {
+ id: "preview",
+ groupId: input.groupId,
+ role: input.role,
+ // Mirrors create's default, so the preview matches what the create
+ // button will actually do.
+ priority:
+ stored.reduce((max, m) => Math.max(max, m.priority), -1) + 1,
+ createdAt: new Date(),
+ },
+ ];
+
+ const { candidates, membersByGroup } = await loadResolverState(
+ organizationId,
+ proposed,
+ undefined,
+ );
+
+ // Same resolver, same actor: the preview inherits the owner skip, the self
+ // skip and the raise-only rule, and therefore cannot over-promise.
+ const changes = resolveRoleMappingChanges({
+ mappings: proposed,
+ membersByGroup,
+ candidates,
+ actorUserId,
+ });
+ return { affectedCount: changes.length };
+};
diff --git a/packages/api/src/services/organization-service.test.ts b/packages/api/src/services/organization-service.test.ts
index e93a98b4..5d3e73b7 100644
--- a/packages/api/src/services/organization-service.test.ts
+++ b/packages/api/src/services/organization-service.test.ts
@@ -14,6 +14,8 @@ interface MemberRow {
userId: string;
userEmail: string;
role: string;
+ /** Absent on the provisioning writes; Prisma defaults it to "active". */
+ status?: string;
}
interface ProjectRow {
id: string;
@@ -24,6 +26,17 @@ interface ProjectRow {
createdByUserEmail: string | null;
seq: number;
}
+interface BindingRow {
+ projectId: string;
+ /** Exactly one of userId/groupId, as the DB CHECK requires. */
+ userId?: string;
+ groupId?: string;
+ role: string;
+}
+interface GroupMemberRow {
+ groupId: string;
+ userId: string;
+}
interface ApiKeyRow {
key: string;
userId: string;
@@ -36,99 +49,208 @@ const store = vi.hoisted(() => ({
orgs: [] as OrgRow[],
members: [] as MemberRow[],
projects: [] as ProjectRow[],
+ bindings: [] as BindingRow[],
+ groupMembers: [] as GroupMemberRow[],
apiKeys: [] as ApiKeyRow[],
seq: 0,
}));
-vi.mock("@onecli/db", () => ({
- db: {
- organization: {
- findUnique: async ({ where: { slug } }: { where: { slug: string } }) =>
- store.orgs.find((o) => o.slug === slug) ?? null,
- findUniqueOrThrow: async ({
- where: { slug },
- }: {
- where: { slug: string };
- }) => {
- const org = store.orgs.find((o) => o.slug === slug);
- if (!org) throw new Error(`org ${slug} not found`);
- return org;
- },
- create: async ({ data }: { data: OrgRow }) => {
- if (store.orgs.some((o) => o.slug === data.slug)) {
- throw new Error("unique constraint: organization.slug");
- }
- const org: OrgRow = { id: data.id, slug: data.slug, name: data.name };
- store.orgs.push(org);
- return org;
- },
- },
- organizationMember: {
- upsert: async ({
- where: { organizationId_userId },
- create,
- }: {
- where: {
- organizationId_userId: { organizationId: string; userId: string };
- };
- create: MemberRow;
- }) => {
- const existing = store.members.find(
- (m) =>
- m.organizationId === organizationId_userId.organizationId &&
- m.userId === organizationId_userId.userId,
+vi.mock("@onecli/db", () => {
+ /** The subset of the Prisma project `where` shapes these helpers build. */
+ interface BindingClause {
+ userId?: string;
+ group?: { members: { some: { userId: string } } };
+ }
+ interface ProjectWhere {
+ id?: { not: string };
+ organizationId?: string;
+ createdByUserId?: string;
+ organization?: {
+ members: { some: { userId: string; status?: { not?: string } } };
+ };
+ accessBindings?: { some: { OR: BindingClause[] } };
+ OR?: ProjectWhere[];
+ }
+
+ /** A binding on `projectId` satisfying any clause — direct or via a group. */
+ const matchesBinding = (projectId: string, clauses: BindingClause[]) =>
+ clauses.some((clause) => {
+ if (clause.userId !== undefined) {
+ return store.bindings.some(
+ (b) => b.projectId === projectId && b.userId === clause.userId,
);
- if (existing) return existing;
- store.members.push(create);
- return create;
+ }
+ const userId = clause.group?.members.some.userId;
+ if (userId === undefined) return false;
+ return store.bindings.some(
+ (b) =>
+ b.projectId === projectId &&
+ b.groupId !== undefined &&
+ store.groupMembers.some(
+ (gm) => gm.groupId === b.groupId && gm.userId === userId,
+ ),
+ );
+ });
+
+ const matchesProject = (p: ProjectRow, where: ProjectWhere): boolean => {
+ if (where.id?.not !== undefined && p.id === where.id.not) return false;
+ if (
+ where.organizationId !== undefined &&
+ p.organizationId !== where.organizationId
+ )
+ return false;
+ if (
+ where.createdByUserId !== undefined &&
+ p.createdByUserId !== where.createdByUserId
+ )
+ return false;
+ if (where.organization) {
+ const { userId, status } = where.organization.members.some;
+ const membership = store.members.find(
+ (m) => m.organizationId === p.organizationId && m.userId === userId,
+ );
+ if (!membership) return false;
+ if (
+ status?.not !== undefined &&
+ (membership.status ?? "active") === status.not
+ )
+ return false;
+ }
+ if (
+ where.accessBindings &&
+ !matchesBinding(p.id, where.accessBindings.some.OR)
+ )
+ return false;
+ if (where.OR && !where.OR.some((sub) => matchesProject(p, sub)))
+ return false;
+ return true;
+ };
+
+ return {
+ db: {
+ organization: {
+ findUnique: async ({ where: { slug } }: { where: { slug: string } }) =>
+ store.orgs.find((o) => o.slug === slug) ?? null,
+ findUniqueOrThrow: async ({
+ where: { slug },
+ }: {
+ where: { slug: string };
+ }) => {
+ const org = store.orgs.find((o) => o.slug === slug);
+ if (!org) throw new Error(`org ${slug} not found`);
+ return org;
+ },
+ create: async ({ data }: { data: OrgRow }) => {
+ if (store.orgs.some((o) => o.slug === data.slug)) {
+ throw new Error("unique constraint: organization.slug");
+ }
+ const org: OrgRow = { id: data.id, slug: data.slug, name: data.name };
+ store.orgs.push(org);
+ return org;
+ },
},
- },
- project: {
- findFirst: async ({
- where: { organizationId, createdByUserId },
- }: {
- where: { organizationId: string; createdByUserId: string };
- }) =>
- store.projects
- .filter(
- (p) =>
- p.organizationId === organizationId &&
- p.createdByUserId === createdByUserId,
- )
- .sort((a, b) => a.seq - b.seq)[0] ?? null,
- create: async ({ data }: { data: Omit }) => {
- if (
- store.projects.some(
- (p) =>
- p.organizationId === data.organizationId && p.slug === data.slug,
- )
- ) {
- throw new Error("unique constraint: (organizationId, slug)");
- }
- const project: ProjectRow = { ...data, seq: store.seq++ };
- store.projects.push(project);
- return project;
+ organizationMember: {
+ upsert: async ({
+ where: { organizationId_userId },
+ create,
+ }: {
+ where: {
+ organizationId_userId: { organizationId: string; userId: string };
+ };
+ create: MemberRow;
+ }) => {
+ const existing = store.members.find(
+ (m) =>
+ m.organizationId === organizationId_userId.organizationId &&
+ m.userId === organizationId_userId.userId,
+ );
+ if (existing) return existing;
+ store.members.push(create);
+ return create;
+ },
+ findFirst: async ({
+ where,
+ }: {
+ where: { userId: string; status?: { not?: string } };
+ }) =>
+ store.members.find(
+ (m) =>
+ m.userId === where.userId &&
+ !(
+ where.status?.not !== undefined &&
+ (m.status ?? "active") === where.status.not
+ ),
+ ) ?? null,
},
- },
- apiKey: {
- findFirst: async ({
- where: { organizationId, scope },
- }: {
- where: { organizationId: string; scope: string };
- }) =>
- store.apiKeys.find(
- (k) => k.organizationId === organizationId && k.scope === scope,
- ) ?? null,
- create: async ({ data }: { data: ApiKeyRow }) => {
- if (store.apiKeys.some((k) => k.key === data.key)) {
- throw new Error("unique constraint: api_key.key");
- }
- store.apiKeys.push(data);
- return data;
+ project: {
+ findFirst: async ({
+ where,
+ select,
+ }: {
+ where: ProjectWhere;
+ select?: { id?: boolean; organizationId?: boolean };
+ }) => {
+ const row =
+ store.projects
+ .filter((p) => matchesProject(p, where))
+ .sort((a, b) => a.seq - b.seq)[0] ?? null;
+ // Honour Prisma's `select` so callers see the same narrow shape they
+ // asked for (these helpers only ever select id + organizationId).
+ if (!row || !select) return row;
+ const picked: Record = {};
+ if (select.id) picked.id = row.id;
+ if (select.organizationId) picked.organizationId = row.organizationId;
+ return picked;
+ },
+ create: async ({
+ data,
+ }: {
+ data: Omit & {
+ accessBindings?: { create: { userId: string; role: string } };
+ };
+ }) => {
+ if (
+ store.projects.some(
+ (p) =>
+ p.organizationId === data.organizationId &&
+ p.slug === data.slug,
+ )
+ ) {
+ throw new Error("unique constraint: (organizationId, slug)");
+ }
+ const project: ProjectRow = { ...data, seq: store.seq++ };
+ store.projects.push(project);
+ // Materialize the nested binding write so the binding-fallback arm of
+ // findUserDefaultProject has something real to find.
+ if (data.accessBindings) {
+ store.bindings.push({
+ projectId: data.id,
+ ...data.accessBindings.create,
+ });
+ }
+ return project;
+ },
+ },
+ apiKey: {
+ findFirst: async ({
+ where: { organizationId, scope },
+ }: {
+ where: { organizationId: string; scope: string };
+ }) =>
+ store.apiKeys.find(
+ (k) => k.organizationId === organizationId && k.scope === scope,
+ ) ?? null,
+ create: async ({ data }: { data: ApiKeyRow }) => {
+ if (store.apiKeys.some((k) => k.key === data.key)) {
+ throw new Error("unique constraint: api_key.key");
+ }
+ store.apiKeys.push(data);
+ return data;
+ },
},
},
- },
-}));
+ };
+});
vi.mock("../lib/logger", () => ({
logger: { warn: () => {}, info: () => {}, error: () => {} },
@@ -136,6 +258,7 @@ vi.mock("../lib/logger", () => ({
import {
joinSharedOrganization,
+ hasResolvableProjectExcluding,
SHARED_ORG_SLUG,
} from "./organization-service";
@@ -143,12 +266,28 @@ beforeEach(() => {
store.orgs = [];
store.members = [];
store.projects = [];
+ store.bindings = [];
+ store.groupMembers = [];
store.apiKeys = [];
store.seq = 0;
delete process.env.ONECLI_ORG_API_KEY;
delete process.env.ONECLI_ORG_API_KEY_FILE;
});
+const ORG = "org-host";
+const HOST = "user-host";
+const GUEST = "user-guest";
+
+const seedOrgWithMember = (userId: string, role = "member") => {
+ store.orgs.push({ id: ORG, slug: "host", name: "Host" });
+ store.members.push({
+ organizationId: ORG,
+ userId,
+ userEmail: `${userId}@example.com`,
+ role,
+ });
+};
+
describe("joinSharedOrganization", () => {
it("creates the one shared org and a project for the first user", async () => {
const { organization, project } = await joinSharedOrganization(
@@ -257,3 +396,119 @@ describe("bootstrap org API key (via joinSharedOrganization)", () => {
).rejects.toThrow(/ONECLI_ORG_API_KEY/);
});
});
+
+// `hasResolvableProjectExcluding` is `deleteProject`'s lockout oracle, and it
+// must answer exactly what `findUserDefaultProject` would find once the named
+// project is gone. Every arm below has a twin above; drift between the two is a
+// lockout (the user resolves no project and gets a 401 on every request).
+
+describe("hasResolvableProjectExcluding", () => {
+ const seedProject = (
+ id: string,
+ createdByUserId: string | null,
+ organizationId = ORG,
+ ) => {
+ store.projects.push({
+ id,
+ name: "Default",
+ slug: id,
+ organizationId,
+ createdByUserId,
+ createdByUserEmail: null,
+ seq: store.seq++,
+ });
+ };
+
+ it("is false when the excluded project is the user's only one", async () => {
+ seedOrgWithMember(GUEST);
+ seedProject("proj-only", GUEST);
+ store.bindings.push({
+ projectId: "proj-only",
+ userId: GUEST,
+ role: "owner",
+ });
+
+ await expect(
+ hasResolvableProjectExcluding(GUEST, "proj-only"),
+ ).resolves.toBe(false);
+ });
+
+ it("is true through a project they CREATED (arm 1)", async () => {
+ seedOrgWithMember(GUEST);
+ seedProject("proj-a", GUEST);
+ seedProject("proj-b", GUEST);
+
+ await expect(hasResolvableProjectExcluding(GUEST, "proj-a")).resolves.toBe(
+ true,
+ );
+ });
+
+ it("is true through a DIRECT binding on another project (arm 2)", async () => {
+ seedOrgWithMember(HOST, "owner");
+ store.members.push({
+ organizationId: ORG,
+ userId: GUEST,
+ userEmail: "guest@example.com",
+ role: "member",
+ });
+ seedProject("proj-a", HOST);
+ seedProject("proj-b", HOST);
+ store.bindings.push({ projectId: "proj-a", userId: GUEST, role: "member" });
+ store.bindings.push({ projectId: "proj-b", userId: GUEST, role: "member" });
+
+ await expect(hasResolvableProjectExcluding(GUEST, "proj-a")).resolves.toBe(
+ true,
+ );
+ });
+
+ it("is true through a GROUP binding on another project (arm 2)", async () => {
+ seedOrgWithMember(HOST, "owner");
+ store.members.push({
+ organizationId: ORG,
+ userId: GUEST,
+ userEmail: "guest@example.com",
+ role: "member",
+ });
+ seedProject("proj-a", HOST);
+ seedProject("proj-b", HOST);
+ store.bindings.push({ projectId: "proj-a", userId: GUEST, role: "member" });
+ store.bindings.push({
+ projectId: "proj-b",
+ groupId: "g-1",
+ role: "member",
+ });
+ store.groupMembers.push({ groupId: "g-1", userId: GUEST });
+
+ await expect(hasResolvableProjectExcluding(GUEST, "proj-a")).resolves.toBe(
+ true,
+ );
+ // ...and the group path is the ONLY one left, so removing it flips it.
+ store.groupMembers = [];
+ await expect(hasResolvableProjectExcluding(GUEST, "proj-a")).resolves.toBe(
+ false,
+ );
+ });
+
+ it("ignores a project in an org the user is only SUSPENDED in", async () => {
+ seedOrgWithMember(GUEST);
+ const suspended = store.members.find((m) => m.userId === GUEST);
+ if (suspended) suspended.status = "suspended";
+ seedProject("proj-a", GUEST);
+ seedProject("proj-b", GUEST);
+
+ await expect(hasResolvableProjectExcluding(GUEST, "proj-a")).resolves.toBe(
+ false,
+ );
+ });
+
+ it("ignores projects of an org the user does not belong to", async () => {
+ seedOrgWithMember(GUEST);
+ seedProject("proj-a", GUEST);
+ store.orgs.push({ id: "org-other", slug: "other", name: "Other" });
+ seedProject("proj-foreign", GUEST, "org-other");
+
+ await expect(hasResolvableProjectExcluding(GUEST, "proj-a")).resolves.toBe(
+ false,
+ );
+ });
+});
diff --git a/packages/api/src/services/organization-service.ts b/packages/api/src/services/organization-service.ts
index ce43d0df..5df079c8 100644
--- a/packages/api/src/services/organization-service.ts
+++ b/packages/api/src/services/organization-service.ts
@@ -86,6 +86,50 @@ export const findUserDefaultProject = async (
});
};
+/**
+ * Whether `userId` would still resolve SOME project if `excludeProjectId`
+ * disappeared — the delete guard's lockout oracle
+ * (`deleteProject`, project-service).
+ *
+ * THESE TWO MUST AGREE: this is exactly `findUserDefaultProject`'s disjunction
+ * (created-by-them, OR bound directly / through a group), fenced to orgs the
+ * user is an ACTIVE member of, minus the project about to be deleted. It lives
+ * here rather than in project-service precisely so the two predicates stay in
+ * sync — drift between them is a lockout: a user whose last project is deleted
+ * resolves no project at all, and session auth then 401s them everywhere.
+ *
+ * Both arms are folded into one `OR` (unlike the ordered two-query fallback
+ * above) because only existence matters here, never which project wins.
+ */
+export const hasResolvableProjectExcluding = async (
+ userId: string,
+ excludeProjectId: string,
+): Promise => {
+ const inActiveMemberOrg = {
+ organization: { members: { some: { userId, ...activeMembershipWhere } } },
+ };
+
+ const row = await db.project.findFirst({
+ where: {
+ ...inActiveMemberOrg,
+ id: { not: excludeProjectId },
+ OR: [
+ { createdByUserId: userId },
+ {
+ accessBindings: {
+ some: {
+ OR: [{ userId }, { group: { members: { some: { userId } } } }],
+ },
+ },
+ },
+ ],
+ },
+ select: { id: true },
+ });
+
+ return row !== null;
+};
+
/**
* The nested-write seeds every user-facing project is born with: one API
* key + the default agent. The single definition all provision sites
diff --git a/packages/api/src/services/project-access-service.ts b/packages/api/src/services/project-access-service.ts
new file mode 100644
index 00000000..d96d2a74
--- /dev/null
+++ b/packages/api/src/services/project-access-service.ts
@@ -0,0 +1,385 @@
+import { db } from "@onecli/db";
+import { ServiceError } from "./errors";
+import { requireProject } from "./project-service";
+import { hasResolvableProjectExcluding } from "./organization-service";
+import {
+ MAX_PROJECT_ACCESS_GROUPS,
+ MAX_PROJECT_ACCESS_USERS,
+ type SetProjectAccessInput,
+} from "../validations/project";
+
+// The project's human sharing surface: read the bindings, replace the set.
+//
+// `ProjectAccess` rows are LIVE authorization data read by three independent
+// enforcement points — `middleware/auth/resolve.ts` (`hasProjectBinding`), the
+// API-key auth path, and the Rust gateway's `load_principal_set`. None of them
+// reads `role`: it is a MANAGEMENT discriminator only (13c), consulted solely
+// by `canManageProject`. Every row is a use grant regardless of its role.
+//
+// Same three rules as `project-service.ts`: org-scoped `findFirst` (never
+// `findUnique({ id })`), the org id always from `auth.organizationId`, and
+// conditional writes.
+
+/** One user binding, in the client's `ProjectAccessUserRow` shape. */
+export interface ProjectAccessUserBinding {
+ id: string;
+ userId: string;
+ name: string | null;
+ email: string;
+ role: "owner" | "member";
+ isOwner: boolean;
+ createdAt: string;
+}
+
+/** One group binding, in the client's `ProjectAccessGroupRow` shape. */
+export interface ProjectAccessGroupBinding {
+ id: string;
+ groupId: string;
+ name: string;
+ memberCount: number;
+ createdAt: string;
+}
+
+export interface ProjectAccessBindings {
+ users: ProjectAccessUserBinding[];
+ groups: ProjectAccessGroupBinding[];
+}
+
+/**
+ * The delta a replace-set applied. Counts are AGGREGATED across users AND
+ * groups — the dialog shows a single toast, so a split would be noise.
+ */
+export interface SetProjectAccessResult {
+ added: number;
+ removed: number;
+ roleChanged: number;
+}
+
+/** `role` is a free-form DB column: normalize, NEVER cast (the ossRoleResolver
+ * precedent). Anything that is not exactly "owner" is a plain use grant. */
+const normalizeRole = (raw: string): "owner" | "member" =>
+ raw === "owner" ? "owner" : "member";
+
+/**
+ * Read the project's bindings.
+ *
+ * User rows are filtered down to users who hold an `OrganizationMember` row in
+ * this org (ANY status). A row for a non-member is inert anyway
+ * (`canAccessProjectAsUser` demands an active membership) — and returning it
+ * would make the UI's "open dialog, save without edits" round-trip 400 on
+ * `setProjectAccess`'s org-membership assertion. SUSPENDED members ARE
+ * returned: suspension is an auth-time gate, not a binding change.
+ *
+ * If this filter changes, `setProjectAccess`'s validation must change with it.
+ */
+export const listProjectAccess = async (
+ organizationId: string,
+ projectId: string,
+): Promise => {
+ const project = await requireProject(organizationId, projectId);
+
+ const [userRows, groupRows] = await Promise.all([
+ db.projectAccess.findMany({
+ where: {
+ projectId,
+ userId: { not: null },
+ user: { organizationMemberships: { some: { organizationId } } },
+ },
+ select: {
+ id: true,
+ userId: true,
+ role: true,
+ createdAt: true,
+ user: { select: { email: true, name: true } },
+ },
+ orderBy: [{ createdAt: "asc" }, { id: "asc" }],
+ // Bounded even against a hand-seeded database.
+ take: MAX_PROJECT_ACCESS_USERS,
+ }),
+ db.projectAccess.findMany({
+ where: {
+ projectId,
+ groupId: { not: null },
+ // Org-fences the join exactly like the gateway's
+ // `JOIN groups g ON … g.organization_id = $org`.
+ group: { organizationId },
+ },
+ select: {
+ id: true,
+ groupId: true,
+ createdAt: true,
+ group: {
+ select: { name: true, _count: { select: { members: true } } },
+ },
+ },
+ orderBy: [{ createdAt: "asc" }, { id: "asc" }],
+ take: MAX_PROJECT_ACCESS_GROUPS,
+ }),
+ ]);
+
+ return {
+ users: userRows.flatMap((row) =>
+ row.userId
+ ? [
+ {
+ id: row.id,
+ userId: row.userId,
+ name: row.user?.name ?? null,
+ email: row.user?.email ?? "",
+ role: normalizeRole(row.role),
+ // Provenance ONLY, deliberately independent of the (transferable)
+ // management role: the creator keeps the badge after a demotion.
+ isOwner: row.userId === project.createdByUserId,
+ createdAt: row.createdAt.toISOString(),
+ },
+ ]
+ : [],
+ ),
+ groups: groupRows.flatMap((row) =>
+ row.groupId
+ ? [
+ {
+ id: row.id,
+ groupId: row.groupId,
+ name: row.group?.name ?? "",
+ memberCount: row.group?._count.members ?? 0,
+ createdAt: row.createdAt.toISOString(),
+ },
+ ]
+ : [],
+ ),
+ };
+};
+
+/**
+ * THE security invariant of every binding write: `ProjectAccess.userId` FKs the
+ * GLOBAL `User` table, so the organization scope exists ONLY in this check.
+ * Every id must resolve to a member of the caller's org — one foreign id and
+ * the whole write is rejected, or a project could capture users from another
+ * organization. (A clone of `assertOrgMembers` in `org-group-service.ts`: the
+ * org services keep their own copies by convention, which keeps this comment
+ * next to the code it protects.)
+ *
+ * Suspended members are deliberately allowed: suspension is an AUTH-time gate,
+ * and stripping bindings on suspend would silently rewrite the member's access
+ * shape on reinstate.
+ */
+const assertOrgMembers = async (organizationId: string, userIds: string[]) => {
+ if (userIds.length === 0) return;
+ const rows = await db.organizationMember.findMany({
+ where: { organizationId, userId: { in: userIds } },
+ select: { userId: true },
+ });
+ const known = new Set(rows.map((row) => row.userId));
+ if (userIds.some((id) => !known.has(id))) {
+ throw new ServiceError(
+ "BAD_REQUEST",
+ "One or more users are not members of this organization.",
+ );
+ }
+};
+
+/** The same fence for group grantees. Unlike group MEMBERSHIP (IdP-owned, so
+ * `requireManualGroup` applies), a project grant TO a group is OneCLI-owned
+ * config: `source: "scim"` groups are valid grantees. */
+const assertOrgGroups = async (organizationId: string, groupIds: string[]) => {
+ if (groupIds.length === 0) return;
+ const rows = await db.group.findMany({
+ where: { organizationId, id: { in: groupIds } },
+ select: { id: true },
+ });
+ const known = new Set(rows.map((row) => row.id));
+ if (groupIds.some((id) => !known.has(id))) {
+ throw new ServiceError(
+ "BAD_REQUEST",
+ "One or more groups do not belong to this organization.",
+ );
+ }
+};
+
+/**
+ * Replace the project's binding set (users + groups) in one write.
+ *
+ * `actorIsOrgAdmin` is computed by the ROUTE from the role resolver (it already
+ * resolved the role for `canManageProject`) and passed in: this service must
+ * never re-resolve it, and must never accept it from the request body.
+ */
+export const setProjectAccess = async (
+ organizationId: string,
+ actorUserId: string,
+ actorIsOrgAdmin: boolean,
+ projectId: string,
+ input: SetProjectAccessInput,
+): Promise => {
+ // Resolve FIRST, before any payload validation: a cross-org project id must
+ // 404 without ever becoming an existence oracle for user/group ids.
+ const project = await requireProject(organizationId, projectId);
+
+ const users = input.users;
+ // Groups carry no role, so a repeat is unambiguous — dedupe silently
+ // (matching `setOrgGroupMembers`). Duplicate USERS are a 422 in the schema.
+ const groupIds = [...new Set(input.groupIds)];
+
+ await assertOrgMembers(
+ organizationId,
+ users.map((u) => u.userId),
+ );
+ await assertOrgGroups(organizationId, groupIds);
+
+ // ── Guard G: the set must keep an owner ──────────────────────────────────
+ // Covers three failure modes at once: demoting every owner, clearing all
+ // users, and the legacy zero-binding project (the admin is forced to name an
+ // owner rather than saving an empty set over an already-orphaned project).
+ if (!users.some((u) => u.role === "owner")) {
+ throw new ServiceError(
+ "BAD_REQUEST",
+ "A project must keep at least one owner.",
+ );
+ }
+
+ const currentRows = await db.projectAccess.findMany({
+ where: { projectId },
+ select: { id: true, userId: true, groupId: true, role: true },
+ });
+
+ const currentUsers = new Map();
+ const currentGroups = new Set();
+ for (const row of currentRows) {
+ if (row.userId) currentUsers.set(row.userId, normalizeRole(row.role));
+ else if (row.groupId) currentGroups.add(row.groupId);
+ }
+
+ // ── Guard H: the actor may not strand themselves ─────────────────────────
+ // A NON-ADMIN may neither drop nor demote themselves: the binding IS their
+ // authority here. (Mirrors `updateOrgMemberStatus`'s "You cannot suspend
+ // yourself.")
+ //
+ // An org admin keeps the DEMOTION exemption — their authority comes from the
+ // org role, so an ownership hand-off must stay possible — but NOT a free
+ // self-REMOVAL. `findUserDefaultProject` has exactly two arms (created the
+ // project, or holds a binding), so an admin whose only path to any project
+ // was this binding resolves NO project once it is gone: `authenticateSession`
+ // then falls back to an `X-Organization-Id` header OSS web never sends and
+ // 401s every request — including the PUT they would need to re-grant. That is
+ // the same lockout `deleteProject` spends three guards preventing, so it is
+ // checked with the same oracle. Excluding THIS project is correct: the
+ // binding on it is exactly what is going away, while the created-by arm
+ // survives the write and is checked separately.
+ //
+ // Deliberately conservative: a group binding on THIS project that would keep
+ // the admin resolving is not counted, so the worst case is a refusal to
+ // perform a safe removal — never a lockout.
+ if (currentUsers.has(actorUserId)) {
+ const mine = users.find((u) => u.userId === actorUserId);
+ if (!mine) {
+ if (!actorIsOrgAdmin) {
+ throw new ServiceError(
+ "BAD_REQUEST",
+ "You cannot remove your own access to this project.",
+ );
+ }
+ const staysResolvable =
+ project.createdByUserId === actorUserId ||
+ (await hasResolvableProjectExcluding(actorUserId, projectId));
+ if (!staysResolvable) {
+ throw new ServiceError(
+ "BAD_REQUEST",
+ "Removing your own access would leave you with no project.",
+ );
+ }
+ } else if (
+ !actorIsOrgAdmin &&
+ mine.role !== "owner" &&
+ currentUsers.get(actorUserId) === "owner"
+ ) {
+ throw new ServiceError(
+ "BAD_REQUEST",
+ "You cannot remove your own management access to this project.",
+ );
+ }
+ }
+
+ const targetUsers = new Map(users.map((u) => [u.userId, u.role]));
+ const targetGroups = new Set(groupIds);
+
+ const userAdds = users.filter((u) => !currentUsers.has(u.userId));
+ const userRemoves = [...currentUsers.keys()].filter(
+ (id) => !targetUsers.has(id),
+ );
+ const roleChanges = [...targetUsers.entries()].filter(
+ ([id, role]) => currentUsers.has(id) && currentUsers.get(id) !== role,
+ );
+ const groupAdds = groupIds.filter((id) => !currentGroups.has(id));
+ const groupRemoves = [...currentGroups].filter((id) => !targetGroups.has(id));
+
+ // Early no-op: nothing to write, so no transaction is opened
+ // (`setOrgGroupMembers` precedent). The route's audit + gateway flush still
+ // run — cheap, and simpler than making withAudit conditional.
+ if (
+ userAdds.length === 0 &&
+ userRemoves.length === 0 &&
+ roleChanges.length === 0 &&
+ groupAdds.length === 0 &&
+ groupRemoves.length === 0
+ ) {
+ return { added: 0, removed: 0, roleChanged: 0 };
+ }
+
+ const toOwner = roleChanges
+ .filter(([, role]) => role === "owner")
+ .map(([id]) => id);
+ const toMember = roleChanges
+ .filter(([, role]) => role === "member")
+ .map(([id]) => id);
+
+ await db.$transaction([
+ // Deletes BEFORE creates: `@@unique([projectId, userId])` and
+ // `@@unique([projectId, groupId])` are partial (Postgres treats NULLs as
+ // distinct), so a create racing a delete on the same key would P2002.
+ db.projectAccess.deleteMany({
+ where: { projectId, userId: { in: userRemoves } },
+ }),
+ db.projectAccess.deleteMany({
+ where: { projectId, groupId: { in: groupRemoves } },
+ }),
+ db.projectAccess.updateMany({
+ where: { projectId, userId: { in: toOwner } },
+ data: { role: "owner" },
+ }),
+ db.projectAccess.updateMany({
+ where: { projectId, userId: { in: toMember } },
+ data: { role: "member" },
+ }),
+ // The DB CHECK is `num_nonnulls(user_id, group_id) = 1`. Each row below is
+ // built literally, with EXACTLY ONE principal column — never from a shared
+ // spread object that could carry both. `skipDuplicates` makes a concurrent
+ // double-add idempotent instead of a P2002.
+ db.projectAccess.createMany({
+ data: userAdds.map((u) => ({
+ projectId,
+ userId: u.userId,
+ role: u.role,
+ createdByUserId: actorUserId,
+ })),
+ skipDuplicates: true,
+ }),
+ db.projectAccess.createMany({
+ // Group bindings are ALWAYS "member": the client payload has no group
+ // role and the gateway ignores `role` entirely — this must never gain a
+ // silent management path.
+ data: groupAdds.map((groupId) => ({
+ projectId,
+ groupId,
+ role: "member",
+ createdByUserId: actorUserId,
+ })),
+ skipDuplicates: true,
+ }),
+ ]);
+
+ return {
+ added: userAdds.length + groupAdds.length,
+ removed: userRemoves.length + groupRemoves.length,
+ roleChanged: roleChanges.length,
+ };
+};
diff --git a/packages/api/src/services/project-service.test.ts b/packages/api/src/services/project-service.test.ts
new file mode 100644
index 00000000..d6406731
--- /dev/null
+++ b/packages/api/src/services/project-service.test.ts
@@ -0,0 +1,78 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+// `canManageProject` in isolation, for the one case the route suite cannot
+// reach: an edition with NO RoleResolver registered. `initRoleResolver` is a
+// module singleton set by `createApiApp`, so it can only be observed unset in
+// a file that mocks the providers module outright.
+//
+// The invariant: MANAGEMENT must fail closed. Unlike `canAccessProjectAsUser`
+// (a USAGE check, which no-ops to `true` for editions without roles), a
+// management check that allowed everyone when roles are unavailable would let
+// any member rename or delete any project.
+
+const store = vi.hoisted(() => ({
+ /** An `owner` binding that exists — and must still not be consulted. */
+ ownerBinding: true,
+ resolverRole: null as string | null,
+}));
+
+vi.mock("@onecli/db", () => ({
+ db: {
+ projectAccess: {
+ findFirst: async () => (store.ownerBinding ? { id: "pa-1" } : null),
+ },
+ },
+}));
+
+vi.mock("../providers", () => ({
+ ROLE_HIERARCHY: { member: 1, admin: 2, owner: 3 },
+ // No resolver registered — the edition never called `initRoleResolver`.
+ getRoleResolver: () =>
+ store.resolverRole === null
+ ? null
+ : { getUserRole: async () => store.resolverRole },
+ getNewOrgPolicySeeder: () => ({ seed: async () => {} }),
+}));
+
+import { canManageProject } from "./project-service";
+
+beforeEach(() => {
+ store.ownerBinding = true;
+ store.resolverRole = null;
+});
+
+describe("canManageProject without a RoleResolver", () => {
+ it("denies, even when an owner binding exists", async () => {
+ await expect(canManageProject("u-1", "org-1", "proj-1")).resolves.toBe(
+ false,
+ );
+ });
+});
+
+describe("canManageProject with a resolver", () => {
+ it("denies a user the resolver reports no role for (suspended / non-member)", async () => {
+ store.resolverRole = null;
+ await expect(canManageProject("u-1", "org-1", "proj-1")).resolves.toBe(
+ false,
+ );
+ });
+
+ it("allows an org admin with no binding at all", async () => {
+ store.resolverRole = "admin";
+ store.ownerBinding = false;
+ await expect(canManageProject("u-1", "org-1", "proj-1")).resolves.toBe(
+ true,
+ );
+ });
+
+ it("allows a plain member holding an owner binding, and denies them without one", async () => {
+ store.resolverRole = "member";
+ await expect(canManageProject("u-1", "org-1", "proj-1")).resolves.toBe(
+ true,
+ );
+ store.ownerBinding = false;
+ await expect(canManageProject("u-1", "org-1", "proj-1")).resolves.toBe(
+ false,
+ );
+ });
+});
diff --git a/packages/api/src/services/project-service.ts b/packages/api/src/services/project-service.ts
new file mode 100644
index 00000000..71f90861
--- /dev/null
+++ b/packages/api/src/services/project-service.ts
@@ -0,0 +1,387 @@
+import { db } from "@onecli/db";
+import { ServiceError } from "./errors";
+import { getRoleResolver, ROLE_HIERARCHY } from "../providers";
+import {
+ activeMembershipWhere,
+ hasResolvableProjectExcluding,
+} from "./organization-service";
+import { invalidateGatewayCacheForKeys } from "../lib/gateway-invalidate";
+
+// Project administration: read, rename, delete. Three rules, same as
+// `org-group-service.ts`:
+// 1. every resolve is `findFirst({ id, organizationId })`, NEVER
+// `findUnique({ where: { id } })` — a cross-org id must read as absent
+// (404), not leak another org's row;
+// 2. the organization id ALWAYS comes from `auth.organizationId`, never from
+// a body or query parameter;
+// 3. writes are conditional `updateMany`/`deleteMany` so a lost race is a
+// 404, not the P2025 500 a bare `update()`/`delete()` would surface.
+
+/** A project row in the client's `Project` shape (`createdAt` as ISO string). */
+export interface ProjectRow {
+ id: string;
+ name: string | null;
+ slug: string | null;
+ createdAt: string;
+}
+
+/** What a delete actually removed. */
+export interface ProjectDeleteResult {
+ id: string;
+ name: string | null;
+ removed: {
+ agents: number;
+ apiKeys: number;
+ secrets: number;
+ policyRules: number;
+ policyRulesV2: number;
+ appConnections: number;
+ appConfigs: number;
+ vaultConnections: number;
+ budgets: number;
+ accessBindings: number;
+ onboardingSurvey: number;
+ };
+}
+
+const projectSelect = {
+ id: true,
+ name: true,
+ slug: true,
+ createdAt: true,
+ createdByUserId: true,
+} as const;
+
+const toProjectRow = (row: {
+ id: string;
+ name: string | null;
+ slug: string | null;
+ createdAt: Date;
+}): ProjectRow => ({
+ id: row.id,
+ name: row.name,
+ slug: row.slug,
+ createdAt: row.createdAt.toISOString(),
+});
+
+/**
+ * Resolve a project WITHIN the caller's org. A cross-org (or unknown) id reads
+ * as absent — 404, never 403: a forbidden response would turn the route into an
+ * existence oracle for another organization's project ids.
+ */
+export const requireProject = async (
+ organizationId: string,
+ projectId: string,
+) => {
+ const project = await db.project.findFirst({
+ where: { id: projectId, organizationId },
+ select: projectSelect,
+ });
+ if (!project) throw new ServiceError("NOT_FOUND", "Project not found.");
+ return project;
+};
+
+export interface ProjectAuthority {
+ /** Org admin/owner — Guard H's exemption in `setProjectAccess`. */
+ isOrgAdmin: boolean;
+ canManage: boolean;
+}
+
+/**
+ * MANAGEMENT authority over a project (step 13c): an org admin/owner, or the
+ * holder of a USER binding with `role: "owner"`. GROUP bindings never confer
+ * management in v1 — the gateway and the usage gate both ignore `role`, so a
+ * group grant is a USE grant only.
+ *
+ * Resolves the org role ONCE and derives both signals from it, so a route never
+ * pays for (or risks disagreeing across) two resolver calls.
+ *
+ * Two invariants, both deliberate:
+ *
+ * - The role is resolved FIRST and a null role denies (the suspension
+ * invariant, copied from `canAccessProjectAsUser`): the binding check lives
+ * INSIDE the active-member gate, so a suspended user's stale owner binding
+ * can never rescue them.
+ * - Unlike `canAccessProjectAsUser`, this is NOT gated on `CAPS.rbac`. A usage
+ * check must no-op (allow) for editions without roles; a MANAGEMENT check
+ * that allowed everyone there would let any member delete any project. With
+ * no resolver registered the role reads null and we deny — fail closed.
+ */
+const resolveAuthority = async (
+ userId: string,
+ organizationId: string,
+ projectId: string,
+): Promise => {
+ const resolver = getRoleResolver();
+ const role = resolver
+ ? await resolver.getUserRole(userId, organizationId)
+ : null;
+ if (!role) return { isOrgAdmin: false, canManage: false };
+ if (ROLE_HIERARCHY[role] >= ROLE_HIERARCHY.admin) {
+ return { isOrgAdmin: true, canManage: true };
+ }
+
+ const owner = await db.projectAccess.findFirst({
+ where: { projectId, userId, role: "owner" },
+ select: { id: true },
+ });
+ return { isOrgAdmin: false, canManage: owner !== null };
+};
+
+export const canManageProject = async (
+ userId: string,
+ organizationId: string,
+ projectId: string,
+): Promise =>
+ (await resolveAuthority(userId, organizationId, projectId)).canManage;
+
+/** Route helper: resolve (404) THEN authorize (403), never the other way —
+ * a cross-org project id must never distinguish "exists but forbidden". */
+export const requireManageableProject = async (
+ organizationId: string,
+ userId: string,
+ projectId: string,
+) => {
+ const project = await requireProject(organizationId, projectId);
+ const authority = await resolveAuthority(userId, organizationId, projectId);
+ if (!authority.canManage) {
+ throw new ServiceError(
+ "FORBIDDEN",
+ "You do not have permission to manage this project.",
+ );
+ }
+ return { project, isOrgAdmin: authority.isOrgAdmin };
+};
+
+export const getProject = async (
+ organizationId: string,
+ projectId: string,
+): Promise =>
+ toProjectRow(await requireProject(organizationId, projectId));
+
+/**
+ * Rename. `name` ONLY — `slug` is immutable (it is write-only provenance,
+ * never read by api/web/gateway, and it is `@@unique([organizationId, slug])`,
+ * so rewriting it could collide). Names are NOT unique per org (see
+ * `projectNameSchema`), so a rename-to-self and a rename onto a sibling's name
+ * are both permitted 200s.
+ */
+export const renameProject = async (
+ organizationId: string,
+ projectId: string,
+ name: string,
+): Promise => {
+ await requireProject(organizationId, projectId);
+
+ // Org-scoped conditional write: count 0 means the row vanished (or never
+ // belonged to this org) between the read and the write — 404, not a 500.
+ const { count } = await db.project.updateMany({
+ where: { id: projectId, organizationId },
+ data: { name },
+ });
+ if (count === 0) throw new ServiceError("NOT_FOUND", "Project not found.");
+
+ const row = await db.project.findFirst({
+ where: { id: projectId, organizationId },
+ select: projectSelect,
+ });
+ if (!row) throw new ServiceError("NOT_FOUND", "Project not found.");
+ return toProjectRow(row);
+};
+
+/**
+ * Delete a project, with an explicit pinned cascade.
+ *
+ * A bare `db.project.delete()` is NOT viable: `agents`, `vault_connections` and
+ * `onboarding_surveys` are `ON DELETE RESTRICT` (and every project is born with
+ * a default agent, so the P2003 would be universal), while `api_keys`,
+ * `secrets`, `policy_rules`, `app_connections`, `app_configs` and `budgets` are
+ * `ON DELETE SET NULL` — they would SURVIVE the project as orphaned
+ * `scope: "project"` rows with `project_id = NULL`. Both hazards are handled by
+ * deleting the children explicitly, in FK order, inside ONE transaction.
+ *
+ * Three refusals guard the lockout cases (a user with no resolvable project
+ * gets a 401 on every request — a bricked dashboard, not a degraded one).
+ * Refusing outright ("empty the project first") is not an option: the default
+ * agent + API key mean a project can never be emptied through the product.
+ */
+export const deleteProject = async (
+ organizationId: string,
+ actorUserId: string,
+ projectId: string,
+): Promise => {
+ const project = await requireProject(organizationId, projectId);
+
+ // ── Guard 1: the org's last project ──────────────────────────────────────
+ // Deleting it makes EVERY session in the org unresolvable — a total instance
+ // lockout in OSS, where there is no project switcher to recover through.
+ const projectCount = await db.project.count({ where: { organizationId } });
+ if (projectCount <= 1) {
+ throw new ServiceError(
+ "CONFLICT",
+ "An organization must keep at least one project.",
+ );
+ }
+
+ // ── Guards 2 & 3: stranded users ─────────────────────────────────────────
+ // Candidates are every human who could be relying on this project: direct
+ // user bindings ∪ members of groups bound to it ∪ the creator. Restricted to
+ // ACTIVE members of the org — a suspended or foreign user cannot be stranded
+ // by definition (they resolve no project either way).
+ const [userBindings, groupBindings] = await Promise.all([
+ db.projectAccess.findMany({
+ where: { projectId, userId: { not: null } },
+ select: { userId: true },
+ }),
+ db.projectAccess.findMany({
+ where: { projectId, groupId: { not: null } },
+ select: { group: { select: { members: { select: { userId: true } } } } },
+ }),
+ ]);
+
+ const candidates = new Set();
+ for (const row of userBindings) if (row.userId) candidates.add(row.userId);
+ for (const row of groupBindings) {
+ for (const m of row.group?.members ?? []) candidates.add(m.userId);
+ }
+ if (project.createdByUserId) candidates.add(project.createdByUserId);
+
+ const activeMembers = await db.organizationMember.findMany({
+ where: {
+ organizationId,
+ userId: { in: [...candidates] },
+ // The shared "active member" filter, so a future change to what counts
+ // as active lands here too instead of silently narrowing this guard.
+ ...activeMembershipWhere,
+ },
+ select: { userId: true },
+ });
+ const atRisk = new Set(activeMembers.map((row) => row.userId));
+
+ // Guard 3 (self) is checked FIRST so the actor's own case yields the sharper
+ // message rather than being folded into the anonymous count below.
+ if (
+ atRisk.has(actorUserId) &&
+ !(await hasResolvableProjectExcluding(actorUserId, projectId))
+ ) {
+ throw new ServiceError(
+ "CONFLICT",
+ "Deleting this project would leave you with no project.",
+ );
+ }
+
+ // Guard 2: a serial loop, deliberately. The candidate set is bounded by the
+ // access-PUT caps and this is a rare destructive action — per-user
+ // correctness matters more than collapsing it into one clever query.
+ let stranded = 0;
+ for (const userId of atRisk) {
+ if (userId === actorUserId) continue; // handled above
+ if (!(await hasResolvableProjectExcluding(userId, projectId))) stranded++;
+ }
+ if (stranded > 0) {
+ throw new ServiceError(
+ "CONFLICT",
+ `Deleting this project would leave ${stranded} member(s) with no project. Give them access to another project first.`,
+ );
+ }
+
+ // Flush the gateway BEFORE the cascade, never after: `/v1/cache/invalidate`
+ // authenticates the bearer through an UNCACHED `find_api_key` lookup
+ // (apps/gateway/src/auth.rs), so a key deleted a moment ago cannot
+ // authenticate its own flush — a post-delete call would silently 401 and
+ // flush nothing. Flushing here is safe in both directions: if the
+ // transaction below rolls back the gateway simply re-reads the config it
+ // just dropped.
+ const keyRows = await db.apiKey.findMany({
+ where: { projectId },
+ select: { key: true },
+ });
+ invalidateGatewayCacheForKeys(keyRows.map((row) => row.key));
+
+ const [
+ agents,
+ apiKeys,
+ secrets,
+ policyRules,
+ policyRulesV2,
+ appConnections,
+ appConfigs,
+ vaultConnections,
+ budgets,
+ accessBindings,
+ onboardingSurvey,
+ ] = await Promise.all([
+ db.agent.count({ where: { projectId } }),
+ db.apiKey.count({ where: { projectId } }),
+ db.secret.count({ where: { projectId } }),
+ db.policyRule.count({ where: { projectId } }),
+ db.policyRuleV2.count({ where: { projectId } }),
+ db.appConnection.count({ where: { projectId } }),
+ db.appConfig.count({ where: { projectId } }),
+ db.vaultConnection.count({ where: { projectId } }),
+ db.budget.count({ where: { projectId } }),
+ db.projectAccess.count({ where: { projectId } }),
+ db.onboardingSurvey.count({ where: { projectId } }),
+ ]);
+
+ // One transaction, children first, in FK order. Each line carries its FK
+ // action so a future schema change is caught in review: a new RESTRICT child
+ // without a line here is a P2003, a new SET NULL child is a silent orphan.
+ //
+ // Interactive (callback) form, not the array form, precisely so the final
+ // `count === 0` check below can ROLL THE CASCADE BACK by throwing.
+ await db.$transaction(async (tx) => {
+ // RESTRICT — must precede the project. Cascades agent_secrets,
+ // agent_app_connections, grant_rules, policy_rule_identities(agent).
+ await tx.agent.deleteMany({ where: { projectId } });
+ // SET NULL — explicit, else orphaned scope:"project" rows survive.
+ // Cascades secret_access, budgets, policy_rule_targets(secret).
+ await tx.secret.deleteMany({ where: { projectId } });
+ // SET NULL — cascades connection_access, policy_rule_targets(connection).
+ await tx.appConnection.deleteMany({ where: { projectId } });
+ // SET NULL
+ await tx.appConfig.deleteMany({ where: { projectId } });
+ // SET NULL — an orphaned PROJECT api key must never outlive its project.
+ await tx.apiKey.deleteMany({ where: { projectId } });
+ // SET NULL (legacy rule model)
+ await tx.policyRule.deleteMany({ where: { projectId } });
+ // SET NULL (cloud-only budgets; inert in OSS)
+ await tx.budget.deleteMany({ where: { projectId } });
+ // RESTRICT
+ await tx.vaultConnection.deleteMany({ where: { projectId } });
+ // RESTRICT
+ await tx.onboardingSurvey.deleteMany({ where: { projectId } });
+
+ // Org-scoped conditional delete. Deliberately NOT deleted by hand:
+ // · policy_rules_v2 + project_access — DB CASCADE, removed with the row;
+ // · audit_logs — SET NULL by design: history SURVIVES and stays
+ // attributable through organization_id. Never delete audit rows.
+ // · request_logs — no FK at all: telemetry keeps a dangling project_id and
+ // becomes unreachable. Deleting it could be millions of rows in one
+ // transaction; out of scope here.
+ const { count } = await tx.project.deleteMany({
+ where: { id: projectId, organizationId },
+ });
+ // A 0 here means the project vanished (or was never ours) between the
+ // resolve and the write — throwing rolls the whole cascade back.
+ if (count === 0) throw new ServiceError("NOT_FOUND", "Project not found.");
+ });
+
+ return {
+ id: project.id,
+ name: project.name,
+ removed: {
+ agents,
+ apiKeys,
+ secrets,
+ policyRules,
+ policyRulesV2,
+ appConnections,
+ appConfigs,
+ vaultConnections,
+ budgets,
+ accessBindings,
+ onboardingSurvey,
+ },
+ };
+};
diff --git a/packages/api/src/validations/org.ts b/packages/api/src/validations/org.ts
index fe4fbaed..260c6359 100644
--- a/packages/api/src/validations/org.ts
+++ b/packages/api/src/validations/org.ts
@@ -98,6 +98,84 @@ export const setGroupMembersSchema = z.object({
userIds: z.array(z.string().min(1)).max(MAX_GROUP_MEMBERS),
});
+// ── Role mappings ────────────────────────────────────────────────────────
+
+/**
+ * Ceiling on the org's mapping set — enforced on BOTH ends, and it has to be:
+ * `PUT /order` takes the FULL ordered id set, so an org that can hold more
+ * mappings than this body allows would have an unreachable reorder endpoint
+ * (every honest body 422s on `.max()`, every shorter one 409s on the
+ * names-every-mapping-once check) and no way to resolve shadowing. The chosen
+ * invariant is therefore `mappings ≤ MAX_ROLE_MAPPINGS`: `createOrgRoleMapping`
+ * 409s at the cap, so the reorder body can always name the whole set.
+ */
+export const MAX_ROLE_MAPPINGS = 500;
+
+/**
+ * Mappings assign exactly the roles the members surface can — never `owner`.
+ * Owner is bootstrap-only, and `updateOrgMemberRole` already refuses to assign
+ * or overwrite it, so a body carrying `role: "owner"` is a 422 here rather
+ * than a privilege the mapping engine could mint.
+ */
+export const roleMappingRoleSchema = orgMemberRoleSchema;
+
+/**
+ * `priority` is an ASCENDING rank: 0 = highest precedence (evaluated first /
+ * wins). Omitted on create means "append after the current last mapping".
+ *
+ * NOT coerced, unlike `limit`: this field only ever arrives in a JSON body, so
+ * it is already a number when it is one. Coercion would turn `null`, `""`,
+ * `false` and `[]` into `0` — the HIGHEST-precedence slot, the single most
+ * consequential value the field can take — instead of the 422 a body that
+ * meant "no priority" deserves. `.optional()` still short-circuits `undefined`,
+ * so the omitted-priority append path is unaffected.
+ */
+export const roleMappingPrioritySchema = z.number().int().min(0).max(100_000);
+
+export const createRoleMappingSchema = z.object({
+ groupId: z.string().min(1),
+ role: roleMappingRoleSchema,
+ priority: roleMappingPrioritySchema.optional(),
+});
+
+export type CreateRoleMappingInput = z.infer;
+
+export const updateRoleMappingSchema = z.object({
+ role: roleMappingRoleSchema,
+ priority: roleMappingPrioritySchema.optional(),
+});
+
+export type UpdateRoleMappingInput = z.infer;
+
+/**
+ * The FULL ordered id set, index 0 = highest priority. A body that does not
+ * name every current mapping exactly once is STALE → 409 in the service (the
+ * `reorderPolicyRules` precedent); duplicates are malformed → 422 here.
+ *
+ * Deliberately NOT `.min(1)` (unlike `reorderPolicyRulesSchema`): an org with
+ * zero mappings legitimately reorders to `[]`, and a minimum would 422 that
+ * honest no-op.
+ */
+export const reorderRoleMappingsSchema = z.object({
+ orderedIds: z
+ .array(z.string().min(1))
+ .max(MAX_ROLE_MAPPINGS)
+ .refine((ids) => new Set(ids).size === ids.length, {
+ message: "orderedIds must not contain duplicates.",
+ }),
+});
+
+/**
+ * Dry run. No `priority`: an existing mapping is previewed at its current
+ * slot, a new one as if appended last — exactly what the create button does.
+ */
+export const previewRoleMappingSchema = z.object({
+ groupId: z.string().min(1),
+ role: roleMappingRoleSchema,
+});
+
+export type PreviewRoleMappingInput = z.infer;
+
/**
* `PATCH /v1/org/members/:userId` accepts EXACTLY ONE change per request —
* either a lifecycle change (`status`) or a role change (`role`). A body
diff --git a/packages/api/src/validations/project.ts b/packages/api/src/validations/project.ts
new file mode 100644
index 00000000..d3eceb53
--- /dev/null
+++ b/packages/api/src/validations/project.ts
@@ -0,0 +1,68 @@
+import { z } from "zod";
+
+// Validation for the `/v1/projects/*` surface (rename + the access replace-set).
+// Mirrors `validations/org.ts` in shape; kept separate because projects are a
+// project-scoped resource, not part of the org directory.
+
+/**
+ * Project display name. Bounded like a group name, but deliberately NOT unique
+ * per organization: `ensureMemberDefaultProject` names EVERY invited member's
+ * project "Default", so a uniqueness rule would 409 the most common state in
+ * the product.
+ */
+export const projectNameSchema = z.string().trim().min(1).max(100);
+
+export const renameProjectSchema = z.object({ name: projectNameSchema });
+
+/**
+ * The management role on a USER binding (step 13c): "owner" may
+ * rename/share/delete the project, "member" is a plain use grant. GROUP
+ * bindings carry no role in v1 — they are always written as "member".
+ */
+export const projectAccessRoleSchema = z.enum(["owner", "member"]);
+
+/**
+ * Replace-set ceilings. Deliberately not `DIRECTORY_LIMIT_MAX` (a page size):
+ * the sharing dialog drains every page of the org directory and PUTs the full
+ * set back, so the write cap must comfortably exceed one page while still
+ * bounding the request body.
+ */
+export const MAX_PROJECT_ACCESS_USERS = 1000;
+export const MAX_PROJECT_ACCESS_GROUPS = 200;
+
+/**
+ * `PUT /v1/projects/:projectId/access` body. Both keys are REQUIRED (no
+ * `.default([])`): a client bug that omits one must be a 422, never a silent
+ * half-wipe of the project's bindings.
+ */
+export const setProjectAccessSchema = z
+ .object({
+ users: z
+ .array(
+ z.object({
+ userId: z.string().min(1),
+ role: projectAccessRoleSchema,
+ }),
+ )
+ .max(MAX_PROJECT_ACCESS_USERS),
+ groupIds: z.array(z.string().min(1)).max(MAX_PROJECT_ACCESS_GROUPS),
+ })
+ .superRefine((body, ctx) => {
+ // A duplicate userId is REJECTED rather than resolved "last wins": each
+ // entry carries a role, so a repeat with a conflicting role is genuinely
+ // ambiguous. `groupIds` carry no role and are deduped silently in the
+ // service (the setOrgGroupMembers precedent).
+ const seen = new Set();
+ for (const user of body.users) {
+ if (seen.has(user.userId)) {
+ ctx.addIssue({
+ code: "custom",
+ message: "Duplicate user in the access set.",
+ });
+ return;
+ }
+ seen.add(user.userId);
+ }
+ });
+
+export type SetProjectAccessInput = z.infer;
From 1b5a1fb3fdd35080c3f3e0663e407dae6e785dcf Mon Sep 17 00:00:00 2001
From: marcorivm
Date: Wed, 29 Jul 2026 19:59:16 -0600
Subject: [PATCH 04/10] feat(gateway): org-scope enforcement with user/group
principals on 1.44.0
Reconciliation Stage F. The OSS gateway now populates the org rule set and
the user/group PrincipalSet that upstream shipped but never filled: a new
loaders.rs adds the org published-rule loader and a principal CTE mirroring
the API's resolvePrincipalSet (users direct and via granted groups, active
members only; groups direct and inherited; fully org-fenced, agent-groups
dropped). Two-level evaluation mirrors upstream's own evaluator including
the hard-floor rule (a lone allow at one level cannot open the other
level's default block), so an org guardrail can't be bypassed by a project
allow. Fail-closed via upstream's anyhow refuse-CONNECT; our old
org_degraded/Fallback/kill-switch scaffolding is deleted. Empty org fails
OPEN. +21 tests, no agent-group, no signature changes to the call sites.
---
apps/gateway/src/policy_engine.rs | 18 +-
apps/gateway/src/policy_engine/assemble.rs | 150 ++++-
apps/gateway/src/policy_engine/enforce.rs | 218 +++++++-
apps/gateway/src/policy_engine/evaluate.rs | 619 +++++++++++++++++----
apps/gateway/src/policy_engine/loaders.rs | 200 +++++++
apps/gateway/src/policy_engine/types.rs | 51 +-
6 files changed, 1080 insertions(+), 176 deletions(-)
create mode 100644 apps/gateway/src/policy_engine/loaders.rs
diff --git a/apps/gateway/src/policy_engine.rs b/apps/gateway/src/policy_engine.rs
index c23d92da..54eb2e9c 100644
--- a/apps/gateway/src/policy_engine.rs
+++ b/apps/gateway/src/policy_engine.rs
@@ -4,19 +4,23 @@
//! builds, so the shared call sites in `connect.rs`, `gateway/forward.rs`, and
//! `gateway/websocket.rs` never change.
//!
-//! The OSS scope (the §2.9 locked matrix — exactly today's capabilities,
-//! restructured): project rules only, agent/any identities, all four target
-//! kinds, allow/block with the approval + rate-limit modifiers, the project
-//! Default Rule terminal under the `enforce_deny` carve, and the explicit-agent
-//! injection selection its equipment migration requires. Org scope, directory
-//! identities, granular session policies, availability, and the shadow
-//! comparator are OneCLI Cloud capabilities and have no code here.
+//! The OSS scope: org + project rules composed two-level (each level reduced
+//! first-match, combined under the hard-floor law mirroring
+//! `policy-translation/evaluator.ts`), agent/user/group/any identities (the
+//! directory kinds matched against the connection's resolved `PrincipalSet`),
+//! all four target kinds, allow/block with the approval + rate-limit modifiers,
+//! each level's Default Rule terminal under the `enforce_deny` carve, and the
+//! explicit-agent injection selection its equipment migration requires. There
+//! is no agent-group concept (deleted). Granular session-policy conditions,
+//! app availability, and the shadow comparator remain OneCLI Cloud
+//! capabilities and have no code here.
mod assemble;
mod catalog;
mod enforce;
mod evaluate;
mod inject_select;
+mod loaders;
mod types;
// The corpus parity test lives in the PRIVATE tree (`src/ee/policy_engine/`)
diff --git a/apps/gateway/src/policy_engine/assemble.rs b/apps/gateway/src/policy_engine/assemble.rs
index bc5d19aa..b8ee3e9d 100644
--- a/apps/gateway/src/policy_engine/assemble.rs
+++ b/apps/gateway/src/policy_engine/assemble.rs
@@ -1,29 +1,39 @@
-//! Decode the loaded published project rows into the evaluator's `Rule` list.
-//! The rows are already new-model; this maps shapes and resolves
-//! connection/secret targets through the fenced connect-time maps.
+//! Decode the loaded published rows of ONE scope (org or project) into the
+//! evaluator's `Rule` list. The rows are already new-model; this maps shapes
+//! and resolves connection/secret targets through the fenced connect-time maps.
use crate::db::{
ConnectionProviders, PolicyIdentityRow, PolicyRuleV2Row, PolicyTargetRow, SecretHosts,
};
-use super::types::{Action, Identity, RateWindow, Rule, Target};
+use super::types::{Action, Identity, RateWindow, Rule, RuleScope, Target};
-/// Agent identities match by id; every other principal kind is a OneCLI Cloud
-/// capability and decodes to `Other`, which never matches — a stored directory
-/// identity narrows its rule to nothing rather than widening it (fail-closed).
+/// Decode each identity row to its principal kind (the DB `one_principal`
+/// CHECK guarantees at most one column is set). `agent_id`/`user_id`/`group_id`
+/// decode to the matching directory kind; a row naming NO principal the OSS
+/// engine understands decodes to `Other`, which never matches — it narrows its
+/// rule to nothing rather than widening it (fail-closed). There is no
+/// agent-group column, so no agent-group case exists.
fn decode_identities(rows: &[PolicyIdentityRow]) -> Vec {
rows.iter()
- .map(|r| match &r.agent_id {
- Some(id) => Identity::Agent(id.clone()),
- None => Identity::Other,
+ .map(|r| {
+ if let Some(id) = &r.agent_id {
+ Identity::Agent(id.clone())
+ } else if let Some(id) = &r.user_id {
+ Identity::User(id.clone())
+ } else if let Some(id) = &r.group_id {
+ Identity::Group(id.clone())
+ } else {
+ Identity::Other
+ }
})
.collect()
}
/// Resolve a `secret` target to the host pattern(s) it gates: a specific
/// `secret_id` via the fenced by-id map (absent/deleted → none → never
-/// matches), or a `secret_scope` level union. The maps are project-fenced at
-/// load, so a forged/foreign id resolves to nothing.
+/// matches), or a `secret_scope` level union. The maps are org+project-fenced
+/// at load, so a forged/foreign id resolves to nothing.
fn secret_target_hosts(r: &PolicyTargetRow, secret_hosts: &SecretHosts) -> Vec {
if let Some(id) = &r.secret_id {
secret_hosts.by_id.get(id).cloned().unwrap_or_default()
@@ -93,11 +103,13 @@ fn rate_window(name: Option<&str>) -> Option {
fn decode_row(
row: &PolicyRuleV2Row,
+ scope: RuleScope,
secret_hosts: &SecretHosts,
connection_providers: &ConnectionProviders,
) -> Rule {
Rule {
id: row.id.clone(),
+ scope,
logical_id: row.logical_id.clone(),
name: row.name.clone(),
priority: usize::try_from(row.priority).unwrap_or(0),
@@ -121,20 +133,23 @@ fn decode_row(
}
}
-/// Assemble the loaded project rows for the evaluator. `source="equipment"`
-/// rows are injection-only — their connection/secret target names a credential
-/// to inject at connect, not a policy grant — and are DROPPED here. That drop
-/// is load-bearing: a `secret` target PERMITS its host, so an undropped
-/// equipment rule would silently grant network access alongside its injection.
+/// Assemble one scope's loaded rows for the evaluator, tagging each with the
+/// scope it came from. `source="equipment"` rows are injection-only — their
+/// connection/secret target names a credential to inject at connect, not a
+/// policy grant — and are DROPPED here. That drop is load-bearing: a `secret`
+/// target PERMITS its host, so an undropped equipment rule would silently
+/// grant network access alongside its injection. Org secret/connection targets
+/// resolve through the SAME fenced maps as project ones (`find_secret_hosts` /
+/// `find_connection_providers` already fetch org+project).
pub(super) fn assemble(
- project_rows: &[PolicyRuleV2Row],
+ rows: &[PolicyRuleV2Row],
+ scope: RuleScope,
secret_hosts: &SecretHosts,
connection_providers: &ConnectionProviders,
) -> Vec {
- project_rows
- .iter()
+ rows.iter()
.filter(|row| row.source != "equipment")
- .map(|row| decode_row(row, secret_hosts, connection_providers))
+ .map(|row| decode_row(row, scope, secret_hosts, connection_providers))
.collect()
}
@@ -176,6 +191,7 @@ mod tests {
];
let rules = assemble(
&rows,
+ RuleScope::Project,
&SecretHosts::default(),
&ConnectionProviders::default(),
);
@@ -183,20 +199,78 @@ mod tests {
assert_eq!(rules[0].id, "keep");
}
+ /// Test #12: agent-group is provably absent — every directory identity kind
+ /// the DB carries (agent/user/group) decodes to a live variant, a group id
+ /// decodes to `Group` (never a swallowed agent-group), and a principal-less
+ /// row is `Other`. There is no agent-group column or variant to decode.
#[test]
- fn directory_identities_decode_to_other_never_agent() {
+ fn agent_user_and_group_identities_decode_and_a_no_principal_row_is_other() {
let rows = vec![row(|r| {
- r.identities = Json(vec![serde_json::from_value(
- json!({"agentId": null, "userId": null, "groupId": "g1"}),
- )
- .expect("identity row")]);
+ r.identities = Json(
+ serde_json::from_value(json!([
+ {"agentId": "a1", "userId": null, "groupId": null},
+ {"agentId": null, "userId": "u1", "groupId": null},
+ {"agentId": null, "userId": null, "groupId": "g1"},
+ {"agentId": null, "userId": null, "groupId": null},
+ ]))
+ .expect("identity rows"),
+ );
})];
let rules = assemble(
&rows,
+ RuleScope::Project,
+ &SecretHosts::default(),
+ &ConnectionProviders::default(),
+ );
+ assert!(matches!(&rules[0].identities[0], Identity::Agent(id) if id == "a1"));
+ assert!(matches!(&rules[0].identities[1], Identity::User(id) if id == "u1"));
+ assert!(matches!(&rules[0].identities[2], Identity::Group(id) if id == "g1"));
+ assert!(matches!(rules[0].identities[3], Identity::Other));
+ }
+
+ #[test]
+ fn rules_are_tagged_with_the_scope_they_were_assembled_for() {
+ let rows = vec![row(|_| {})];
+ let org = assemble(
+ &rows,
+ RuleScope::Organization,
+ &SecretHosts::default(),
+ &ConnectionProviders::default(),
+ );
+ let project = assemble(
+ &rows,
+ RuleScope::Project,
&SecretHosts::default(),
&ConnectionProviders::default(),
);
- assert!(matches!(rules[0].identities[0], Identity::Other));
+ assert_eq!(org[0].scope, RuleScope::Organization);
+ assert_eq!(project[0].scope, RuleScope::Project);
+ }
+
+ #[test]
+ fn org_scope_targets_resolve_through_the_same_fenced_maps() {
+ let mut hosts = SecretHosts::default();
+ hosts
+ .by_id
+ .insert("s1".to_string(), vec!["api.example.com".to_string()]);
+ let mut providers = ConnectionProviders::default();
+ providers
+ .by_id
+ .insert("c1".to_string(), "github".to_string());
+ let rows = vec![row(|r| {
+ r.targets = Json(vec![
+ target(json!({"kind": "secret", "secretId": "s1"})),
+ target(json!({"kind": "connection", "appConnectionId": "c1", "appTools": []})),
+ ]);
+ })];
+ let rules = assemble(&rows, RuleScope::Organization, &hosts, &providers);
+ assert!(
+ matches!(&rules[0].targets[0], Target::Secret { host_patterns } if host_patterns == &["api.example.com".to_string()])
+ );
+ assert!(matches!(
+ &rules[0].targets[1],
+ Target::Connection { id, provider, .. } if id == "c1" && provider == "github"
+ ));
}
#[test]
@@ -211,7 +285,12 @@ mod tests {
target(json!({"kind": "connection", "appConnectionId": "missing", "appTools": []})),
]);
})];
- let rules = assemble(&rows, &SecretHosts::default(), &providers);
+ let rules = assemble(
+ &rows,
+ RuleScope::Project,
+ &SecretHosts::default(),
+ &providers,
+ );
assert!(matches!(
&rules[0].targets[0],
Target::Connection { id, provider, .. } if id == "c1" && provider == "github"
@@ -233,7 +312,12 @@ mod tests {
target(json!({"kind": "secret", "secretId": "deleted"})),
]);
})];
- let rules = assemble(&rows, &hosts, &ConnectionProviders::default());
+ let rules = assemble(
+ &rows,
+ RuleScope::Project,
+ &hosts,
+ &ConnectionProviders::default(),
+ );
assert!(
matches!(&rules[0].targets[0], Target::Secret { host_patterns } if host_patterns == &["api.example.com".to_string()])
);
@@ -259,7 +343,12 @@ mod tests {
let rows = vec![row(|r| {
r.targets = Json(vec![target(json!({"kind": "secret", "secretId": "s1"}))]);
})];
- let rules = assemble(&rows, &hosts, &ConnectionProviders::default());
+ let rules = assemble(
+ &rows,
+ RuleScope::Project,
+ &hosts,
+ &ConnectionProviders::default(),
+ );
let Target::Secret { host_patterns } = &rules[0].targets[0] else {
panic!("expected a secret target");
};
@@ -292,6 +381,7 @@ mod tests {
];
let rules = assemble(
&rows,
+ RuleScope::Project,
&SecretHosts::default(),
&ConnectionProviders::default(),
);
diff --git a/apps/gateway/src/policy_engine/enforce.rs b/apps/gateway/src/policy_engine/enforce.rs
index 1e8d0f13..0af4c190 100644
--- a/apps/gateway/src/policy_engine/enforce.rs
+++ b/apps/gateway/src/policy_engine/enforce.rs
@@ -1,8 +1,13 @@
-//! The OSS enforce seam: load the published project rules at connection
-//! resolution and decide requests with the first-match core, producing the
-//! `policy::PolicyDecision` the forward/websocket act-path understands. The engine
-//! is authoritative — an empty rule set (a load error, or an unmigrated project
-//! with no Default Rule) decides `Allow`; there is no fallback.
+//! The OSS enforce seam: load the published org + project rules (and, when a
+//! rule targets a directory identity, the connection's principal set) at
+//! connection resolution and decide requests with the two-level first-match
+//! core, producing the `policy::PolicyDecision` the forward/websocket act-path
+//! understands. The engine is authoritative — there is no legacy fallback.
+//!
+//! Fail-closed: every resolution query PROPAGATES its error (anyhow) so the
+//! caller (`connect.rs`, via `.map_err(db_err)?`) REFUSES the CONNECT rather
+//! than caching a policy-free (allow-everything, inject-nothing) state for the
+//! ~60s cache cycle. The agent simply retries.
//!
//! HIGH PERFORMANCE: rules load ONCE at connection resolution (cached ~60s
//! with the rest of the connect state); the per-request decision path never
@@ -14,14 +19,15 @@ use sqlx::PgPool;
use crate::cache::CacheStore;
use crate::db::{
find_connection_providers, find_published_policy_rules_v2_by_project, find_secret_hosts,
- AvailableApps, ConnectionProviders, PolicyRuleV2Row, PolicyV2Rules, SecretHosts,
+ AvailableApps, ConnectionProviders, PolicyRuleV2Row, PolicyV2Rules, PrincipalSet, SecretHosts,
};
use crate::gateway::{strip_port, ProxyContext};
use crate::policy::{check_rate_limit, MatchedRule, PolicyDecision};
use super::assemble::assemble;
use super::evaluate::evaluate_outcome;
-use super::types::{Action, Outcome, Request, Rule};
+use super::loaders;
+use super::types::{Action, Outcome, Request, Rule, RuleScope};
/// `false` always: OSS's `condition_match` arm cannot buffer bodies and never
/// evaluates conditions (they match vacuously), so there is nothing to buffer for.
@@ -29,37 +35,54 @@ pub(crate) fn needs_body_buffer(_v2: &PolicyV2Rules) -> bool {
false
}
-/// Equipment rows are excluded: they are injection-only (dropped by the
-/// assembler), so their secret/connection targets never need host/provider
-/// resolution — mirroring the EE loader's lazy skip, which keeps the common
-/// selective-agent connect resolution free of the two extra queries.
-fn has_target_kind(rows: &[PolicyRuleV2Row], kind: &str) -> bool {
- rows.iter()
+/// True when any loaded rule (org or project) has a target of `kind`, skipping
+/// equipment rows (injection-only — dropped by the assembler, so their
+/// secret/connection targets never need host/provider resolution). The lazy
+/// gate that keeps the common connect resolution free of the two extra queries.
+fn has_target_kind(levels: &[&[PolicyRuleV2Row]], kind: &str) -> bool {
+ levels
+ .iter()
+ .flat_map(|rows| rows.iter())
.filter(|r| r.source != "equipment")
.any(|r| r.targets.0.iter().any(|t| t.kind == kind))
}
-/// Load the published project rules at resolution time — cached with
-/// `ConnectResponse`, off the per-request hot path. Secret hosts and connection
-/// providers resolve lazily, only when some loaded rule needs them. Any load error
-/// PROPAGATES: the caller refuses the CONNECT rather than caching a policy-free
-/// (allow-everything, inject-nothing) state for the ~60s cache cycle.
+/// Load the published org + project rules (and lazily the principal set) at
+/// resolution time — cached with `ConnectResponse`, off the per-request hot
+/// path. Principals, secret hosts, and connection providers resolve lazily,
+/// only when some loaded rule needs them. Any load error PROPAGATES: the caller
+/// refuses the CONNECT rather than caching a policy-free state for the ~60s
+/// cache cycle.
pub(crate) async fn load_connect_v2(
pool: &PgPool,
org_id: &str,
project_id: &str,
) -> anyhow::Result {
+ let org = loaders::find_published_policy_rules_v2_by_org(pool, org_id)
+ .await
+ .context("policy v2: org load failed at resolution")?;
let project = find_published_policy_rules_v2_by_project(pool, project_id)
.await
.context("policy v2: project load failed at resolution")?;
- let secret_hosts = if has_target_kind(&project, "secret") {
+ // Principals resolve lazily: only when some loaded rule (org or project,
+ // equipment included — inject-selection matches against them too) carries a
+ // directory identity. The set is agent-independent, so `agent_id` is not a
+ // parameter. The common agent-only connect stays at zero extra queries.
+ let principals = if loaders::has_directory_identity(&[&org, &project]) {
+ loaders::load_principal_set(pool, org_id, project_id)
+ .await
+ .context("policy v2: principal resolution failed at resolution")?
+ } else {
+ PrincipalSet::default()
+ };
+ let secret_hosts = if has_target_kind(&[&org, &project], "secret") {
find_secret_hosts(pool, org_id, project_id)
.await
.context("policy v2: secret-host resolution failed at resolution")?
} else {
SecretHosts::default()
};
- let connection_providers = if has_target_kind(&project, "connection") {
+ let connection_providers = if has_target_kind(&[&org, &project], "connection") {
find_connection_providers(pool, org_id, project_id)
.await
.context("policy v2: connection-provider resolution failed at resolution")?
@@ -67,10 +90,11 @@ pub(crate) async fn load_connect_v2(
ConnectionProviders::default()
};
Ok(PolicyV2Rules {
+ org,
project,
+ principals,
secret_hosts,
connection_providers,
- ..PolicyV2Rules::default()
})
}
@@ -122,10 +146,9 @@ async fn decision_for_rule(
PolicyDecision::Allow
}
-/// Decide via the OSS core over the already-resolved project rules. No DB access.
-/// If the identity is somehow incomplete, or the rule set is empty (a load error,
-/// or a project with no published policy), the decision is `Allow` — the engine is
-/// authoritative, so there is no fallback.
+/// Decide via the OSS two-level core over the already-resolved org + project
+/// rules. No DB access. If the identity is somehow incomplete, the decision is
+/// `Allow` — the engine is authoritative, so there is no fallback.
#[allow(clippy::too_many_arguments)]
pub(crate) async fn evaluate(
proxy_ctx: &ProxyContext,
@@ -148,7 +171,18 @@ pub(crate) async fn evaluate(
};
let agent_token = proxy_ctx.agent_token.as_deref().unwrap_or("");
- let rules = assemble(&v2.project, &v2.secret_hosts, &v2.connection_providers);
+ let org_rules = assemble(
+ &v2.org,
+ RuleScope::Organization,
+ &v2.secret_hosts,
+ &v2.connection_providers,
+ );
+ let project_rules = assemble(
+ &v2.project,
+ RuleScope::Project,
+ &v2.secret_hosts,
+ &v2.connection_providers,
+ );
let request = Request {
host: strip_port(host).to_string(),
path: path.to_string(),
@@ -162,9 +196,9 @@ pub(crate) async fn evaluate(
let matched_of = |rule: &Rule| MatchedRule {
logical_id: rule.logical_id.clone(),
name: rule.name.clone(),
- scope: "project".to_string(),
+ scope: rule.scope.as_str().to_string(),
};
- match evaluate_outcome(&rules, &request, body) {
+ match evaluate_outcome(&org_rules, &project_rules, &request, &v2.principals, body) {
Outcome::Rule(rule) => (
decision_for_rule(rule, org_id, project_id, agent_token, cache).await,
Some(matched_of(rule)),
@@ -176,3 +210,131 @@ pub(crate) async fn evaluate(
Outcome::Allow => (PolicyDecision::Allow, None),
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use serde_json::json;
+ use sqlx::types::Json;
+
+ fn row(over: impl FnOnce(&mut PolicyRuleV2Row)) -> PolicyRuleV2Row {
+ let mut r = PolicyRuleV2Row {
+ id: "r1".to_string(),
+ logical_id: "l1".to_string(),
+ name: "rule".to_string(),
+ source: "custom".to_string(),
+ priority: 0,
+ is_default: false,
+ action: "allow".to_string(),
+ rate_limit: None,
+ rate_limit_window: None,
+ require_approval: false,
+ conditions: None,
+ identities: Json(Vec::new()),
+ targets: Json(Vec::new()),
+ };
+ over(&mut r);
+ r
+ }
+
+ fn proxy_ctx() -> ProxyContext {
+ ProxyContext {
+ project_id: Some("p1".to_string()),
+ organization_id: Some("o1".to_string()),
+ agent_id: Some("a1".to_string()),
+ agent_name: None,
+ agent_identifier: None,
+ agent_token: Some("t".to_string()),
+ }
+ }
+
+ fn network_target() -> serde_json::Value {
+ json!({"kind": "network", "hostPattern": "api.example.com"})
+ }
+
+ /// An org rule scoped to a directory GROUP, plus a project Default Rule.
+ fn org_group_block_bundle(principals: PrincipalSet) -> PolicyV2Rules {
+ PolicyV2Rules {
+ org: vec![row(|r| {
+ r.action = "block".to_string();
+ r.identities = Json(
+ serde_json::from_value(json!([
+ {"agentId": null, "userId": null, "groupId": "g1"}
+ ]))
+ .expect("identity rows"),
+ );
+ r.targets = Json(vec![
+ serde_json::from_value(network_target()).expect("target row")
+ ]);
+ })],
+ project: vec![row(|r| r.is_default = true)],
+ principals,
+ ..PolicyV2Rules::default()
+ }
+ }
+
+ async fn run_seam(v2: &PolicyV2Rules) -> (PolicyDecision, Option) {
+ let store = crate::cache::create_store().await.expect("store");
+ evaluate(
+ &proxy_ctx(),
+ "api.example.com",
+ "GET",
+ "/",
+ None,
+ false,
+ false,
+ None,
+ store.as_ref(),
+ v2,
+ )
+ .await
+ }
+
+ /// Test #1/#3: the seam wires `&v2.org` → the org assemble, `&v2.principals`
+ /// → the evaluator (a g1 membership matches), and `matched_of` attributes
+ /// the org scope end-to-end.
+ #[tokio::test]
+ async fn evaluate_enforces_an_org_group_rule_through_the_seam() {
+ let v2 = org_group_block_bundle(PrincipalSet {
+ group_ids: vec!["g1".to_string()],
+ ..PrincipalSet::default()
+ });
+ let (decision, matched) = run_seam(&v2).await;
+ assert!(matches!(decision, PolicyDecision::Blocked { .. }));
+ let m = matched.expect("the winning org rule must be attributed");
+ assert_eq!(m.scope, "organization");
+ }
+
+ /// The companion regression: an EMPTY principal set narrows the same org
+ /// group rule to nothing → Allow. A `PrincipalSet::default()` wired into the
+ /// seam would flip the test above, never this one.
+ #[tokio::test]
+ async fn empty_principals_narrow_the_org_group_rule_to_nothing() {
+ let v2 = org_group_block_bundle(PrincipalSet::default());
+ let (decision, matched) = run_seam(&v2).await;
+ assert!(matches!(decision, PolicyDecision::Allow));
+ assert!(matched.is_none());
+ }
+
+ #[test]
+ fn has_target_kind_scans_org_and_project_and_skips_equipment() {
+ let org = vec![row(|r| {
+ r.targets = Json(vec![serde_json::from_value(
+ json!({"kind": "secret", "secretId": "s1"}),
+ )
+ .expect("target row")]);
+ })];
+ let project: Vec = Vec::new();
+ assert!(has_target_kind(&[&org, &project], "secret"));
+ assert!(!has_target_kind(&[&org, &project], "connection"));
+ // Equipment rows stay excluded — they are injection-only.
+ let equipment = vec![row(|r| {
+ r.source = "equipment".to_string();
+ r.targets = Json(vec![serde_json::from_value(
+ json!({"kind": "secret", "secretId": "s1"}),
+ )
+ .expect("target row")]);
+ })];
+ assert!(!has_target_kind(&[&equipment, &project], "secret"));
+ }
+}
diff --git a/apps/gateway/src/policy_engine/evaluate.rs b/apps/gateway/src/policy_engine/evaluate.rs
index 46511ebd..a2b897ff 100644
--- a/apps/gateway/src/policy_engine/evaluate.rs
+++ b/apps/gateway/src/policy_engine/evaluate.rs
@@ -1,23 +1,35 @@
-//! The OSS first-match evaluator: ONE level (project), the single-level
-//! reduction of the uniform per-level law — the first matching rule decides,
-//! else the project Default Rule is the terminal (its Block gated by the
-//! `enforce_deny` carve), else allow.
+//! The OSS two-level first-match evaluator, mirroring the canonical
+//! `policy-translation/evaluator.ts` (`evaluatePolicyOutcome`): per-scope
+//! first-match (org, then project), combined by STRICTEST (block strictest …
+//! allow loosest), with each level's Default Rule as its fallback verdict
+//! (deny wins), PLUS the HARD-FLOOR rule — a lone ALLOW at one level cannot
+//! open the OTHER level's default-Block. Org-first tie-break.
+//!
+//! Why two levels rather than one merged list: a project rule may shadow a
+//! project sibling, but must NEVER override an org guardrail. A single merged
+//! first-match can honor at most one of "identity beats strictness" and "org is
+//! un-overridable"; splitting org/project and combining by strictest honors both.
//!
//! Matching routes through the gateway's own `connect::host_matches` +
//! `policy::matches_request`, so path globs, methods, the git-receive-pack
//! bridge, and the (no-op in OSS) condition arm are byte-identical to the
//! legacy path.
+use crate::db::PrincipalSet;
use crate::policy::{matches_request, PolicyAction, PolicyRule};
-use super::types::{Identity, Outcome, Request, Rule, Target};
+use super::types::{Action, Identity, Outcome, Request, Rule, Target};
-/// Empty identities = "any agent"; an `Agent` identity matches by id; `Other`
-/// (a stored directory identity) never matches.
-fn identity_matches(rule: &Rule, request: &Request) -> bool {
+/// Empty identities = "any"; an `Agent` identity matches the acting agent by
+/// id; the directory kinds (`User`/`Group`) match against the connection's
+/// resolved principal set; `Other` (a row naming no principal the OSS engine
+/// understands) never matches. Linear scans are fine — principal sets are small.
+fn identity_matches(rule: &Rule, request: &Request, principals: &PrincipalSet) -> bool {
rule.identities.is_empty()
|| rule.identities.iter().any(|i| match i {
Identity::Agent(id) => *id == request.agent_id,
+ Identity::User(id) => principals.user_ids.contains(id),
+ Identity::Group(id) => principals.group_ids.contains(id),
Identity::Other => false,
})
}
@@ -94,8 +106,13 @@ fn target_matches(target: &Target, rule: &Rule, request: &Request, body: Option<
/// them matches. Empty targets = matches NOTHING: "match everything" is the
/// Default Rule's job, never an empty list — which also neutralizes a rule
/// orphaned to zero targets by an FK cascade (fail-closed).
-fn rule_matches(rule: &Rule, request: &Request, body: Option<&[u8]>) -> bool {
- identity_matches(rule, request)
+fn rule_matches(
+ rule: &Rule,
+ request: &Request,
+ principals: &PrincipalSet,
+ body: Option<&[u8]>,
+) -> bool {
+ identity_matches(rule, request, principals)
&& !rule.targets.is_empty()
&& rule
.targets
@@ -103,35 +120,128 @@ fn rule_matches(rule: &Rule, request: &Request, body: Option<&[u8]>) -> bool {
.any(|t| target_matches(t, rule, request, body))
}
-/// First matching non-default rule in `(priority, id)` order. The id tie-break
-/// makes equal priorities total and deterministic, agreeing with the DB's
-/// `ORDER BY r.priority, r.id` (ids are lowercase-hex UUIDs, so Rust byte order
-/// equals the Postgres collation).
-fn first_match<'a>(rules: &'a [Rule], request: &Request, body: Option<&[u8]>) -> Option<&'a Rule> {
+/// Strictness rank, mirroring `strictness.ts::strictnessRank`: block strictest
+/// (0) … allow loosest (3). LOWER is stricter, so the reduce below keeps the
+/// smaller rank. (A rate-limit modifier ranks by its presence alone, exactly
+/// as the TS does — `rateLimit !== null`.)
+fn strictness_rank(rule: &Rule) -> u8 {
+ if rule.action == Action::Block {
+ 0
+ } else if rule.require_approval {
+ 1
+ } else if rule.rate_limit.is_some() {
+ 2
+ } else {
+ 3
+ }
+}
+
+/// A level's first matching non-default rule, carrying its strictness rank.
+#[derive(Clone, Copy)]
+struct LevelMatch<'a> {
+ rank: u8,
+ rule: &'a Rule,
+}
+
+/// First matching non-default rule of one level in `(priority, id)` order. The
+/// id tie-break makes equal priorities total and deterministic, agreeing with
+/// the DB's `ORDER BY r.priority, r.id` (ids are lowercase-hex UUIDs, so Rust
+/// byte order equals the Postgres collation).
+fn first_match<'a>(
+ rules: &'a [Rule],
+ request: &Request,
+ principals: &PrincipalSet,
+ body: Option<&[u8]>,
+) -> Option> {
let mut ordered: Vec<&'a Rule> = rules.iter().filter(|r| !r.is_default).collect();
ordered.sort_by(|a, b| a.priority.cmp(&b.priority).then_with(|| a.id.cmp(&b.id)));
ordered
.into_iter()
- .find(|rule| rule_matches(rule, request, body))
+ .find(|rule| rule_matches(rule, request, principals, body))
+ .map(|rule| LevelMatch {
+ rank: strictness_rank(rule),
+ rule,
+ })
}
-/// Decide the request: the first matching rule wins (allow or block — an
-/// explicit project allow opens its own Default-Block, allowlist-style);
-/// otherwise the project Default Rule is the terminal, its Block enforced only
-/// under the `enforce_deny` carve (credentialed, non-LLM traffic); otherwise
-/// allow. This is exactly the EE evaluator's project arm with no org level
-/// contributing a verdict.
+/// Decide the request under the two-level hard-floor law, a faithful port of
+/// `evaluator.ts::evaluatePolicyOutcome`:
+///
+/// - each level's verdict is its first matching explicit rule (else nothing);
+/// - a Default-Block is a HARD FLOOR at its level (gated by the `enforce_deny`
+/// carve): a lone ALLOW at the OTHER level is DROPPED so it can't open it —
+/// an org allow can't punch through a project allowlist floor, and a project
+/// allow can't punch through an org default-Block; a BLOCK still applies (it
+/// only tightens);
+/// - surviving matches combine by STRICTEST (lower rank wins), org-first on a
+/// tie (the org rate/approval modifier wins);
+/// - with no surviving match the level defaults decide, deny-wins, org-first.
+///
+/// Only ONE rule ever decides — modifiers never stack across levels.
pub(super) fn evaluate_outcome<'a>(
- rules: &'a [Rule],
+ org_rules: &'a [Rule],
+ project_rules: &'a [Rule],
request: &Request,
+ principals: &PrincipalSet,
body: Option<&[u8]>,
) -> Outcome<'a> {
- if let Some(rule) = first_match(rules, request, body) {
- return Outcome::Rule(rule);
+ let org_default = org_rules.iter().find(|r| r.is_default);
+ let project_default = project_rules.iter().find(|r| r.is_default);
+
+ let org_match = first_match(org_rules, request, principals, body);
+ let project_match = first_match(project_rules, request, principals, body);
+
+ // A Default-Block is enforced only under the carve (credentialed, non-LLM),
+ // at EVERY level.
+ let enforce_deny = request.enforce_deny();
+ let org_default_blocks = org_default.is_some_and(|d| d.action == Action::Block) && enforce_deny;
+ let project_default_blocks =
+ project_default.is_some_and(|d| d.action == Action::Block) && enforce_deny;
+
+ // A lone org ALLOW can't punch through the project default-Block (allowlist
+ // mode) — drop it so it falls through to the deny-default. An org BLOCK
+ // still applies (it only tightens). Approval/rate rules are action "allow",
+ // so they defer too — symmetric with the org floor below.
+ let effective_org = if project_match.is_none()
+ && matches!(org_match, Some(m) if m.rule.action == Action::Allow)
+ && project_default_blocks
+ {
+ None
+ } else {
+ org_match
+ };
+
+ // A lone project ALLOW can't punch through the org default-Block — drop it
+ // so it falls through to the deny-default. A project BLOCK still applies (it
+ // only tightens); an allow-posture org lets the project allow win.
+ let effective_project = if org_match.is_none()
+ && matches!(project_match, Some(m) if m.rule.action == Action::Allow)
+ && org_default_blocks
+ {
+ None
+ } else {
+ project_match
+ };
+
+ // Combine by strictest (lower rank = stricter); on a tie keep the org match
+ // (left bias) so the org modifier wins, matching the oracle's org-first pass.
+ let best = [effective_org, effective_project]
+ .into_iter()
+ .flatten()
+ .reduce(|a, b| if b.rank < a.rank { b } else { a });
+ if let Some(best) = best {
+ return Outcome::Rule(best.rule);
}
- let default = rules.iter().find(|r| r.is_default);
- if let Some(d) = default {
- if d.action == super::types::Action::Block && request.enforce_deny() {
+
+ // No explicit rule survived → the level defaults decide; deny wins,
+ // attributed org-first (the org default is checked first at the gateway).
+ if org_default_blocks {
+ if let Some(d) = org_default {
+ return Outcome::DenyDefault(d);
+ }
+ }
+ if project_default_blocks {
+ if let Some(d) = project_default {
return Outcome::DenyDefault(d);
}
}
@@ -140,12 +250,13 @@ pub(super) fn evaluate_outcome<'a>(
#[cfg(test)]
mod tests {
- use super::super::types::{Action, RateWindow};
+ use super::super::types::{Action, RateWindow, RuleScope};
use super::*;
fn rule(id: &str, priority: usize, action: Action) -> Rule {
Rule {
id: id.to_string(),
+ scope: RuleScope::Project,
logical_id: format!("l-{id}"),
name: id.to_string(),
priority,
@@ -164,6 +275,26 @@ mod tests {
}
}
+ fn org_rule(id: &str, priority: usize, action: Action) -> Rule {
+ Rule {
+ scope: RuleScope::Organization,
+ ..rule(id, priority, action)
+ }
+ }
+
+ fn approval_rule(id: &str, priority: usize) -> Rule {
+ let mut r = rule(id, priority, Action::Allow);
+ r.require_approval = true;
+ r
+ }
+
+ fn rate_rule(id: &str, priority: usize) -> Rule {
+ let mut r = rule(id, priority, Action::Allow);
+ r.rate_limit = Some(5);
+ r.rate_limit_window = Some(RateWindow::Minute);
+ r
+ }
+
fn default_rule(action: Action) -> Rule {
let mut r = rule("default", 99, action);
r.is_default = true;
@@ -171,6 +302,24 @@ mod tests {
r
}
+ fn org_default(action: Action) -> Rule {
+ Rule {
+ scope: RuleScope::Organization,
+ ..default_rule(action)
+ }
+ }
+
+ fn no_principals() -> PrincipalSet {
+ PrincipalSet::default()
+ }
+
+ fn principals() -> PrincipalSet {
+ PrincipalSet {
+ user_ids: vec!["u-1".to_string()],
+ group_ids: vec!["g-1".to_string()],
+ }
+ }
+
fn request() -> Request {
Request {
host: "api.example.com".to_string(),
@@ -190,62 +339,12 @@ mod tests {
}
}
- /// The per-account law, all four directions: a `Connection` target matches
- /// iff (the request's winning injected connection == its id) AND the
- /// provider catalog fan-out hits. Lockstep twin of the EE corpus arms
- /// 6b/6c/11/12 and the TS `connection target binds to the winner` block.
- #[test]
- fn connection_target_binds_to_the_winning_connection() {
- let conn_block = |id: &str| {
- let mut r = rule("c-rule", 1, Action::Block);
- r.targets = vec![Target::Connection {
- id: id.to_string(),
- provider: "gmail".to_string(),
- tools: Vec::new(),
- }];
- r
- };
- let req_via = |winner: Option<&str>| Request {
- host: "gmail.googleapis.com".to_string(),
- path: "/gmail/v1/users/me/messages".to_string(),
- method: "GET".to_string(),
- agent_id: "agent-1".to_string(),
- has_injections: true,
- is_llm_host: false,
- winning_connection_id: winner.map(str::to_string),
- };
- let rules = vec![conn_block("c1")];
-
- // Matching winner on the provider's catalog host → the block binds.
- assert!(matches!(
- evaluate_outcome(&rules, &req_via(Some("c1")), None),
- Outcome::Rule(r) if r.action == Action::Block
- ));
- // A same-provider sibling account → no match (the deliberate change
- // from the provider-wide decode).
- assert!(matches!(
- evaluate_outcome(&rules, &req_via(Some("c2")), None),
- Outcome::Allow
- ));
- // No winner (secret-served / uncredentialed) → no match (fail-closed).
- assert!(matches!(
- evaluate_outcome(&rules, &req_via(None), None),
- Outcome::Allow
- ));
- // Winner equality alone is not enough: a host outside the provider's
- // catalog fails the fan-out gate.
- let mut off_host = req_via(Some("c1"));
- off_host.host = "api.github.com".to_string();
- assert!(matches!(
- evaluate_outcome(&rules, &off_host, None),
- Outcome::Allow
- ));
- }
+ // ── Single-level (project) reductions — the org slice is empty ──────
#[test]
fn first_match_wins_by_priority() {
let rules = vec![rule("b", 1, Action::Block), rule("a", 0, Action::Allow)];
- match evaluate_outcome(&rules, &request(), None) {
+ match evaluate_outcome(&[], &rules, &request(), &no_principals(), None) {
Outcome::Rule(r) => assert_eq!(r.id, "a"),
_ => panic!("expected a rule match"),
}
@@ -257,13 +356,14 @@ mod tests {
vec![rule("a", 5, Action::Allow), rule("b", 5, Action::Block)],
vec![rule("b", 5, Action::Block), rule("a", 5, Action::Allow)],
] {
- match evaluate_outcome(&rules, &request(), None) {
+ match evaluate_outcome(&[], &rules, &request(), &no_principals(), None) {
Outcome::Rule(r) => assert_eq!(r.id, "a", "lower id wins the tie"),
_ => panic!("expected a rule match"),
}
}
}
+ /// Test #11 (part): `Other` never matches; empty identities = any.
#[test]
fn agent_identity_scopes_and_other_never_matches() {
let mut agent_scoped = rule("scoped", 0, Action::Block);
@@ -273,40 +373,43 @@ mod tests {
let allow = rule("any", 2, Action::Allow);
let rules = vec![agent_scoped, other, allow];
- match evaluate_outcome(&rules, &request(), None) {
+ match evaluate_outcome(&[], &rules, &request(), &no_principals(), None) {
Outcome::Rule(r) => assert_eq!(r.id, "scoped"),
_ => panic!("expected the agent-scoped match"),
}
let mut foreign = request();
foreign.agent_id = "agent-2".to_string();
- match evaluate_outcome(&rules, &foreign, None) {
+ match evaluate_outcome(&[], &rules, &foreign, &no_principals(), None) {
// The directory identity must NOT match — the any-agent allow wins.
Outcome::Rule(r) => assert_eq!(r.id, "any"),
_ => panic!("expected the any-agent match"),
}
}
+ /// Test #11 (part): an empty-target rule is inert.
#[test]
fn empty_target_rule_is_inert() {
let mut orphan = rule("orphan", 0, Action::Block);
orphan.targets = Vec::new();
let control = rule("control", 1, Action::Allow);
- match evaluate_outcome(&[orphan, control], &request(), None) {
+ match evaluate_outcome(&[], &[orphan, control], &request(), &no_principals(), None) {
Outcome::Rule(r) => assert_eq!(r.id, "control"),
_ => panic!("expected the control match"),
}
}
+ /// Test #10 (project level): the Default Rule Block enforces only under the
+ /// `enforce_deny` carve.
#[test]
fn default_block_enforces_only_under_the_carve() {
let rules = vec![default_rule(Action::Block)];
// Uncredentialed → the carve spares it.
assert!(matches!(
- evaluate_outcome(&rules, &request(), None),
+ evaluate_outcome(&[], &rules, &request(), &no_principals(), None),
Outcome::Allow
));
// Credentialed non-LLM → blocked, attributed to the Default Rule.
- match evaluate_outcome(&rules, &injected_request(), None) {
+ match evaluate_outcome(&[], &rules, &injected_request(), &no_principals(), None) {
Outcome::DenyDefault(d) => assert!(d.is_default),
_ => panic!("expected the deny-default"),
}
@@ -314,17 +417,17 @@ mod tests {
let mut llm = injected_request();
llm.is_llm_host = true;
assert!(matches!(
- evaluate_outcome(&rules, &llm, None),
+ evaluate_outcome(&[], &rules, &llm, &no_principals(), None),
Outcome::Allow
));
}
#[test]
- fn explicit_allow_opens_the_default_block() {
+ fn explicit_allow_opens_the_same_level_default_block() {
let rules = vec![rule("open", 0, Action::Allow), default_rule(Action::Block)];
- match evaluate_outcome(&rules, &injected_request(), None) {
+ match evaluate_outcome(&[], &rules, &injected_request(), &no_principals(), None) {
Outcome::Rule(r) => assert_eq!(r.id, "open"),
- _ => panic!("expected the allow rule to win over the default block"),
+ _ => panic!("expected the allow rule to win over its own default block"),
}
}
@@ -332,28 +435,350 @@ mod tests {
fn default_allow_is_neutral() {
let rules = vec![default_rule(Action::Allow)];
assert!(matches!(
- evaluate_outcome(&rules, &injected_request(), None),
+ evaluate_outcome(&[], &rules, &injected_request(), &no_principals(), None),
Outcome::Allow
));
}
+ /// Test #11 (part): the OSS condition arm is the no-op — a conditioned block
+ /// matches vacuously. This pins the Stage-G seam; if OSS ships real
+ /// condition matching this test must flip with it.
#[test]
fn conditioned_rule_matches_with_no_body_in_oss() {
- // OSS's condition arm is the no-op (vacuously true) — a conditioned
- // block matches exactly like the legacy OSS gateway treated it. This
- // pins the posture; if OSS ever ships real condition matching, this
- // test must flip with it.
let mut conditioned = rule("cond", 0, Action::Block);
conditioned.conditions = serde_json::from_str(
r#"[{"target":"body","operator":"contains","value":"never-present"}]"#,
)
.ok();
- match evaluate_outcome(&[conditioned], &request(), None) {
+ match evaluate_outcome(&[], &[conditioned], &request(), &no_principals(), None) {
Outcome::Rule(r) => assert_eq!(r.id, "cond"),
_ => panic!("expected the conditioned rule to match vacuously"),
}
}
+ // ── Test #7: connection winner-binding, fail-closed both ways ───────
+
+ /// A `Connection` target matches iff (winner == its id) AND the catalog
+ /// fan-out hits — for an ALLOW and a BLOCK alike; no winner → never matches.
+ #[test]
+ fn connection_target_binds_to_the_winning_connection() {
+ let conn_rule = |id: &str, action: Action| {
+ let mut r = rule("c-rule", 1, action);
+ r.targets = vec![Target::Connection {
+ id: id.to_string(),
+ provider: "gmail".to_string(),
+ tools: Vec::new(),
+ }];
+ r
+ };
+ let req_via = |winner: Option<&str>| Request {
+ host: "gmail.googleapis.com".to_string(),
+ path: "/gmail/v1/users/me/messages".to_string(),
+ method: "GET".to_string(),
+ agent_id: "agent-1".to_string(),
+ has_injections: true,
+ is_llm_host: false,
+ winning_connection_id: winner.map(str::to_string),
+ };
+
+ // BLOCK: matching winner binds; no winner → no match (fail-closed).
+ let blk = vec![conn_rule("c1", Action::Block)];
+ assert!(matches!(
+ evaluate_outcome(&[], &blk, &req_via(Some("c1")), &no_principals(), None),
+ Outcome::Rule(r) if r.action == Action::Block
+ ));
+ assert!(matches!(
+ evaluate_outcome(&[], &blk, &req_via(None), &no_principals(), None),
+ Outcome::Allow
+ ));
+ // A same-provider sibling account → no match.
+ assert!(matches!(
+ evaluate_outcome(&[], &blk, &req_via(Some("c2")), &no_principals(), None),
+ Outcome::Allow
+ ));
+
+ // ALLOW: an allow-connection rule over a project default-Block only
+ // opens the door for its OWN winner; no winner → the default-Block
+ // stands (fail-closed for allow too).
+ let allow_over_block = vec![conn_rule("c1", Action::Allow), default_rule(Action::Block)];
+ match evaluate_outcome(
+ &[],
+ &allow_over_block,
+ &req_via(Some("c1")),
+ &no_principals(),
+ None,
+ ) {
+ Outcome::Rule(r) => assert_eq!(r.action, Action::Allow),
+ _ => panic!("winner should open its own connection allow"),
+ }
+ assert!(matches!(
+ evaluate_outcome(
+ &[],
+ &allow_over_block,
+ &req_via(None),
+ &no_principals(),
+ None
+ ),
+ Outcome::DenyDefault(_)
+ ));
+ }
+
+ // ── Test #1: an org-scope rule is enforced and attributed ───────────
+
+ #[test]
+ fn org_rule_is_enforced_and_carries_org_scope() {
+ let org = vec![org_rule("org-block", 0, Action::Block)];
+ match evaluate_outcome(&org, &[], &request(), &no_principals(), None) {
+ Outcome::Rule(r) => {
+ assert_eq!(r.id, "org-block");
+ assert_eq!(r.scope, RuleScope::Organization);
+ }
+ _ => panic!("expected the org block"),
+ }
+ }
+
+ // ── Tests #2/#3/#4: directory identities via the principal set ──────
+
+ #[test]
+ fn user_and_group_identities_match_via_the_principal_set() {
+ for (id, identity) in [
+ ("by-user", Identity::User("u-1".to_string())),
+ ("by-group", Identity::Group("g-1".to_string())),
+ ] {
+ let mut scoped = org_rule(id, 0, Action::Block);
+ scoped.identities = vec![identity];
+ let org = vec![scoped];
+ // Present in the principal set → the rule matches.
+ match evaluate_outcome(&org, &[], &request(), &principals(), None) {
+ Outcome::Rule(r) => assert_eq!(r.id, id),
+ _ => panic!("expected {id} to match via principals"),
+ }
+ // Absent (empty/stale set) → the rule narrows to nothing.
+ assert!(matches!(
+ evaluate_outcome(&org, &[], &request(), &no_principals(), None),
+ Outcome::Allow
+ ));
+ }
+ }
+
+ /// Test #4: cross-org isolation at the match boundary — a rule naming a
+ /// principal absent from THIS connection's set (it belongs to another org's
+ /// directory, so the org-fenced loader never put it here) never matches.
+ #[test]
+ fn a_principal_outside_the_resolved_set_never_matches() {
+ let mut foreign_user = org_rule("foreign-user", 0, Action::Block);
+ foreign_user.identities = vec![Identity::User("u-other".to_string())];
+ let mut foreign_group = org_rule("foreign-group", 1, Action::Block);
+ foreign_group.identities = vec![Identity::Group("g-other".to_string())];
+ let org = vec![foreign_user, foreign_group];
+ assert!(matches!(
+ evaluate_outcome(&org, &[], &request(), &principals(), None),
+ Outcome::Allow
+ ));
+ }
+
+ // ── Test #5: EMPTY-ORG FAIL-OPEN ────────────────────────────────────
+
+ /// An empty org slice must contribute NO verdict — never a phantom block.
+ /// Most orgs have zero org rules (the boot converter writes project-scope
+ /// only), so this is the load-bearing safety property.
+ #[test]
+ fn empty_org_fails_open_not_closed() {
+ // No project rules either → plain allow, even credentialed.
+ assert!(matches!(
+ evaluate_outcome(&[], &[], &injected_request(), &no_principals(), None),
+ Outcome::Allow
+ ));
+ // An empty org slice changes nothing vs the project-only walk.
+ let project = vec![rule("open", 0, Action::Allow), default_rule(Action::Block)];
+ match evaluate_outcome(&[], &project, &injected_request(), &no_principals(), None) {
+ Outcome::Rule(r) => assert_eq!(r.id, "open"),
+ _ => panic!("expected the project allow, not a phantom org block"),
+ }
+ }
+
+ // ── Test #6: empty project → the org level decides ──────────────────
+
+ #[test]
+ fn empty_project_lets_the_org_level_decide() {
+ let org = vec![org_rule("org-allow", 0, Action::Allow)];
+ match evaluate_outcome(&org, &[], &request(), &no_principals(), None) {
+ Outcome::Rule(r) => {
+ assert_eq!(r.id, "org-allow");
+ assert_eq!(r.scope, RuleScope::Organization);
+ }
+ _ => panic!("expected the org allow to decide"),
+ }
+ // An org default-Block over an empty project blocks under the carve.
+ let org = vec![org_default(Action::Block)];
+ match evaluate_outcome(&org, &[], &injected_request(), &no_principals(), None) {
+ Outcome::DenyDefault(d) => assert_eq!(d.scope, RuleScope::Organization),
+ _ => panic!("expected the org deny-default"),
+ }
+ }
+
+ // ── Test #8: two-level stricter-wins ────────────────────────────────
+
+ #[test]
+ fn org_block_overrides_project_allow_and_vice_versa() {
+ // Org guardrail Block beats a project allow…
+ let org = vec![org_rule("org-block", 0, Action::Block)];
+ let project = vec![rule("proj-allow", 0, Action::Allow)];
+ match evaluate_outcome(&org, &project, &request(), &no_principals(), None) {
+ Outcome::Rule(r) => assert_eq!(r.id, "org-block"),
+ _ => panic!("expected the org block"),
+ }
+ // …and symmetrically a project Block survives an org allow.
+ let org = vec![org_rule("org-allow", 0, Action::Allow)];
+ let project = vec![rule("proj-block", 0, Action::Block)];
+ match evaluate_outcome(&org, &project, &request(), &no_principals(), None) {
+ Outcome::Rule(r) => assert_eq!(r.id, "proj-block"),
+ _ => panic!("expected the project block"),
+ }
+ }
+
+ #[test]
+ fn org_approval_beats_project_rate_limit() {
+ let org = vec![{
+ let mut r = approval_rule("org-approval", 0);
+ r.scope = RuleScope::Organization;
+ r
+ }];
+ let project = vec![rate_rule("proj-rate", 0)];
+ match evaluate_outcome(&org, &project, &request(), &no_principals(), None) {
+ Outcome::Rule(r) => assert_eq!(r.id, "org-approval"),
+ _ => panic!("expected the approval to outrank the rate limit"),
+ }
+ }
+
+ #[test]
+ fn equal_rank_rate_limits_attribute_to_the_org_rule() {
+ // Two rate verdicts: only the winner acts, and the equal-rank tie goes
+ // to org (left bias).
+ let org = vec![{
+ let mut r = rate_rule("org-rate", 0);
+ r.scope = RuleScope::Organization;
+ r
+ }];
+ let project = vec![rate_rule("proj-rate", 0)];
+ match evaluate_outcome(&org, &project, &request(), &no_principals(), None) {
+ Outcome::Rule(r) => {
+ assert_eq!(r.id, "org-rate");
+ assert_eq!(r.scope, RuleScope::Organization);
+ }
+ _ => panic!("expected the org rate rule"),
+ }
+ }
+
+ /// Test #11 (part): a level's `Other`-only rule is inert, the empty-identity
+ /// rule at that level still fires, and both levels honor "any".
+ #[test]
+ fn empty_identities_match_any_at_both_levels_and_other_never_does() {
+ let mut malformed = org_rule("malformed", 0, Action::Block);
+ malformed.identities = vec![Identity::Other];
+ let org = vec![malformed, org_rule("org-any", 1, Action::Block)];
+ let project = vec![rule("proj-any", 0, Action::Allow)];
+ match evaluate_outcome(&org, &project, &request(), &principals(), None) {
+ Outcome::Rule(r) => assert_eq!(r.id, "org-any"),
+ _ => panic!("expected the any-identity org block"),
+ }
+ }
+
+ // ── Test #9: the HARD FLOOR, both directions ────────────────────────
+
+ /// A lone project ALLOW cannot open the org default-Block; a lone org ALLOW
+ /// cannot open the project allowlist default-Block. Under the carve both
+ /// fall through to the respective deny-default.
+ #[test]
+ fn a_lone_allow_cannot_open_the_other_levels_default_block() {
+ // Direction 1: org default-Block + lone project allow → org deny-default.
+ let org = vec![org_default(Action::Block)];
+ let project = vec![rule("proj-allow", 0, Action::Allow)];
+ match evaluate_outcome(&org, &project, &injected_request(), &no_principals(), None) {
+ Outcome::DenyDefault(d) => {
+ assert!(d.is_default);
+ assert_eq!(d.scope, RuleScope::Organization);
+ }
+ _ => panic!("the project allow must not punch the org floor"),
+ }
+ // Without the carve the org level allows — the project allow wins.
+ match evaluate_outcome(&org, &project, &request(), &no_principals(), None) {
+ Outcome::Rule(r) => assert_eq!(r.id, "proj-allow"),
+ _ => panic!("expected the project allow off the carve"),
+ }
+
+ // Direction 2: project default-Block (allowlist) + lone org allow →
+ // project deny-default.
+ let org = vec![org_rule("org-allow", 0, Action::Allow)];
+ let project = vec![default_rule(Action::Block)];
+ match evaluate_outcome(&org, &project, &injected_request(), &no_principals(), None) {
+ Outcome::DenyDefault(d) => {
+ assert!(d.is_default);
+ assert_eq!(d.scope, RuleScope::Project);
+ }
+ _ => panic!("the org allow must not punch the project allowlist floor"),
+ }
+ // Without the carve the project level allows — the org allow wins.
+ match evaluate_outcome(&org, &project, &request(), &no_principals(), None) {
+ Outcome::Rule(r) => assert_eq!(r.id, "org-allow"),
+ _ => panic!("expected the org allow off the carve"),
+ }
+ }
+
+ /// The counter-case: a BLOCK is never dropped by the floor logic (it only
+ /// tightens), and an allow-posture opposite level lets the allow through.
+ #[test]
+ fn a_block_survives_the_floor_and_an_allow_posture_lets_an_allow_win() {
+ // A project BLOCK applies even against an org default-Block…
+ let org = vec![org_default(Action::Block)];
+ let project = vec![rule("proj-block", 0, Action::Block)];
+ match evaluate_outcome(&org, &project, &injected_request(), &no_principals(), None) {
+ Outcome::Rule(r) => assert_eq!(r.id, "proj-block"),
+ _ => panic!("a block must survive the org floor"),
+ }
+ // …and with no org default-Block a lone org allow just wins.
+ let org = vec![org_rule("org-allow", 0, Action::Allow)];
+ match evaluate_outcome(&org, &[], &injected_request(), &no_principals(), None) {
+ Outcome::Rule(r) => assert_eq!(r.id, "org-allow"),
+ _ => panic!("expected the org allow"),
+ }
+ }
+
+ // ── Test #10: deny-default carve per level ──────────────────────────
+
+ #[test]
+ fn org_default_block_carve_gates_each_level_independently() {
+ // Org default-Block: spared off the carve, blocks under it.
+ let org = vec![org_default(Action::Block)];
+ assert!(matches!(
+ evaluate_outcome(&org, &[], &request(), &no_principals(), None),
+ Outcome::Allow
+ ));
+ match evaluate_outcome(&org, &[], &injected_request(), &no_principals(), None) {
+ Outcome::DenyDefault(d) => assert_eq!(d.scope, RuleScope::Organization),
+ _ => panic!("expected the org deny-default under the carve"),
+ }
+ // Project default-Block: same carve, independently.
+ let project = vec![default_rule(Action::Block)];
+ assert!(matches!(
+ evaluate_outcome(&[], &project, &request(), &no_principals(), None),
+ Outcome::Allow
+ ));
+ match evaluate_outcome(&[], &project, &injected_request(), &no_principals(), None) {
+ Outcome::DenyDefault(d) => assert_eq!(d.scope, RuleScope::Project),
+ _ => panic!("expected the project deny-default under the carve"),
+ }
+ }
+
+ #[test]
+ fn absent_project_default_with_org_default_allow_is_allow() {
+ let org = vec![org_default(Action::Allow)];
+ assert!(matches!(
+ evaluate_outcome(&org, &[], &injected_request(), &no_principals(), None),
+ Outcome::Allow
+ ));
+ }
+
#[test]
fn rate_window_secs_mapping() {
assert_eq!(RateWindow::Minute.secs(), 60);
diff --git a/apps/gateway/src/policy_engine/loaders.rs b/apps/gateway/src/policy_engine/loaders.rs
new file mode 100644
index 00000000..2dfcc4be
--- /dev/null
+++ b/apps/gateway/src/policy_engine/loaders.rs
@@ -0,0 +1,200 @@
+//! The OSS analogs of the EE overlay loaders: the org-scope published-rule
+//! query and the connection's principal-set resolution. OSS-only by
+//! construction — the EE builds swap the whole `policy_engine` tree (with
+//! their own loaders) via `#[path]` in `main.rs`, so nothing here can collide
+//! with the enterprise overlay on merge.
+//!
+//! Both run ONCE at connection resolution (cached with `ConnectResponse`);
+//! the per-request decision path never touches the DB.
+
+use anyhow::{Context, Result};
+use sqlx::PgPool;
+
+use crate::db::{PolicyRuleV2Row, PrincipalSet, POLICY_V2_SELECT};
+
+/// Active published ORG-scope rules (max published generation), first-match
+/// ordered. Mirrors `db::find_published_policy_rules_v2_by_project` exactly —
+/// same SELECT, same generation law, same `ORDER BY priority, id` — with the
+/// org arm's fence (`organization_id` + `scope = 'organization'`).
+pub(super) async fn find_published_policy_rules_v2_by_org(
+ pool: &PgPool,
+ organization_id: &str,
+) -> Result> {
+ sqlx::query_as::<_, PolicyRuleV2Row>(&format!(
+ r#"{POLICY_V2_SELECT}
+ WHERE r.organization_id = $1 AND r.scope = 'organization'
+ AND r.status = 'published' AND r.enabled = true
+ AND r.generation = (
+ SELECT max(generation) FROM policy_rules_v2
+ WHERE organization_id = $1 AND scope = 'organization' AND status = 'published')
+ ORDER BY r.priority, r.id"#
+ ))
+ .bind(organization_id)
+ .fetch_all(pool)
+ .await
+ .context("querying org policy_rules_v2 by organization_id")
+}
+
+/// One resolved principal set: the two text[] columns of the CTE below.
+#[derive(sqlx::FromRow)]
+struct PrincipalRow {
+ user_ids: Vec,
+ group_ids: Vec,
+}
+
+/// Resolve the connection's principal set — the humans a proxied request is
+/// matched against, and the directory groups they carry. Proxied traffic bears
+/// no connecting-user identity (`ProxyContext` is agent-only), so the set is
+/// AGENT-INDEPENDENT: one resolution covers every agent of the project. A pure
+/// mirror of `resolvePrincipalSet`
+/// (packages/api/src/services/policy-simulate/principal-set.ts):
+///
+/// - `direct_users` = ProjectAccess rows naming a user;
+/// - `direct_groups` = ProjectAccess rows naming a group, ORG-FENCED FIRST
+/// (a granted group must belong to this org);
+/// - `candidate_users` = direct_users ∪ members of the (org-fenced) direct_groups;
+/// - `user_ids` = candidate_users ∩ ACTIVE org members (status <> 'suspended',
+/// mirroring the people-gate `user_can_manage_project`);
+/// - `group_ids` = direct_groups ∪ every group the resolved user_ids belong to,
+/// the latter ORG-FENCED (a user can belong to OTHER orgs' groups).
+///
+/// Every arm is org-fenced, so a foreign group grant or a user's membership in
+/// another org's groups can never leak in. Role-agnostic (presence-only). Run
+/// as ONE indexed CTE round-trip (off the hot path — the gateway resolves this
+/// at connect, cached with `ConnectResponse`). Keep in lockstep with the TS.
+pub(super) async fn load_principal_set(
+ pool: &PgPool,
+ organization_id: &str,
+ project_id: &str,
+) -> Result {
+ let row: PrincipalRow = sqlx::query_as::<_, PrincipalRow>(
+ r#"
+ WITH access AS (
+ SELECT user_id, group_id FROM project_access WHERE project_id = $1
+ ),
+ direct_users AS (
+ SELECT user_id FROM access WHERE user_id IS NOT NULL
+ ),
+ direct_groups AS (
+ SELECT g.id FROM groups g
+ WHERE g.id IN (SELECT group_id FROM access WHERE group_id IS NOT NULL)
+ AND g.organization_id = $2
+ ),
+ candidate_users AS (
+ SELECT user_id FROM direct_users
+ UNION
+ SELECT gm.user_id FROM group_members gm
+ WHERE gm.group_id IN (SELECT id FROM direct_groups)
+ ),
+ active_users AS (
+ SELECT om.user_id FROM organization_members om
+ WHERE om.user_id IN (SELECT user_id FROM candidate_users)
+ AND om.organization_id = $2
+ AND om.status <> 'suspended'
+ ),
+ user_groups AS (
+ SELECT gm.group_id FROM group_members gm
+ JOIN groups g ON g.id = gm.group_id AND g.organization_id = $2
+ WHERE gm.user_id IN (SELECT user_id FROM active_users)
+ )
+ SELECT
+ COALESCE((SELECT array_agg(DISTINCT user_id) FROM active_users), '{}') AS user_ids,
+ COALESCE((
+ SELECT array_agg(DISTINCT gid) FROM (
+ SELECT id AS gid FROM direct_groups
+ UNION
+ SELECT group_id AS gid FROM user_groups
+ ) g
+ ), '{}') AS group_ids
+ "#,
+ )
+ .bind(project_id)
+ .bind(organization_id)
+ .fetch_one(pool)
+ .await
+ .context("resolving the connection principal set")?;
+
+ Ok(PrincipalSet {
+ user_ids: row.user_ids,
+ group_ids: row.group_ids,
+ })
+}
+
+/// True when any loaded rule (org or project, every source — equipment rows
+/// matter for inject-selection) carries a directory identity row (user or
+/// group). The lazy gate on principal resolution: agent-only configs skip the
+/// resolution query entirely. There is no agent-group column, so only user/group
+/// rows trigger it.
+pub(super) fn has_directory_identity(levels: &[&[PolicyRuleV2Row]]) -> bool {
+ levels.iter().flat_map(|rows| rows.iter()).any(|r| {
+ r.identities
+ .0
+ .iter()
+ .any(|i| i.user_id.is_some() || i.group_id.is_some())
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use serde_json::json;
+ use sqlx::types::Json;
+
+ fn row(identities: serde_json::Value, source: &str) -> PolicyRuleV2Row {
+ PolicyRuleV2Row {
+ id: "r1".to_string(),
+ logical_id: "l1".to_string(),
+ name: "rule".to_string(),
+ source: source.to_string(),
+ priority: 0,
+ is_default: false,
+ action: "allow".to_string(),
+ rate_limit: None,
+ rate_limit_window: None,
+ require_approval: false,
+ conditions: None,
+ identities: Json(serde_json::from_value(identities).expect("identities")),
+ targets: Json(Vec::new()),
+ }
+ }
+
+ fn identity(v: serde_json::Value) -> serde_json::Value {
+ json!([v])
+ }
+
+ #[test]
+ fn agent_only_rows_do_not_trigger_principal_resolution() {
+ let rows = vec![
+ row(json!([]), "custom"),
+ row(
+ identity(json!({"agentId": "a1", "userId": null, "groupId": null})),
+ "custom",
+ ),
+ ];
+ assert!(!has_directory_identity(&[&rows, &[]]));
+ }
+
+ #[test]
+ fn each_directory_kind_triggers_principal_resolution() {
+ for principal in [
+ json!({"agentId": null, "userId": "u1", "groupId": null}),
+ json!({"agentId": null, "userId": null, "groupId": "g1"}),
+ ] {
+ let rows = vec![row(identity(principal), "custom")];
+ assert!(has_directory_identity(&[&rows, &[]]));
+ }
+ }
+
+ #[test]
+ fn scans_both_levels_and_counts_equipment_rows() {
+ let org: Vec = Vec::new();
+ // An equipment row's directory identity matters (inject-selection reads
+ // equipment rows), so it must trigger resolution too.
+ let project = vec![row(
+ identity(json!({"agentId": null, "userId": null, "groupId": "g1"})),
+ "equipment",
+ )];
+ assert!(has_directory_identity(&[&org, &project]));
+ assert!(!has_directory_identity(&[&org, &[]]));
+ }
+}
diff --git a/apps/gateway/src/policy_engine/types.rs b/apps/gateway/src/policy_engine/types.rs
index 56f32ba5..85470964 100644
--- a/apps/gateway/src/policy_engine/types.rs
+++ b/apps/gateway/src/policy_engine/types.rs
@@ -1,7 +1,9 @@
-//! Shapes for the OSS project-level policy core: the decoded rule, the request
-//! context, and the evaluation outcome. Project scope only — OSS has no org
-//! layer, no directory identities, and no granular conditions; those live in
-//! the EE engine this module replaces under `edition_oss`.
+//! Shapes for the OSS policy core: the decoded rule, the request context, and
+//! the evaluation outcome. Org + project scopes with agent and directory
+//! (user/group) identities — granular conditions stay vacuous here; those live
+//! in the EE engine this module replaces under `edition_oss`. There is no
+//! agent-group concept: it was deleted, so no identity kind, principal column,
+//! or loader references one.
/// The rule verdict: the v2 binary. Approval and rate limits are modifiers on
/// `Allow` (see `Rule`).
@@ -29,14 +31,34 @@ impl RateWindow {
}
}
-/// A rule identity. OSS rules target a specific agent or all agents (empty
-/// identity list = "any"). `Other` covers every non-agent identity row a
-/// permissive API client might have stored (user/group are OneCLI Cloud
-/// capabilities) — it NEVER matches, so such a row narrows to nothing
-/// instead of silently widening to "any" (fail-closed).
+/// Which scope a decoded rule came from. Drives `MatchedRule.scope` (telemetry
+/// attribution) and the org-first tie-break in the two-level evaluator.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(super) enum RuleScope {
+ Organization,
+ Project,
+}
+
+impl RuleScope {
+ pub(super) fn as_str(self) -> &'static str {
+ match self {
+ RuleScope::Organization => "organization",
+ RuleScope::Project => "project",
+ }
+ }
+}
+
+/// A rule identity (empty identity list = "any"). `Agent` matches the acting
+/// agent by id; the directory kinds (`User`/`Group`) match against the
+/// connection's resolved `PrincipalSet`. `Other` covers a row naming NO
+/// principal the OSS engine understands (malformed, or a future kind) — it
+/// NEVER matches, so such a row narrows to nothing instead of silently
+/// widening to "any" (fail-closed).
#[derive(Debug, Clone)]
pub(super) enum Identity {
Agent(String),
+ User(String),
+ Group(String),
Other,
}
@@ -73,11 +95,12 @@ pub(super) enum Target {
Unresolved,
}
-/// A decoded project rule the evaluator walks. No `scope` field — everything
-/// here is project scope (`MatchedRule.scope` is the constant "project").
+/// A decoded rule the evaluator walks, tagged with the scope it came from.
#[derive(Debug, Clone)]
pub(super) struct Rule {
pub id: String,
+ /// The level (org guardrail vs project) this rule decides for.
+ pub scope: RuleScope,
/// Generation-stable identity — the shared rate counter keys on it, so the
/// count survives republishes.
pub logical_id: String,
@@ -115,14 +138,14 @@ pub(super) struct Request {
impl Request {
/// The deny-default carve: only credentialed, non-LLM traffic can be
- /// blocked by the Default Rule. Mirrors `forward.rs`'s `enforce_deny`.
+ /// blocked by a Default Rule. Mirrors `forward.rs`'s `enforce_deny`.
pub(super) fn enforce_deny(&self) -> bool {
self.has_injections && !self.is_llm_host
}
}
-/// The winning outcome of an evaluation: an explicit matching rule, the
-/// project Default Rule's enforced Block (carrying THAT rule, so telemetry can
+/// The winning outcome of an evaluation: an explicit matching rule, a level's
+/// Default Rule's enforced Block (carrying THAT rule, so telemetry can
/// attribute it — always concrete, never anonymous), or a plain allow.
pub(super) enum Outcome<'a> {
Rule(&'a Rule),
From 21c248cc950c8d34d1c47390b985859dfb670d6b Mon Sep 17 00:00:00 2001
From: marcorivm
Date: Wed, 29 Jul 2026 20:56:01 -0600
Subject: [PATCH 05/10] feat(gateway): re-land body/header condition matching
onto 1.44.0
Reconciliation Stage G. condition_match.rs swaps its OSS no-op for the
real matcher (memchr body contains/equals/regex, size-limited regex cache,
case-insensitive header ops, 256 KiB cap); MatchInput/BodyCapture and the
body buffer re-thread onto upstream's rewritten forward.rs, and Stage F's
two-level evaluation carries conditions at both org and project scope for
free. Conditions are honored on connection, whole-app, secret, and network
targets (the two ignored-target bugs from the original Tier 3a are fixed
here too). Fail-closed by action at both scopes: any unevaluable condition
makes a Block match and every other rule fall through. Streaming stays the
default; buffered bodies reach upstream byte-identical. +22 tests, no
agent-group, no migration.
---
apps/gateway/Cargo.lock | 1 +
apps/gateway/Cargo.toml | 4 +
apps/gateway/src/condition_match.rs | 696 +++++++++++++++++-
apps/gateway/src/gateway/forward.rs | 70 +-
apps/gateway/src/gateway/websocket.rs | 13 +-
apps/gateway/src/main.rs | 9 +-
apps/gateway/src/policy.rs | 173 ++++-
apps/gateway/src/policy_engine/catalog.rs | 157 +++-
apps/gateway/src/policy_engine/enforce.rs | 112 ++-
apps/gateway/src/policy_engine/evaluate.rs | 627 ++++++++++++++--
apps/gateway/src/policy_engine/types.rs | 7 +-
apps/web/src/lib/api/types.ts | 7 +-
.../src/lib/components/condition-builder.tsx | 17 -
.../condition-builder/condition-builder.tsx | 86 +++
.../condition-builder/condition-row.tsx | 148 ++++
.../lib/components/condition-builder/index.ts | 3 +
.../components/condition-builder/validate.ts | 38 +
.../lib/policy-editor/how-rules-evaluated.tsx | 5 +
.../lib/policy-editor/policy-rule-form.tsx | 39 +-
.../api/src/validations/condition-syntax.ts | 38 +
.../api/src/validations/policy-rule.test.ts | 166 +++++
packages/api/src/validations/policy-rule.ts | 63 +-
packages/api/src/validations/policy.test.ts | 24 +
23 files changed, 2292 insertions(+), 211 deletions(-)
delete mode 100644 apps/web/src/lib/components/condition-builder.tsx
create mode 100644 apps/web/src/lib/components/condition-builder/condition-builder.tsx
create mode 100644 apps/web/src/lib/components/condition-builder/condition-row.tsx
create mode 100644 apps/web/src/lib/components/condition-builder/index.ts
create mode 100644 apps/web/src/lib/components/condition-builder/validate.ts
create mode 100644 packages/api/src/validations/condition-syntax.ts
create mode 100644 packages/api/src/validations/policy-rule.test.ts
diff --git a/apps/gateway/Cargo.lock b/apps/gateway/Cargo.lock
index 6e62c693..ea04a10f 100644
--- a/apps/gateway/Cargo.lock
+++ b/apps/gateway/Cargo.lock
@@ -2355,6 +2355,7 @@ dependencies = [
"hyper 1.8.1",
"hyper-util",
"jsonwebtoken",
+ "memchr",
"percent-encoding",
"rcgen",
"redis",
diff --git a/apps/gateway/Cargo.toml b/apps/gateway/Cargo.toml
index ba91eeb1..14fdcf0d 100644
--- a/apps/gateway/Cargo.toml
+++ b/apps/gateway/Cargo.toml
@@ -59,6 +59,10 @@ dashmap = "6"
# catastrophic backtracking (no ReDoS). Compiled patterns are cached in a DashMap.
regex = "1"
+# Linear-time byte-substring search for body condition matching (attacker-
+# controlled request bodies). Already in the tree via `regex`.
+memchr = "2"
+
# Base64 (for gateway auth)
base64 = "0.22"
diff --git a/apps/gateway/src/condition_match.rs b/apps/gateway/src/condition_match.rs
index 7b91098d..eea3b98d 100644
--- a/apps/gateway/src/condition_match.rs
+++ b/apps/gateway/src/condition_match.rs
@@ -1,14 +1,698 @@
-use crate::policy::PolicyRule;
+//! OSS body/header condition matching (Tier 3a).
+//!
+//! A rule's `conditions` JSON (validated server-side as `RuleCondition[]`)
+//! further narrows when the rule applies: every condition must hold (AND —
+//! exactly like `method` + `path_pattern` already AND together). Two targets:
+//!
+//! - `body`: a raw byte-level match over the fully buffered request body
+//! (`contains` / `equals` / `regex` via `regex::bytes` — linear-time, no
+//! ReDoS, no lossy UTF-8 conversion so binary bodies can't dodge a needle).
+//! - `header`: matched against the request headers. Header NAMES are
+//! case-insensitive (RFC 9110, free with `HeaderMap`); header VALUES are
+//! compared case-sensitively on raw bytes (`(?i)` regex serves the
+//! case-insensitive cases); any value of a multi-value header satisfies the
+//! condition. `exists` (header-only) needs at least one value present.
+//!
+//! ## Failure law (SECURITY)
+//!
+//! A condition that cannot be evaluated — malformed JSON, unknown
+//! target/operator, missing required value/key, an uncompilable regex, or a
+//! body that exceeded the buffer cap — must never weaken enforcement:
+//! the rule MATCHES if it is a Block rule (over-block, fail-closed) and does
+//! NOT match otherwise (an Allow-family rule falls through to the next rule /
+//! the Default Rule instead of silently widening). The v2 engine routes its
+//! rules through here via pseudo-rules that carry the owning rule's Block
+//! action for exactly this reason (see `policy_engine/evaluate.rs`).
+//!
+//! A rule's `conditions` may also be a JSON OBJECT — a connection target's
+//! granular session policy (`{repositories: […]}` / `{folders: […]}`), not a
+//! behavioral condition. Those are vacuous here (Tier 3b's `granular_access`
+//! concern), matching the server-side `isSessionPolicy` discriminator.
-/// OSS stub: conditions match vacuously (no body inspection).
-pub(crate) fn matches(_rule: &PolicyRule, _body: Option<&[u8]>) -> bool {
- true
+use std::collections::HashMap;
+use std::sync::{Mutex, OnceLock};
+
+use hyper::body::Bytes;
+use hyper::header::{HeaderName, HeaderValue};
+use tracing::{debug, warn};
+
+use crate::policy::{BodyCapture, MatchInput, PolicyAction, PolicyRule};
+
+/// Maximum request body buffered for condition matching. A larger body is
+/// forwarded intact but becomes unevaluable for body conditions (→ the
+/// failure law: Block rules over-block, Allow rules fall through). No
+/// prefix-only matching — an attacker could push the needle past any prefix.
+pub(crate) const CONDITION_BODY_CAP: usize = 256 * 1024;
+
+/// One decoded behavioral condition (the server-validated `RuleCondition`
+/// shape). Unknown FIELDS fail to decode (`deny_unknown_fields`) and unknown
+/// target/operator VALUES decode but evaluate to `Invalid` — both route
+/// through the fail-closed law, so a NEWER authoring surface (say, a future
+/// `negate` flag) can never silently widen an older gateway.
+#[derive(Debug, serde::Deserialize)]
+#[serde(deny_unknown_fields)]
+struct RuleCondition {
+ target: String,
+ operator: String,
+ #[serde(default)]
+ value: Option,
+ #[serde(default)]
+ key: Option,
+}
+
+/// Three-state condition evaluation. `Invalid` = unevaluable, routed through
+/// the failure law.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum CondEval {
+ Match,
+ NoMatch,
+ Invalid,
+}
+
+/// The decoded shape of a rule's `conditions` JSON.
+enum DecodedConditions {
+ /// None / session-policy object / empty array → no behavioral conditions.
+ Vacuous,
+ /// A behavioral array; each element decoded independently so one malformed
+ /// element poisons only itself (→ `Invalid`), not its siblings.
+ Behavioral(Vec>),
+}
+
+fn decode_conditions(raw: &Option) -> DecodedConditions {
+ match raw {
+ None => DecodedConditions::Vacuous,
+ // An object is a connection target's granular session policy
+ // (`repositories`/`folders`) — scoping, not a behavioral condition.
+ Some(serde_json::Value::Object(_)) => DecodedConditions::Vacuous,
+ Some(serde_json::Value::Array(items)) if items.is_empty() => DecodedConditions::Vacuous,
+ Some(serde_json::Value::Array(items)) => DecodedConditions::Behavioral(
+ items
+ .iter()
+ .map(|item| serde_json::from_value::(item.clone()).map_err(|_| ()))
+ .collect(),
+ ),
+ // Any other JSON shape is malformed → one unevaluable condition.
+ Some(_) => DecodedConditions::Behavioral(vec![Err(())]),
+ }
+}
+
+/// Whether a rule's `conditions` JSON contains at least one BODY condition —
+/// the buffering predicate's core. Header-only conditions never buffer
+/// (headers are always available). Elements that fail to decode do NOT count:
+/// they evaluate to `Invalid` regardless of body content, so the body is
+/// never needed to decide them.
+pub(crate) fn has_body_condition(raw: &Option) -> bool {
+ match decode_conditions(raw) {
+ DecodedConditions::Vacuous => false,
+ DecodedConditions::Behavioral(conds) => conds
+ .iter()
+ .any(|c| matches!(c, Ok(cond) if cond.target == "body")),
+ }
+}
+
+/// True iff any rule carries a body condition. Header conditions do not trigger
+/// buffering. Kept for API symmetry and unit tests; the v2 forward path uses
+/// the host-scoped `policy_engine::needs_body_buffer` instead.
+#[allow(dead_code)]
+pub(crate) fn needs_body_buffer(rules: &[PolicyRule]) -> bool {
+ rules.iter().any(|r| has_body_condition(&r.conditions_raw))
+}
+
+// ── Evaluation ──────────────────────────────────────────────────────────
+
+/// Byte-substring search (an empty needle matches anything). Linear-time
+/// (`memchr::memmem`) — the haystack is an attacker-controlled request body,
+/// so a naive O(haystack × needle) scan would be a cheap CPU-DoS amplifier.
+fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool {
+ memchr::memmem::find(haystack, needle).is_some()
+}
+
+/// Compiled-program cap per pattern (1 MiB — ample for the API's 1000-char
+/// patterns). The crate default is 10 MiB, which would let a rule author pin
+/// gigabytes of compiled programs in the process-wide cache via nested
+/// repetitions; an over-limit pattern fails to compile and routes through the
+/// existing `Invalid` fail-closed path.
+const REGEX_SIZE_LIMIT: usize = 1 << 20;
+
+fn compile_regex(pattern: &str) -> Option {
+ regex::bytes::RegexBuilder::new(pattern)
+ .size_limit(REGEX_SIZE_LIMIT)
+ .build()
+ .ok()
+}
+
+/// Compile (or fetch) a `regex::bytes` pattern through a bounded process-wide
+/// cache; `None` caches a compile failure so a broken pattern doesn't
+/// recompile per request. On cache overflow, compile uncached (correctness
+/// identical, just slower).
+fn compiled_regex(pattern: &str) -> Option {
+ static CACHE: OnceLock>>> = OnceLock::new();
+ const CACHE_CAP: usize = 256;
+ let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
+ if let Ok(mut map) = cache.lock() {
+ if let Some(cached) = map.get(pattern) {
+ return cached.clone();
+ }
+ let compiled = compile_regex(pattern);
+ if map.len() < CACHE_CAP {
+ map.insert(pattern.to_string(), compiled.clone());
+ }
+ return compiled;
+ }
+ compile_regex(pattern)
+}
+
+/// Apply a value operator (`contains`/`equals`/`regex`) over raw bytes.
+fn eval_operator(operator: &str, haystack: &[u8], value: &str) -> CondEval {
+ match operator {
+ "contains" => {
+ if contains_bytes(haystack, value.as_bytes()) {
+ CondEval::Match
+ } else {
+ CondEval::NoMatch
+ }
+ }
+ "equals" => {
+ if haystack == value.as_bytes() {
+ CondEval::Match
+ } else {
+ CondEval::NoMatch
+ }
+ }
+ "regex" => match compiled_regex(value) {
+ Some(re) if re.is_match(haystack) => CondEval::Match,
+ Some(_) => CondEval::NoMatch,
+ None => CondEval::Invalid,
+ },
+ _ => CondEval::Invalid,
+ }
+}
+
+fn eval_body_condition(cond: &RuleCondition, input: &MatchInput<'_>) -> CondEval {
+ // `exists` is header-only ("has a body" is not a meaningful policy).
+ if cond.operator == "exists" {
+ return CondEval::Invalid;
+ }
+ // Over-cap body: unevaluable (never a prefix match — see the module doc).
+ if input.body_truncated {
+ return CondEval::Invalid;
+ }
+ let Some(value) = cond.value.as_deref() else {
+ return CondEval::Invalid;
+ };
+ // Absent body is a FACT, not a failure: `needs_body_buffer` is a superset
+ // of "a body condition could be consulted", so `None` here genuinely means
+ // the request had no body (GETs, WS upgrades) → match against empty.
+ let body = input.body.unwrap_or(&[]);
+ eval_operator(&cond.operator, body, value)
+}
+
+fn eval_header_condition(cond: &RuleCondition, input: &MatchInput<'_>) -> CondEval {
+ let Some(key) = cond.key.as_deref().filter(|k| !k.trim().is_empty()) else {
+ return CondEval::Invalid;
+ };
+ // Header-name lookup is case-insensitive via HeaderMap; a name that isn't
+ // a valid header name can never have been sent → unevaluable.
+ let Ok(name) = HeaderName::from_bytes(key.as_bytes()) else {
+ return CondEval::Invalid;
+ };
+ let values: Vec<&HeaderValue> = match input.headers {
+ Some(headers) => headers.get_all(&name).iter().collect(),
+ None => Vec::new(),
+ };
+ if cond.operator == "exists" {
+ return if values.is_empty() {
+ CondEval::NoMatch
+ } else {
+ CondEval::Match
+ };
+ }
+ let Some(value) = cond.value.as_deref() else {
+ return CondEval::Invalid;
+ };
+ // Any value of a multi-value header satisfies the condition; values are
+ // compared case-sensitively on raw bytes (`(?i)` regex for insensitive).
+ let mut result = CondEval::NoMatch;
+ for v in values {
+ match eval_operator(&cond.operator, v.as_bytes(), value) {
+ CondEval::Match => return CondEval::Match,
+ CondEval::Invalid => return CondEval::Invalid,
+ CondEval::NoMatch => result = CondEval::NoMatch,
+ }
+ }
+ result
+}
+
+fn eval_condition(cond: &RuleCondition, input: &MatchInput<'_>) -> CondEval {
+ match cond.target.as_str() {
+ // `key` on a body condition is accepted-but-ignored (reserved; a
+ // JSON-path narrowing could use it later without breaking anything).
+ "body" => eval_body_condition(cond, input),
+ "header" => eval_header_condition(cond, input),
+ _ => CondEval::Invalid,
+ }
+}
+
+/// Warn ONCE per rule name that a condition is unevaluable (a stored broken
+/// rule would otherwise log per request — per pseudo-rule variant on tool
+/// fan-outs — and flood a busy host); repeats land at `debug!`. The seen-set
+/// is bounded: past the cap, new names also log at debug (never unbounded
+/// memory for log bookkeeping).
+fn log_unevaluable(rule_name: &str, is_block: bool) {
+ use std::collections::HashSet;
+ static SEEN: OnceLock>> = OnceLock::new();
+ const SEEN_CAP: usize = 1024;
+ let first = SEEN
+ .get_or_init(|| Mutex::new(HashSet::new()))
+ .lock()
+ .map(|mut seen| {
+ !seen.contains(rule_name) && seen.len() < SEEN_CAP && seen.insert(rule_name.to_string())
+ })
+ .unwrap_or(true);
+ let outcome = if is_block {
+ "failing closed (rule matches)"
+ } else {
+ "rule falls through"
+ };
+ if first {
+ warn!(rule = %rule_name, is_block, "policy: unevaluable rule condition — {outcome}");
+ } else {
+ debug!(rule = %rule_name, is_block, "policy: unevaluable rule condition — {outcome}");
+ }
+}
+
+/// Does the rule's condition set hold for this request? Vacuously true without
+/// behavioral conditions; else ALL conditions must match (AND). Any
+/// unevaluable condition applies the failure law: the rule matches iff it is
+/// a Block rule (see the module doc).
+pub(crate) fn matches(rule: &PolicyRule, input: &MatchInput<'_>) -> bool {
+ let conds = match decode_conditions(&rule.conditions_raw) {
+ DecodedConditions::Vacuous => return true,
+ DecodedConditions::Behavioral(conds) => conds,
+ };
+ let mut all_match = true;
+ for cond in &conds {
+ let eval = match cond {
+ Ok(cond) => eval_condition(cond, input),
+ Err(()) => CondEval::Invalid,
+ };
+ match eval {
+ CondEval::Match => {}
+ CondEval::NoMatch => all_match = false,
+ CondEval::Invalid => {
+ let is_block = matches!(rule.action, PolicyAction::Block);
+ log_unevaluable(&rule.name, is_block);
+ return is_block;
+ }
+ }
+ }
+ all_match
+}
+
+// ── Body buffering ──────────────────────────────────────────────────────
+
+/// What `buffer_up_to` produced: either the complete body, or the buffered
+/// prefix plus the UNREAD remainder of the stream.
+enum BufferOutcome {
+ /// The body ended within the cap — these are ALL its bytes.
+ Complete(Vec),
+ /// The cap was exceeded: the prefix read so far (cap+ε — frame-granular)
+ /// and the rest of the body, still unread.
+ Exceeded(Vec, B),
+}
+
+/// Accumulate DATA frames until the body ends or the cap is exceeded.
+/// Trailers are dropped when the body completes within the cap — the same
+/// pre-existing posture as the fully-buffered default-interception branch in
+/// forward.rs (HTTP/1 chunked trailers are vanishingly rare on API traffic).
+async fn buffer_up_to(mut body: B, cap: usize) -> anyhow::Result>
+where
+ B: hyper::body::Body + Unpin,
+ B::Error: std::error::Error + Send + Sync + 'static,
+{
+ use http_body_util::BodyExt;
+ let mut buffered: Vec = Vec::new();
+ while let Some(frame) = body.frame().await {
+ let frame = frame.map_err(anyhow::Error::new)?;
+ if let Ok(data) = frame.into_data() {
+ buffered.extend_from_slice(&data);
+ if buffered.len() > cap {
+ return Ok(BufferOutcome::Exceeded(buffered, body));
+ }
+ }
+ }
+ Ok(BufferOutcome::Complete(buffered))
+}
+
+/// The forward stream for an over-cap body: the buffered prefix first, then
+/// the remaining frames relayed one by one (never collected — the tail may be
+/// arbitrarily large), so the upstream receives EXACTLY the original bytes.
+fn forward_stream(
+ prefix: Vec,
+ rest: B,
+) -> impl futures_util::Stream- >
+where
+ B: hyper::body::Body
+ Unpin,
+ B::Error: std::error::Error + Send + Sync + 'static,
+{
+ use futures_util::{StreamExt, TryStreamExt};
+ let head = futures_util::stream::iter(std::iter::once(Ok(Bytes::from(prefix))));
+ let tail =
+ http_body_util::BodyDataStream::new(rest).map_err(|e| std::io::Error::other(e.to_string()));
+ head.chain(tail)
}
+/// Buffer the request body for condition matching and rebuild the forwarding
+/// body. MITM correctness: the upstream always receives the original bytes —
+/// a within-cap body forwards the exact buffered bytes; an over-cap body
+/// forwards the buffered prefix chained with the untouched remaining stream
+/// (and captures `Truncated`, which the matcher treats as unevaluable).
pub(crate) async fn prepare_body(
body: hyper::body::Incoming,
_method: &str,
_url: &str,
-) -> anyhow::Result<(Option>, reqwest::Body)> {
- Ok((None, reqwest::Body::wrap(body)))
+) -> anyhow::Result<(BodyCapture, reqwest::Body)> {
+ match buffer_up_to(body, CONDITION_BODY_CAP).await? {
+ BufferOutcome::Complete(bytes) => {
+ let fwd = reqwest::Body::from(bytes.clone());
+ Ok((BodyCapture::Full(bytes), fwd))
+ }
+ BufferOutcome::Exceeded(prefix, rest) => {
+ let fwd = reqwest::Body::wrap_stream(forward_stream(prefix.clone(), rest));
+ Ok((BodyCapture::Truncated(prefix), fwd))
+ }
+ }
+}
+
+// ── Tests ───────────────────────────────────────────────────────────────
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn rule(action: PolicyAction, conditions: &str) -> PolicyRule {
+ PolicyRule {
+ name: "Conditioned rule".to_string(),
+ path_pattern: "*".to_string(),
+ method: None,
+ action,
+ conditions_raw: Some(serde_json::from_str(conditions).expect("conditions JSON")),
+ }
+ }
+
+ fn block(conditions: &str) -> PolicyRule {
+ rule(PolicyAction::Block, conditions)
+ }
+
+ fn allow(conditions: &str) -> PolicyRule {
+ rule(PolicyAction::Allow, conditions)
+ }
+
+ fn with_body(body: &[u8]) -> MatchInput<'_> {
+ MatchInput {
+ body: Some(body),
+ body_truncated: false,
+ headers: None,
+ }
+ }
+
+ fn headers(pairs: &[(&str, &str)]) -> hyper::HeaderMap {
+ let mut map = hyper::HeaderMap::new();
+ for (name, value) in pairs {
+ map.append(
+ HeaderName::from_bytes(name.as_bytes()).expect("header name"),
+ HeaderValue::from_str(value).expect("header value"),
+ );
+ }
+ map
+ }
+
+ fn with_headers(map: &hyper::HeaderMap) -> MatchInput<'_> {
+ MatchInput {
+ body: None,
+ body_truncated: false,
+ headers: Some(map),
+ }
+ }
+
+ // ── Decode + vacuous shapes ─────────────────────────────────────────
+
+ #[test]
+ fn no_conditions_is_vacuous() {
+ let mut none = block(r#"[]"#);
+ none.conditions_raw = None;
+ let empty = block(r#"[]"#);
+ // A session-policy OBJECT is granular scoping, not behavioral — must
+ // stay vacuous or every granular allow rule would stop matching.
+ let session = block(r#"{"repositories":["owner/repo"]}"#);
+ for r in [&none, &empty, &session] {
+ assert!(matches(r, &MatchInput::empty()));
+ assert!(!needs_body_buffer(std::slice::from_ref(r)));
+ }
+ }
+
+ // ── Body operators ──────────────────────────────────────────────────
+
+ #[test]
+ fn body_contains_matches_and_falls_through() {
+ let r = block(r#"[{"target":"body","operator":"contains","value":"needle"}]"#);
+ assert!(matches(&r, &with_body(b"hay needle stack")));
+ assert!(!matches(&r, &with_body(b"just hay")));
+ }
+
+ #[test]
+ fn body_equals_and_regex_match() {
+ let eq = block(r#"[{"target":"body","operator":"equals","value":"exact"}]"#);
+ assert!(matches(&eq, &with_body(b"exact")));
+ assert!(!matches(&eq, &with_body(b"exact-not")));
+
+ let re = allow(r#"[{"target":"body","operator":"regex","value":"(?i)delete\\s+repo"}]"#);
+ assert!(matches(&re, &with_body(b"please DELETE repo now")));
+ assert!(!matches(&re, &with_body(b"read repo")));
+
+ // Raw-byte matching: a needle inside a binary body still matches.
+ let bin = block(r#"[{"target":"body","operator":"contains","value":"secret"}]"#);
+ let mut body = vec![0xFF, 0xFE, 0x00];
+ body.extend_from_slice(b"secret");
+ body.push(0x80);
+ assert!(matches(&bin, &with_body(&body)));
+ }
+
+ #[test]
+ fn conditions_are_anded() {
+ let r = block(
+ r#"[{"target":"body","operator":"contains","value":"alpha"},
+ {"target":"body","operator":"contains","value":"beta"}]"#,
+ );
+ assert!(matches(&r, &with_body(b"alpha and beta")));
+ assert!(!matches(&r, &with_body(b"alpha only")));
+ }
+
+ // ── Header conditions ───────────────────────────────────────────────
+
+ #[test]
+ fn header_name_lookup_is_case_insensitive() {
+ let r = block(r#"[{"target":"header","operator":"equals","key":"X-Foo","value":"bar"}]"#);
+ let map = headers(&[("x-foo", "bar")]);
+ assert!(matches(&r, &with_headers(&map)));
+ }
+
+ #[test]
+ fn header_operators() {
+ let map = headers(&[("x-multi", "first"), ("x-multi", "second-value")]);
+
+ let eq = block(
+ r#"[{"target":"header","operator":"equals","key":"x-multi","value":"second-value"}]"#,
+ );
+ assert!(matches(&eq, &with_headers(&map)), "any value satisfies");
+
+ let contains =
+ block(r#"[{"target":"header","operator":"contains","key":"x-multi","value":"econd"}]"#);
+ assert!(matches(&contains, &with_headers(&map)));
+
+ let re =
+ block(r#"[{"target":"header","operator":"regex","key":"x-multi","value":"^SECOND"}]"#);
+ // Values are case-SENSITIVE: an uppercase anchor misses…
+ assert!(!matches(&re, &with_headers(&map)));
+ // …and `(?i)` opts in to case-insensitive.
+ let re_i = block(
+ r#"[{"target":"header","operator":"regex","key":"x-multi","value":"(?i)^SECOND"}]"#,
+ );
+ assert!(matches(&re_i, &with_headers(&map)));
+
+ let exists = block(r#"[{"target":"header","operator":"exists","key":"x-multi"}]"#);
+ assert!(matches(&exists, &with_headers(&map)));
+
+ // Missing header → NoMatch for every operator, exists included (an
+ // ALLOW falls through AND a BLOCK falls through — absence is a fact).
+ let missing_eq =
+ block(r#"[{"target":"header","operator":"equals","key":"x-gone","value":"v"}]"#);
+ assert!(!matches(&missing_eq, &with_headers(&map)));
+ let missing_exists = block(r#"[{"target":"header","operator":"exists","key":"x-gone"}]"#);
+ assert!(!matches(&missing_exists, &with_headers(&map)));
+ // No headers at all behaves like the header being absent.
+ assert!(!matches(&missing_exists, &MatchInput::empty()));
+ }
+
+ // ── Decision I: absent body is a fact, not a failure ────────────────
+
+ #[test]
+ fn absent_body_is_empty_not_invalid() {
+ let r = block(r#"[{"target":"body","operator":"contains","value":"needle"}]"#);
+ // Even a Block rule falls through: `needs_body_buffer` guarantees a
+ // body-conditioned rule only ever sees `None` when there WAS no body.
+ assert!(!matches(&r, &MatchInput::empty()));
+ // But an operator satisfied by the empty body still matches.
+ let empty_ok = block(r#"[{"target":"body","operator":"regex","value":"^$"}]"#);
+ assert!(matches(&empty_ok, &MatchInput::empty()));
+ }
+
+ // ── Failure law (Decisions H + J) ───────────────────────────────────
+
+ #[test]
+ fn truncated_body_fails_closed_for_block_and_open_for_allow() {
+ let cond = r#"[{"target":"body","operator":"contains","value":"needle"}]"#;
+ let truncated = MatchInput {
+ body: None,
+ body_truncated: true,
+ headers: None,
+ };
+ assert!(matches(&block(cond), &truncated), "Block must over-block");
+ assert!(
+ !matches(&allow(cond), &truncated),
+ "Allow must fall through"
+ );
+ let approval = rule(
+ PolicyAction::ManualApproval {
+ rule_id: "r1".to_string(),
+ },
+ cond,
+ );
+ assert!(!matches(&approval, &truncated));
+ let rate = rule(
+ PolicyAction::RateLimit {
+ rule_id: "r1".to_string(),
+ max_requests: 5,
+ window_secs: 60,
+ },
+ cond,
+ );
+ assert!(!matches(&rate, &truncated));
+ }
+
+ #[test]
+ fn malformed_condition_json_fails_closed_by_action() {
+ for cond in [
+ r#"[42]"#, // garbage element
+ r#"[{"target":"body","operator":"telepathy","value":"x"}]"#, // unknown operator
+ r#"[{"target":"cookies","operator":"contains","value":"x"}]"#, // unknown target
+ r#"[{"target":"body","operator":"contains"}]"#, // missing value
+ r#"[{"target":"header","operator":"equals","value":"x"}]"#, // header w/o key
+ r#"[{"target":"header","operator":"equals","key":"bad name","value":"x"}]"#,
+ r#"[{"target":"body","operator":"exists"}]"#, // exists on body
+ // Unknown field: a future narrowing/inverting flag (e.g. `negate`)
+ // must fail decode, not silently drop and widen matching.
+ r#"[{"target":"body","operator":"contains","value":"x","negate":true}]"#,
+ r#""nonsense""#, // non-array/object
+ ] {
+ assert!(matches(&block(cond), &with_body(b"body")), "{cond}");
+ assert!(!matches(&allow(cond), &with_body(b"body")), "{cond}");
+ }
+ }
+
+ #[test]
+ fn uncompilable_regex_fails_closed_for_block() {
+ // The headline security case: a Block whose regex Rust rejects (JS
+ // lookbehind) must BLOCK, never silently fall through.
+ let cond = r#"[{"target":"body","operator":"regex","value":"(?<=x)y["}]"#;
+ assert!(matches(&block(cond), &with_body(b"anything")));
+ assert!(!matches(&allow(cond), &with_body(b"anything")));
+ }
+
+ #[test]
+ fn oversized_regex_program_fails_closed() {
+ // Nested repetitions can approach the compiler's size limit; capping
+ // it at `REGEX_SIZE_LIMIT` (instead of the 10 MiB default) keeps a
+ // rule author from pinning gigabytes of compiled programs in the
+ // process-wide cache. Over-limit patterns fail to compile → the
+ // Invalid fail-closed path.
+ assert!(compile_regex("(?:x{1000}){1000}").is_none(), "over the cap");
+ assert!(compile_regex("(?i)delete\\s+repo").is_some(), "normal");
+ let cond = r#"[{"target":"body","operator":"regex","value":"(?:x{1000}){1000}"}]"#;
+ assert!(matches(&block(cond), &with_body(b"x")));
+ assert!(!matches(&allow(cond), &with_body(b"x")));
+ }
+
+ // ── Buffering predicate ─────────────────────────────────────────────
+
+ #[test]
+ fn needs_body_buffer_only_for_body_conditions() {
+ let header_only = block(r#"[{"target":"header","operator":"exists","key":"x-api-key"}]"#);
+ assert!(!needs_body_buffer(&[header_only]));
+ let body = block(r#"[{"target":"body","operator":"contains","value":"x"}]"#);
+ assert!(needs_body_buffer(&[body]));
+ let unconditioned = PolicyRule {
+ name: "plain".to_string(),
+ path_pattern: "*".to_string(),
+ method: None,
+ action: PolicyAction::Block,
+ conditions_raw: None,
+ };
+ assert!(!needs_body_buffer(&[unconditioned]));
+ assert!(!needs_body_buffer(&[]));
+ }
+
+ // ── prepare_body / buffer_up_to (MITM correctness) ──────────────────
+
+ #[tokio::test]
+ async fn buffer_with_cap_returns_exact_bytes_and_forwards_them_intact() {
+ use http_body_util::Full;
+ let payload = b"{\"content\":\"hello world\"}".to_vec();
+ let body = Full::new(Bytes::from(payload.clone()));
+ let BufferOutcome::Complete(captured) = buffer_up_to(body, 1024).await.expect("buffer")
+ else {
+ panic!("within-cap body must buffer completely");
+ };
+ assert_eq!(captured, payload, "capture must be byte-identical");
+ // The forwarded body is rebuilt from the same bytes (prepare_body's
+ // Complete arm): byte-identical to the original.
+ let fwd = reqwest::Body::from(captured.clone());
+ assert_eq!(fwd.as_bytes(), Some(payload.as_slice()));
+ }
+
+ #[tokio::test]
+ async fn buffer_with_cap_truncates_over_cap_and_still_forwards_everything() {
+ use futures_util::TryStreamExt;
+ use http_body_util::StreamBody;
+ use hyper::body::Frame;
+
+ // Three frames, 30 bytes total, cap 10 → the capture truncates after
+ // the frame that crosses the cap; the upstream must still receive all
+ // 30 original bytes in order.
+ let frames: Vec, std::convert::Infallible>> = vec![
+ Ok(Frame::data(Bytes::from_static(b"0123456789"))),
+ Ok(Frame::data(Bytes::from_static(b"abcdefghij"))),
+ Ok(Frame::data(Bytes::from_static(b"ABCDEFGHIJ"))),
+ ];
+ let body = StreamBody::new(futures_util::stream::iter(frames));
+ let BufferOutcome::Exceeded(prefix, rest) = buffer_up_to(body, 10).await.expect("buffer")
+ else {
+ panic!("over-cap body must report Exceeded");
+ };
+ assert_eq!(prefix, b"0123456789abcdefghij".to_vec(), "cap+ε prefix");
+
+ // Draining the reconstructed forward stream yields the FULL original
+ // byte sequence — nothing lost, nothing reordered.
+ let forwarded: Vec = forward_stream(prefix.clone(), rest)
+ .try_collect::>()
+ .await
+ .expect("drain forward stream")
+ .concat();
+ assert_eq!(forwarded, b"0123456789abcdefghijABCDEFGHIJ".to_vec());
+
+ // And the capture is opaque to matching (only peekable).
+ let capture = BodyCapture::Truncated(prefix.clone());
+ assert_eq!(capture.bytes(), Some(prefix.as_slice()));
+ assert_eq!(capture.bytes_for_matching(), None);
+ }
}
diff --git a/apps/gateway/src/gateway/forward.rs b/apps/gateway/src/gateway/forward.rs
index 9e6631ad..06f36961 100644
--- a/apps/gateway/src/gateway/forward.rs
+++ b/apps/gateway/src/gateway/forward.rs
@@ -21,7 +21,7 @@ use crate::apps;
use crate::cache::CacheStore;
use crate::default_interceptions;
use crate::inject;
-use crate::policy::{self, PolicyDecision};
+use crate::policy::{self, BodyCapture, MatchInput, PolicyDecision};
use crate::policy_engine;
use super::hooks;
@@ -162,36 +162,38 @@ pub(crate) async fn forward_request(
default_interceptions::match_target(super::strip_port(host), &path, &method)
.filter(|_| content_length_at_most(req.headers(), MAX_DEFAULT_INTERCEPT_BODY));
- // Buffer the request body for condition matching, when the request guard needs
- // to inspect it (e.g. Dropbox folder scoping reads the JSON body), or for a
- // matched default interception. In OSS, both predicates return false → zero
- // overhead unless a default interception matched.
- let (condition_buffer, req) = if crate::policy_engine::needs_body_buffer(&rules.policy_rules_v2)
- || hooks::needs_request_body(rules, host, method.as_str(), &path)
- {
- let (parts, incoming) = req.into_parts();
- let (buf, fwd_body) =
- crate::condition_match::prepare_body(incoming, method.as_str(), &url).await?;
- (buf, hyper::Request::from_parts(parts, fwd_body))
- } else if default_target.is_some() {
- // OSS-safe: fully buffer the known-small body, keeping the bytes for both
- // the interception check and (if it declines) forwarding.
- let (parts, incoming) = req.into_parts();
- let bytes = incoming
- .collect()
- .await
- .context("buffering request body for default interception")?
- .to_bytes();
- let req = hyper::Request::from_parts(parts, reqwest::Body::from(bytes.clone()));
- (Some(bytes.to_vec()), req)
- } else {
- (None, req.map(reqwest::Body::wrap))
- };
+ // Buffer the request body for condition matching, when a body-conditioned
+ // rule could govern this host (`needs_body_buffer` is host-scoped), when the
+ // request guard needs to inspect it (e.g. Dropbox folder scoping reads the
+ // JSON body), or for a matched default interception. Unconditioned traffic
+ // keeps streaming → zero overhead.
+ let (capture, req) =
+ if crate::policy_engine::needs_body_buffer(&rules.policy_rules_v2, policy_host)
+ || hooks::needs_request_body(rules, host, method.as_str(), &path)
+ {
+ let (parts, incoming) = req.into_parts();
+ let (capture, fwd_body) =
+ crate::condition_match::prepare_body(incoming, method.as_str(), &url).await?;
+ (capture, hyper::Request::from_parts(parts, fwd_body))
+ } else if default_target.is_some() {
+ // OSS-safe: fully buffer the known-small body, keeping the bytes for both
+ // the interception check and (if it declines) forwarding.
+ let (parts, incoming) = req.into_parts();
+ let bytes = incoming
+ .collect()
+ .await
+ .context("buffering request body for default interception")?
+ .to_bytes();
+ let req = hyper::Request::from_parts(parts, reqwest::Body::from(bytes.clone()));
+ (BodyCapture::Full(bytes.to_vec()), req)
+ } else {
+ (BodyCapture::None, req.map(reqwest::Body::wrap))
+ };
// Answer a matched default interception before any forwarding. A handler that
// declines (e.g. a real refresh token) falls through to normal forwarding.
if let Some(target) = default_target {
- if let Some(synth) = target.handle(condition_buffer.as_deref().unwrap_or(&[])) {
+ if let Some(synth) = target.handle(capture.bytes().unwrap_or(&[])) {
info!(method = %method, url = %url, "default interception — serving synthetic response");
return Ok(response::json(synth.status, synth.body));
}
@@ -218,6 +220,12 @@ pub(crate) async fn forward_request(
));
}
+ // The per-request condition input: the captured body (only a FULL capture is
+ // matchable — a truncated one fails closed) plus the pre-injection request
+ // headers. Borrows `req`; its last use is the `evaluate` call below, which NLL
+ // releases before `req.into_parts()` later.
+ let match_input = MatchInput::from_capture(&capture, req.headers());
+
// The first-match engine over the published `policy_rules_v2` is authoritative.
// `policy_host` is the pre-rewrite rule-match host; `is_llm_host(host)` is the
// effective host for the deny-default carve.
@@ -226,7 +234,7 @@ pub(crate) async fn forward_request(
policy_host,
method.as_str(),
&path,
- condition_buffer.as_deref(),
+ &match_input,
has_injections,
policy::is_llm_host(host),
rules.winning_connection_id.as_deref(),
@@ -346,7 +354,7 @@ pub(crate) async fn forward_request(
method.as_str(),
&path,
&headers,
- condition_buffer.as_deref(),
+ capture.bytes(),
)
.await
{
@@ -378,8 +386,8 @@ pub(crate) async fn forward_request(
// Peek a bounded prefix of the body for the summary + preview, then
// build the forwarding body. If condition buffering already captured
// the body, reuse that buffer instead of peeking the stream again.
- let (summary_bytes, fwd_body): (Cow<'_, [u8]>, reqwest::Body) = if let Some(ref buf) =
- condition_buffer
+ let (summary_bytes, fwd_body): (Cow<'_, [u8]>, reqwest::Body) = if let Some(buf) =
+ capture.bytes()
{
// Body already buffered for condition matching — borrow its prefix
// for the summary instead of copying it again.
diff --git a/apps/gateway/src/gateway/websocket.rs b/apps/gateway/src/gateway/websocket.rs
index 23d53cd9..f5add1c4 100644
--- a/apps/gateway/src/gateway/websocket.rs
+++ b/apps/gateway/src/gateway/websocket.rs
@@ -21,7 +21,7 @@ use tracing::{info, warn};
use crate::cache::CacheStore;
use crate::inject;
-use crate::policy::{self, PolicyDecision};
+use crate::policy::{self, MatchInput, PolicyDecision};
use super::hooks;
use super::mitm::ResolvedRules;
@@ -127,6 +127,15 @@ pub(super) async fn handle_websocket(
));
}
+ // A WebSocket upgrade is a GET with no inspectable body: header conditions
+ // apply to the handshake, and the absent body is a FACT (a body condition
+ // matches against the empty body, never fails closed on missing bytes).
+ let match_input = MatchInput {
+ body: None,
+ body_truncated: false,
+ headers: Some(req.headers()),
+ };
+
// The first-match engine over `policy_rules_v2` is authoritative. WebSocket
// blocks emit no telemetry today, so the matched rule is not attributed here
// (allow-attribution for ws is out of scope) — only the decision is consumed.
@@ -135,7 +144,7 @@ pub(super) async fn handle_websocket(
policy_host,
"GET",
&path,
- None,
+ &match_input,
has_injections,
policy::is_llm_host(host),
rules.winning_connection_id.as_deref(),
diff --git a/apps/gateway/src/main.rs b/apps/gateway/src/main.rs
index d66b9f19..ecb9d9b7 100644
--- a/apps/gateway/src/main.rs
+++ b/apps/gateway/src/main.rs
@@ -43,10 +43,11 @@ mod org_routes;
mod connect;
-// Body-condition matcher (step 9.5): the real matcher rides with the full EE
-// engine — onprem included, else a v2 `body contains` block rule would never
-// see a body there and fail OPEN. The OSS arm stays the no-op (conditions are
-// carried but never evaluated in OSS, matching its legacy behavior).
+// Body-condition matcher (Tier 3a): the OSS arm evaluates body/header
+// conditions byte-level over the buffered request body and headers, at both
+// org and project scopes, with the fail-closed-by-action failure law (an
+// unevaluable condition over-blocks a Block rule and drops any other). The EE
+// build swaps in the cloud overlay via the `#[path]` module below.
#[cfg(edition_oss)]
mod condition_match;
diff --git a/apps/gateway/src/policy.rs b/apps/gateway/src/policy.rs
index 5fbe1489..689c1a6c 100644
--- a/apps/gateway/src/policy.rs
+++ b/apps/gateway/src/policy.rs
@@ -38,6 +38,84 @@ pub(crate) struct PolicyRule {
pub conditions_raw: Option,
}
+/// Everything a rule condition can look at for one request. Built once per
+/// request (forward.rs / websocket.rs) and borrowed through the whole
+/// evaluation — the shared matcher and the v2 two-level walk.
+///
+/// Lives here (not in `condition_match`) because `condition_match` is
+/// edition-swapped and the shared call sites need one stable shape.
+#[derive(Debug, Clone, Copy)]
+pub(crate) struct MatchInput<'a> {
+ /// The fully buffered request body, when condition buffering captured it.
+ /// `None` means the request genuinely has no (buffered) body — GETs,
+ /// WebSocket upgrades, or the streaming path (`needs_body_buffer` is a
+ /// superset of "some body condition could be consulted", so a
+ /// body-conditioned rule never sees `None` for a request that had a body).
+ pub body: Option<&'a [u8]>,
+ /// The body exceeded the buffer cap: body conditions become unevaluable
+ /// and fail closed per the condition failure law (`condition_match`).
+ pub body_truncated: bool,
+ /// The request headers at evaluation time (pre-injection).
+ pub headers: Option<&'a hyper::HeaderMap>,
+}
+
+impl<'a> MatchInput<'a> {
+ /// No body, no headers — for call sites (and tests) with nothing to match
+ /// conditions against.
+ #[allow(dead_code)] // production paths build real inputs; tests + EE use this
+ pub(crate) const fn empty() -> Self {
+ MatchInput {
+ body: None,
+ body_truncated: false,
+ headers: None,
+ }
+ }
+
+ /// The per-request input: what `prepare_body` captured plus the request
+ /// headers.
+ pub(crate) fn from_capture(capture: &'a BodyCapture, headers: &'a hyper::HeaderMap) -> Self {
+ MatchInput {
+ body: capture.bytes_for_matching(),
+ body_truncated: matches!(capture, BodyCapture::Truncated(_)),
+ headers: Some(headers),
+ }
+ }
+}
+
+/// What `condition_match::prepare_body` captured of a request body.
+#[derive(Debug)]
+pub(crate) enum BodyCapture {
+ /// Nothing buffered (the streaming path).
+ None,
+ /// The complete body (within the cap).
+ Full(Vec),
+ /// The first cap(+ε) bytes; the rest streams to the upstream untouched.
+ /// Body conditions never match on a truncated capture — a prefix-only
+ /// match would let a needle pushed past the cap dodge a Block rule.
+ Truncated(Vec),
+}
+
+impl BodyCapture {
+ /// The captured bytes, full or truncated — for consumers that only peek
+ /// (default interception, approval summaries).
+ pub(crate) fn bytes(&self) -> Option<&[u8]> {
+ match self {
+ BodyCapture::None => None,
+ BodyCapture::Full(b) | BodyCapture::Truncated(b) => Some(b),
+ }
+ }
+
+ /// The bytes condition matching may consult: only a FULL capture. A
+ /// truncated capture exposes nothing here — matching a prefix is exactly
+ /// the bypass the failure law refuses.
+ pub(crate) fn bytes_for_matching(&self) -> Option<&[u8]> {
+ match self {
+ BodyCapture::Full(b) => Some(b),
+ _ => None,
+ }
+ }
+}
+
/// The v2 rule that decided a request — recorded into telemetry so Activity
/// can say "decided by rule X". `logical_id` is the generation-stable identity
/// (row ids regenerate on every publish); the name is a display snapshot;
@@ -139,14 +217,14 @@ pub(crate) fn matches_request(
rule: &PolicyRule,
method: &str,
path: &str,
- body: Option<&[u8]>,
+ input: &MatchInput<'_>,
) -> bool {
let direct = path_matches(path, &rule.path_pattern)
&& rule
.method
.as_ref()
.is_none_or(|m| m.eq_ignore_ascii_case(method))
- && crate::condition_match::matches(rule, body);
+ && crate::condition_match::matches(rule, input);
if direct {
return true;
}
@@ -157,7 +235,7 @@ pub(crate) fn matches_request(
&& method.eq_ignore_ascii_case("GET")
&& is_git_push_discovery(path)
{
- return crate::condition_match::matches(rule, body);
+ return crate::condition_match::matches(rule, input);
}
false
}
@@ -191,12 +269,12 @@ pub(crate) fn is_llm_host(host: &str) -> bool {
pub(crate) fn is_blocked(
request_method: &str,
request_path: &str,
- request_body: Option<&[u8]>,
+ input: &MatchInput<'_>,
rules: &[PolicyRule],
) -> bool {
rules.iter().any(|rule| {
matches!(rule.action, PolicyAction::Block)
- && matches_request(rule, request_method, request_path, request_body)
+ && matches_request(rule, request_method, request_path, input)
})
}
@@ -224,7 +302,7 @@ mod tests {
assert!(is_blocked(
"POST",
"/gmail/v1/users/me/messages/send",
- None,
+ &MatchInput::empty(),
&rules
));
}
@@ -235,7 +313,7 @@ mod tests {
assert!(!is_blocked(
"GET",
"/gmail/v1/users/me/messages/send",
- None,
+ &MatchInput::empty(),
&rules
));
}
@@ -246,7 +324,7 @@ mod tests {
assert!(!is_blocked(
"POST",
"/gmail/v1/users/me/messages",
- None,
+ &MatchInput::empty(),
&rules
));
}
@@ -254,9 +332,24 @@ mod tests {
#[test]
fn blocks_all_methods_when_none() {
let rules = vec![block_rule("/admin/*", None)];
- assert!(is_blocked("GET", "/admin/users", None, &rules));
- assert!(is_blocked("POST", "/admin/users", None, &rules));
- assert!(is_blocked("DELETE", "/admin/settings", None, &rules));
+ assert!(is_blocked(
+ "GET",
+ "/admin/users",
+ &MatchInput::empty(),
+ &rules
+ ));
+ assert!(is_blocked(
+ "POST",
+ "/admin/users",
+ &MatchInput::empty(),
+ &rules
+ ));
+ assert!(is_blocked(
+ "DELETE",
+ "/admin/settings",
+ &MatchInput::empty(),
+ &rules
+ ));
}
#[test]
@@ -265,36 +358,56 @@ mod tests {
assert!(is_blocked(
"POST",
"/gmail/v1/users/me/messages/send",
- None,
+ &MatchInput::empty(),
+ &rules
+ ));
+ assert!(!is_blocked(
+ "POST",
+ "/calendar/v1/events",
+ &MatchInput::empty(),
&rules
));
- assert!(!is_blocked("POST", "/calendar/v1/events", None, &rules));
}
#[test]
fn blocks_all_paths() {
let rules = vec![block_rule("*", Some("DELETE"))];
- assert!(is_blocked("DELETE", "/anything", None, &rules));
- assert!(!is_blocked("GET", "/anything", None, &rules));
+ assert!(is_blocked(
+ "DELETE",
+ "/anything",
+ &MatchInput::empty(),
+ &rules
+ ));
+ assert!(!is_blocked(
+ "GET",
+ "/anything",
+ &MatchInput::empty(),
+ &rules
+ ));
}
#[test]
fn method_matching_is_case_insensitive() {
let rules = vec![block_rule("*", Some("POST"))];
- assert!(is_blocked("post", "/path", None, &rules));
- assert!(is_blocked("Post", "/path", None, &rules));
+ assert!(is_blocked("post", "/path", &MatchInput::empty(), &rules));
+ assert!(is_blocked("Post", "/path", &MatchInput::empty(), &rules));
}
#[test]
fn no_rules_allows_everything() {
- assert!(!is_blocked("POST", "/anything", None, &[]));
+ assert!(!is_blocked("POST", "/anything", &MatchInput::empty(), &[]));
}
#[test]
fn blocks_with_default_wildcard_path() {
let rules = vec![block_rule("*", Some("POST"))];
- assert!(is_blocked("POST", "/any/path/here", None, &rules));
- assert!(is_blocked("POST", "/", None, &rules));
+ assert!(is_blocked(
+ "POST",
+ "/any/path/here",
+ &MatchInput::empty(),
+ &rules
+ ));
+ assert!(is_blocked("POST", "/", &MatchInput::empty(), &rules));
}
#[test]
@@ -303,8 +416,18 @@ mod tests {
block_rule("/safe/*", Some("GET")),
block_rule("/danger/*", Some("POST")),
];
- assert!(!is_blocked("POST", "/safe/path", None, &rules));
- assert!(is_blocked("POST", "/danger/path", None, &rules));
+ assert!(!is_blocked(
+ "POST",
+ "/safe/path",
+ &MatchInput::empty(),
+ &rules
+ ));
+ assert!(is_blocked(
+ "POST",
+ "/danger/path",
+ &MatchInput::empty(),
+ &rules
+ ));
}
// ── Git push discovery tests ────────────────────────────────────
@@ -315,7 +438,7 @@ mod tests {
assert!(is_blocked(
"GET",
"/owner/repo.git/info/refs?service=git-receive-pack",
- None,
+ &MatchInput::empty(),
&rules
));
}
@@ -326,7 +449,7 @@ mod tests {
assert!(!is_blocked(
"GET",
"/owner/repo.git/info/refs?service=git-upload-pack",
- None,
+ &MatchInput::empty(),
&rules
));
}
@@ -337,7 +460,7 @@ mod tests {
assert!(is_blocked(
"POST",
"/owner/repo.git/git-receive-pack",
- None,
+ &MatchInput::empty(),
&rules
));
}
diff --git a/apps/gateway/src/policy_engine/catalog.rs b/apps/gateway/src/policy_engine/catalog.rs
index 40685243..0f23755a 100644
--- a/apps/gateway/src/policy_engine/catalog.rs
+++ b/apps/gateway/src/policy_engine/catalog.rs
@@ -16,7 +16,7 @@ use std::sync::OnceLock;
use serde::Deserialize;
use crate::connect::host_matches;
-use crate::policy::{matches_request, PolicyAction, PolicyRule};
+use crate::policy::{matches_request, MatchInput, PolicyAction, PolicyRule};
/// One tool's endpoint fan-out (camelCase JSON keys). An empty `methods` list
/// means "any method".
@@ -52,19 +52,28 @@ fn single_host_family(provider_tools: &HashMap) -> bool {
}
/// A throwaway `policy::PolicyRule` so one path×method variant routes through
-/// the gateway's exact `matches_request` (the action is irrelevant to
-/// matching). Conditions ride from the owning rule — vacuous in OSS, where the
-/// `condition_match` arm is the no-op.
+/// the gateway's exact `matches_request`. Conditions ride from the owning
+/// rule, and so does its BLOCK-ness: `condition_match`'s failure law is
+/// action-aware (an unevaluable condition fails CLOSED only for a Block
+/// rule), so hardcoding Allow here would fail a v2 Block open. The owning
+/// rule's NAME rides along too, so the matcher's unevaluable-condition
+/// warning identifies the broken rule.
fn variant_rule(
+ name: &str,
path_pattern: &str,
method: Option,
conditions: &Option,
+ is_block: bool,
) -> PolicyRule {
PolicyRule {
- name: String::new(),
+ name: name.to_string(),
path_pattern: path_pattern.to_string(),
method,
- action: PolicyAction::Allow,
+ action: if is_block {
+ PolicyAction::Block
+ } else {
+ PolicyAction::Allow
+ },
conditions_raw: conditions.clone(),
}
}
@@ -91,23 +100,40 @@ fn variant_rule(
/// bleed across sibling services. A truly distinct endpoint host (github
/// `raw.githubusercontent.com`, fly.io GraphQL) is a separate catalog tool of
/// its own; whole-app rules also cover it.
+#[allow(clippy::too_many_arguments)]
pub(super) fn app_target_matches(
+ rule_name: &str,
provider: &str,
tools: &[String],
request_host: &str,
request_method: &str,
request_path: &str,
- body: Option<&[u8]>,
+ input: &MatchInput<'_>,
conditions: &Option,
+ is_block: bool,
) -> bool {
let Some(provider_tools) = catalog().get(provider) else {
return false;
};
if tools.is_empty() {
- return provider_tools
+ // Behavioral conditions gate the whole-app match too (a wildcard-path
+ // variant carrying the owning rule's Block-ness, so an unevaluable
+ // condition fails closed by action) — otherwise a conditioned
+ // whole-app ALLOW would match on host alone and could shadow a later
+ // Block. A connection target's session-policy OBJECT stays vacuous in
+ // `condition_match::decode_conditions`, so granular connection rules
+ // are unaffected.
+ let host_hit = provider_tools
.values()
.any(|tool| host_matches(request_host, &tool.host_pattern))
|| crate::apps::provider_matches_host_and_path(provider, request_host, request_path);
+ return host_hit
+ && matches_request(
+ &variant_rule(rule_name, "*", None, conditions, is_block),
+ request_method,
+ request_path,
+ input,
+ );
}
// The host is the app's per-tool catalog host OR an injection MIRROR of the
// app (tool-independent → computed once): a path-scoped mirror (Gmail's
@@ -139,8 +165,14 @@ pub(super) fn app_target_matches(
};
tool.paths.iter().any(|path| {
methods.iter().any(|method| {
- let rule = variant_rule(path, method.map(str::to_string), conditions);
- matches_request(&rule, request_method, request_path, body)
+ let rule = variant_rule(
+ rule_name,
+ path,
+ method.map(str::to_string),
+ conditions,
+ is_block,
+ );
+ matches_request(&rule, request_method, request_path, input)
})
})
})
@@ -152,7 +184,98 @@ mod tests {
fn matches(provider: &str, tools: &[&str], host: &str, method: &str, path: &str) -> bool {
let tools: Vec = tools.iter().map(|s| s.to_string()).collect();
- app_target_matches(provider, &tools, host, method, path, None, &None)
+ app_target_matches(
+ "test rule",
+ provider,
+ &tools,
+ host,
+ method,
+ path,
+ &MatchInput::empty(),
+ &None,
+ false,
+ )
+ }
+
+ #[test]
+ fn conditions_gate_the_tool_fanout_and_fail_closed_for_block() {
+ // A tool-scoped target honors the owning rule's conditions through the
+ // variant fan-out, and the variant carries the rule's Block-ness so an
+ // unevaluable condition fails closed exactly like a network target.
+ let tools = vec!["create_issue".to_string()];
+ let hit = |input: &MatchInput<'_>, conditions: &str, is_block: bool| {
+ app_target_matches(
+ "test rule",
+ "github",
+ &tools,
+ "api.github.com",
+ "POST",
+ "/repos/o/r/issues",
+ input,
+ &serde_json::from_str(conditions).ok(),
+ is_block,
+ )
+ };
+ let cond = r#"[{"target":"body","operator":"contains","value":"needle"}]"#;
+ let with_needle = MatchInput {
+ body: Some(b"has needle"),
+ body_truncated: false,
+ headers: None,
+ };
+ let without_needle = MatchInput {
+ body: Some(b"nothing"),
+ body_truncated: false,
+ headers: None,
+ };
+ assert!(hit(&with_needle, cond, false));
+ assert!(!hit(&without_needle, cond, false));
+ // Unevaluable (uncompilable regex): matches only when Block-owned.
+ let broken = r#"[{"target":"body","operator":"regex","value":"("}]"#;
+ assert!(hit(&without_needle, broken, true));
+ assert!(!hit(&without_needle, broken, false));
+ }
+
+ #[test]
+ fn conditions_gate_the_whole_app_match_and_fail_closed_for_block() {
+ // The empty-tools branch mirrors the tool fan-out: behavioral
+ // conditions gate the host-wide match, and an unevaluable condition
+ // fails closed only when the owning rule is a Block. Without this a
+ // conditioned whole-app ALLOW would match on host alone and shadow a
+ // later Block.
+ let hit = |input: &MatchInput<'_>, conditions: &str, is_block: bool| {
+ app_target_matches(
+ "test rule",
+ "github",
+ &[],
+ "api.github.com",
+ "DELETE",
+ "/anything",
+ input,
+ &serde_json::from_str(conditions).ok(),
+ is_block,
+ )
+ };
+ let cond = r#"[{"target":"body","operator":"contains","value":"needle"}]"#;
+ let with_needle = MatchInput {
+ body: Some(b"has needle"),
+ body_truncated: false,
+ headers: None,
+ };
+ let without_needle = MatchInput {
+ body: Some(b"nothing"),
+ body_truncated: false,
+ headers: None,
+ };
+ assert!(hit(&with_needle, cond, false));
+ assert!(!hit(&without_needle, cond, false));
+ // Unevaluable (uncompilable regex): matches only when Block-owned.
+ let broken = r#"[{"target":"body","operator":"regex","value":"("}]"#;
+ assert!(hit(&without_needle, broken, true));
+ assert!(!hit(&without_needle, broken, false));
+ // A session-policy OBJECT (granular connection scope) stays vacuous —
+ // the whole-app match is unaffected by it.
+ let session = r#"{"repositories":["o/r"]}"#;
+ assert!(hit(&without_needle, session, false));
}
#[test]
@@ -344,7 +467,17 @@ mod tests {
continue;
}
assert!(
- app_target_matches(provider, &[], &host, "POST", &path, None, &None),
+ app_target_matches(
+ "test rule",
+ provider,
+ &[],
+ &host,
+ "POST",
+ &path,
+ &MatchInput::empty(),
+ &None,
+ false
+ ),
"whole-app rule for `{provider}` must cover its injection host `{host}` (path `{path}`)"
);
}
diff --git a/apps/gateway/src/policy_engine/enforce.rs b/apps/gateway/src/policy_engine/enforce.rs
index 0af4c190..6e37107c 100644
--- a/apps/gateway/src/policy_engine/enforce.rs
+++ b/apps/gateway/src/policy_engine/enforce.rs
@@ -22,17 +22,43 @@ use crate::db::{
AvailableApps, ConnectionProviders, PolicyRuleV2Row, PolicyV2Rules, PrincipalSet, SecretHosts,
};
use crate::gateway::{strip_port, ProxyContext};
-use crate::policy::{check_rate_limit, MatchedRule, PolicyDecision};
+use crate::policy::{check_rate_limit, MatchInput, MatchedRule, PolicyDecision};
use super::assemble::assemble;
use super::evaluate::evaluate_outcome;
use super::loaders;
use super::types::{Action, Outcome, Request, Rule, RuleScope};
-/// `false` always: OSS's `condition_match` arm cannot buffer bodies and never
-/// evaluates conditions (they match vacuously), so there is nothing to buffer for.
-pub(crate) fn needs_body_buffer(_v2: &PolicyV2Rules) -> bool {
- false
+/// True iff a loaded rule (org or project) has a BODY condition on a target
+/// that could govern this `host` — the host-scoped superset that keeps the
+/// buffering as narrow as correctness allows. A network target matches its
+/// own `host_pattern`; app/connection/secret targets buffer unconditionally
+/// (their host resolution lives in the catalog/fenced maps — not worth
+/// duplicating here); unknown kinds never match anything. Header-only
+/// conditions never buffer (headers are always available); equipment rows are
+/// injection-only and skipped; empty slices never buffer.
+///
+/// The superset law: `needs_body_buffer` must be TRUE whenever some body
+/// condition could be consulted for this host, so the matcher only ever sees
+/// `body: None` for a request that genuinely had no body — never for one whose
+/// body was skipped by the streaming path.
+pub(crate) fn needs_body_buffer(v2: &PolicyV2Rules, host: &str) -> bool {
+ let host = strip_port(host);
+ v2.org
+ .iter()
+ .chain(v2.project.iter())
+ .filter(|r| r.source != "equipment")
+ .filter(|r| crate::condition_match::has_body_condition(&r.conditions))
+ .any(|r| {
+ r.targets.0.iter().any(|t| match t.kind.as_str() {
+ "network" => t
+ .host_pattern
+ .as_deref()
+ .is_some_and(|p| crate::connect::host_matches(host, p)),
+ "app" | "connection" | "secret" => true,
+ _ => false,
+ })
+ })
}
/// True when any loaded rule (org or project) has a target of `kind`, skipping
@@ -155,7 +181,7 @@ pub(crate) async fn evaluate(
host: &str,
method: &str,
path: &str,
- body: Option<&[u8]>,
+ input: &MatchInput<'_>,
has_injections: bool,
is_llm_host: bool,
winning_connection_id: Option<&str>,
@@ -198,7 +224,7 @@ pub(crate) async fn evaluate(
name: rule.name.clone(),
scope: rule.scope.as_str().to_string(),
};
- match evaluate_outcome(&org_rules, &project_rules, &request, &v2.principals, body) {
+ match evaluate_outcome(&org_rules, &project_rules, &request, &v2.principals, input) {
Outcome::Rule(rule) => (
decision_for_rule(rule, org_id, project_id, agent_token, cache).await,
Some(matched_of(rule)),
@@ -280,7 +306,7 @@ mod tests {
"api.example.com",
"GET",
"/",
- None,
+ &MatchInput::empty(),
false,
false,
None,
@@ -337,4 +363,74 @@ mod tests {
})];
assert!(!has_target_kind(&[&equipment, &project], "secret"));
}
+
+ #[test]
+ fn needs_body_buffer_scopes_to_host_and_skips_equipment() {
+ let body_cond: Option =
+ serde_json::from_str(r#"[{"target":"body","operator":"contains","value":"x"}]"#).ok();
+ let network_rule = |conditions: Option| {
+ row(|r| {
+ r.conditions = conditions;
+ r.targets = Json(vec![serde_json::from_value(
+ json!({"kind": "network", "hostPattern": "api.example.com"}),
+ )
+ .expect("target row")]);
+ })
+ };
+ // Network-target rule with a body condition: only its host buffers
+ // (port-stripped), foreign hosts keep streaming.
+ let v2 = PolicyV2Rules {
+ project: vec![network_rule(body_cond.clone())],
+ ..PolicyV2Rules::default()
+ };
+ assert!(needs_body_buffer(&v2, "api.example.com"));
+ assert!(needs_body_buffer(&v2, "api.example.com:443"));
+ assert!(!needs_body_buffer(&v2, "other.example.com"));
+ // Org-scope rules count too.
+ let v2 = PolicyV2Rules {
+ org: vec![network_rule(body_cond.clone())],
+ ..PolicyV2Rules::default()
+ };
+ assert!(needs_body_buffer(&v2, "api.example.com"));
+ // App-target rule → conservatively buffer everywhere (superset law).
+ let app_rule = row(|r| {
+ r.conditions = body_cond.clone();
+ r.targets = Json(vec![serde_json::from_value(
+ json!({"kind": "app", "appProvider": "github", "appTools": []}),
+ )
+ .expect("target row")]);
+ });
+ let v2 = PolicyV2Rules {
+ project: vec![app_rule],
+ ..PolicyV2Rules::default()
+ };
+ assert!(needs_body_buffer(&v2, "anything.example.com"));
+ // Equipment rows are injection-only — never buffer.
+ let equipment = row(|r| {
+ r.source = "equipment".to_string();
+ r.conditions = body_cond.clone();
+ r.targets = Json(vec![serde_json::from_value(
+ json!({"kind": "network", "hostPattern": "api.example.com"}),
+ )
+ .expect("target row")]);
+ });
+ let v2 = PolicyV2Rules {
+ project: vec![equipment],
+ ..PolicyV2Rules::default()
+ };
+ assert!(!needs_body_buffer(&v2, "api.example.com"));
+ // Header-only conditions never buffer (headers are always available).
+ let header_cond: Option =
+ serde_json::from_str(r#"[{"target":"header","operator":"exists","key":"x-k"}]"#).ok();
+ let v2 = PolicyV2Rules {
+ project: vec![network_rule(header_cond)],
+ ..PolicyV2Rules::default()
+ };
+ assert!(!needs_body_buffer(&v2, "api.example.com"));
+ // Empty bundle never buffers.
+ assert!(!needs_body_buffer(
+ &PolicyV2Rules::default(),
+ "api.example.com"
+ ));
+ }
}
diff --git a/apps/gateway/src/policy_engine/evaluate.rs b/apps/gateway/src/policy_engine/evaluate.rs
index a2b897ff..36b0ccd7 100644
--- a/apps/gateway/src/policy_engine/evaluate.rs
+++ b/apps/gateway/src/policy_engine/evaluate.rs
@@ -12,11 +12,13 @@
//!
//! Matching routes through the gateway's own `connect::host_matches` +
//! `policy::matches_request`, so path globs, methods, the git-receive-pack
-//! bridge, and the (no-op in OSS) condition arm are byte-identical to the
-//! legacy path.
+//! bridge, and the body/header condition arm are byte-identical to the shared
+//! matcher. Conditions are evaluated at BOTH scopes: every rule's own
+//! Block-ness rides through the pseudo-rule seam, so an unevaluable condition
+//! fails CLOSED by action (a Block over-blocks, an Allow falls through).
use crate::db::PrincipalSet;
-use crate::policy::{matches_request, PolicyAction, PolicyRule};
+use crate::policy::{matches_request, MatchInput, PolicyAction, PolicyRule};
use super::types::{Action, Identity, Outcome, Request, Rule, Target};
@@ -35,22 +37,35 @@ fn identity_matches(rule: &Rule, request: &Request, principals: &PrincipalSet) -
}
/// A throwaway `policy::PolicyRule` so the network match runs the gateway's
-/// exact `matches_request` (the action is irrelevant to matching).
+/// exact `matches_request`. Conditions ride from the owning rule, and so does
+/// its BLOCK-ness (`is_block`): `condition_match`'s failure law is
+/// action-aware — an unevaluable condition fails CLOSED only for a Block rule
+/// — so hardcoding Allow here would fail a v2 Block rule OPEN on a broken
+/// regex/oversized body. The owning rule's NAME rides along too, so the
+/// matcher's unevaluable-condition warning identifies the broken rule instead
+/// of logging an empty name.
fn pseudo_rule(
+ name: &str,
path_pattern: Option<&str>,
method: Option,
conditions: &Option,
+ is_block: bool,
) -> PolicyRule {
PolicyRule {
- name: String::new(),
+ name: name.to_string(),
path_pattern: path_pattern.unwrap_or("*").to_string(),
method,
- action: PolicyAction::Allow,
+ action: if is_block {
+ PolicyAction::Block
+ } else {
+ PolicyAction::Allow
+ },
conditions_raw: conditions.clone(),
}
}
-fn target_matches(target: &Target, rule: &Rule, request: &Request, body: Option<&[u8]>) -> bool {
+fn target_matches(target: &Target, rule: &Rule, request: &Request, input: &MatchInput<'_>) -> bool {
+ let is_block = rule.action == Action::Block;
match target {
Target::Network {
host_pattern,
@@ -59,24 +74,33 @@ fn target_matches(target: &Target, rule: &Rule, request: &Request, body: Option<
} => {
crate::connect::host_matches(&request.host, host_pattern)
&& matches_request(
- &pseudo_rule(path_pattern.as_deref(), method.clone(), &rule.conditions),
+ &pseudo_rule(
+ &rule.name,
+ path_pattern.as_deref(),
+ method.clone(),
+ &rule.conditions,
+ is_block,
+ ),
&request.method,
&request.path,
- body,
+ input,
)
}
Target::App { provider, tools } => super::catalog::app_target_matches(
+ &rule.name,
provider,
tools,
&request.host,
&request.method,
&request.path,
- body,
+ input,
&rule.conditions,
+ is_block,
),
// A connection target matches only when it is the request's winning
// injected connection AND the provider/tools fan-out hits. No winner →
- // never matches (fail-closed for allow AND block).
+ // never matches (fail-closed for allow AND block). Conditions ride
+ // through the fan-out carrying the owning rule's Block-ness.
Target::Connection {
id,
provider,
@@ -84,20 +108,34 @@ fn target_matches(target: &Target, rule: &Rule, request: &Request, body: Option<
} => {
request.winning_connection_id.as_deref() == Some(id.as_str())
&& super::catalog::app_target_matches(
+ &rule.name,
provider,
tools,
&request.host,
&request.method,
&request.path,
- body,
+ input,
&rule.conditions,
+ is_block,
+ )
+ }
+ // A secret target gates its resolved host(s). Empty patterns
+ // (unresolved/deleted secret) never match — fail-closed. The owning
+ // rule's conditions still narrow the match (wildcard-path pseudo-rule
+ // carrying its Block-ness): without this gate a conditioned ALLOW on a
+ // secret would match unconditionally and could shadow a later Block —
+ // the widening the fail-closed law forbids.
+ Target::Secret { host_patterns } => {
+ host_patterns
+ .iter()
+ .any(|h| crate::connect::host_matches(&request.host, h))
+ && matches_request(
+ &pseudo_rule(&rule.name, None, None, &rule.conditions, is_block),
+ &request.method,
+ &request.path,
+ input,
)
}
- // A secret target gates its resolved host(s), host-only. Empty patterns
- // (unresolved/deleted secret) never match — fail-closed.
- Target::Secret { host_patterns } => host_patterns
- .iter()
- .any(|h| crate::connect::host_matches(&request.host, h)),
Target::Unresolved => false,
}
}
@@ -110,14 +148,14 @@ fn rule_matches(
rule: &Rule,
request: &Request,
principals: &PrincipalSet,
- body: Option<&[u8]>,
+ input: &MatchInput<'_>,
) -> bool {
identity_matches(rule, request, principals)
&& !rule.targets.is_empty()
&& rule
.targets
.iter()
- .any(|t| target_matches(t, rule, request, body))
+ .any(|t| target_matches(t, rule, request, input))
}
/// Strictness rank, mirroring `strictness.ts::strictnessRank`: block strictest
@@ -151,13 +189,13 @@ fn first_match<'a>(
rules: &'a [Rule],
request: &Request,
principals: &PrincipalSet,
- body: Option<&[u8]>,
+ input: &MatchInput<'_>,
) -> Option> {
let mut ordered: Vec<&'a Rule> = rules.iter().filter(|r| !r.is_default).collect();
ordered.sort_by(|a, b| a.priority.cmp(&b.priority).then_with(|| a.id.cmp(&b.id)));
ordered
.into_iter()
- .find(|rule| rule_matches(rule, request, principals, body))
+ .find(|rule| rule_matches(rule, request, principals, input))
.map(|rule| LevelMatch {
rank: strictness_rank(rule),
rule,
@@ -183,13 +221,13 @@ pub(super) fn evaluate_outcome<'a>(
project_rules: &'a [Rule],
request: &Request,
principals: &PrincipalSet,
- body: Option<&[u8]>,
+ input: &MatchInput<'_>,
) -> Outcome<'a> {
let org_default = org_rules.iter().find(|r| r.is_default);
let project_default = project_rules.iter().find(|r| r.is_default);
- let org_match = first_match(org_rules, request, principals, body);
- let project_match = first_match(project_rules, request, principals, body);
+ let org_match = first_match(org_rules, request, principals, input);
+ let project_match = first_match(project_rules, request, principals, input);
// A Default-Block is enforced only under the carve (credentialed, non-LLM),
// at EVERY level.
@@ -344,7 +382,13 @@ mod tests {
#[test]
fn first_match_wins_by_priority() {
let rules = vec![rule("b", 1, Action::Block), rule("a", 0, Action::Allow)];
- match evaluate_outcome(&[], &rules, &request(), &no_principals(), None) {
+ match evaluate_outcome(
+ &[],
+ &rules,
+ &request(),
+ &no_principals(),
+ &MatchInput::empty(),
+ ) {
Outcome::Rule(r) => assert_eq!(r.id, "a"),
_ => panic!("expected a rule match"),
}
@@ -356,7 +400,13 @@ mod tests {
vec![rule("a", 5, Action::Allow), rule("b", 5, Action::Block)],
vec![rule("b", 5, Action::Block), rule("a", 5, Action::Allow)],
] {
- match evaluate_outcome(&[], &rules, &request(), &no_principals(), None) {
+ match evaluate_outcome(
+ &[],
+ &rules,
+ &request(),
+ &no_principals(),
+ &MatchInput::empty(),
+ ) {
Outcome::Rule(r) => assert_eq!(r.id, "a", "lower id wins the tie"),
_ => panic!("expected a rule match"),
}
@@ -373,13 +423,25 @@ mod tests {
let allow = rule("any", 2, Action::Allow);
let rules = vec![agent_scoped, other, allow];
- match evaluate_outcome(&[], &rules, &request(), &no_principals(), None) {
+ match evaluate_outcome(
+ &[],
+ &rules,
+ &request(),
+ &no_principals(),
+ &MatchInput::empty(),
+ ) {
Outcome::Rule(r) => assert_eq!(r.id, "scoped"),
_ => panic!("expected the agent-scoped match"),
}
let mut foreign = request();
foreign.agent_id = "agent-2".to_string();
- match evaluate_outcome(&[], &rules, &foreign, &no_principals(), None) {
+ match evaluate_outcome(
+ &[],
+ &rules,
+ &foreign,
+ &no_principals(),
+ &MatchInput::empty(),
+ ) {
// The directory identity must NOT match — the any-agent allow wins.
Outcome::Rule(r) => assert_eq!(r.id, "any"),
_ => panic!("expected the any-agent match"),
@@ -392,7 +454,13 @@ mod tests {
let mut orphan = rule("orphan", 0, Action::Block);
orphan.targets = Vec::new();
let control = rule("control", 1, Action::Allow);
- match evaluate_outcome(&[], &[orphan, control], &request(), &no_principals(), None) {
+ match evaluate_outcome(
+ &[],
+ &[orphan, control],
+ &request(),
+ &no_principals(),
+ &MatchInput::empty(),
+ ) {
Outcome::Rule(r) => assert_eq!(r.id, "control"),
_ => panic!("expected the control match"),
}
@@ -405,11 +473,23 @@ mod tests {
let rules = vec![default_rule(Action::Block)];
// Uncredentialed → the carve spares it.
assert!(matches!(
- evaluate_outcome(&[], &rules, &request(), &no_principals(), None),
+ evaluate_outcome(
+ &[],
+ &rules,
+ &request(),
+ &no_principals(),
+ &MatchInput::empty()
+ ),
Outcome::Allow
));
// Credentialed non-LLM → blocked, attributed to the Default Rule.
- match evaluate_outcome(&[], &rules, &injected_request(), &no_principals(), None) {
+ match evaluate_outcome(
+ &[],
+ &rules,
+ &injected_request(),
+ &no_principals(),
+ &MatchInput::empty(),
+ ) {
Outcome::DenyDefault(d) => assert!(d.is_default),
_ => panic!("expected the deny-default"),
}
@@ -417,7 +497,7 @@ mod tests {
let mut llm = injected_request();
llm.is_llm_host = true;
assert!(matches!(
- evaluate_outcome(&[], &rules, &llm, &no_principals(), None),
+ evaluate_outcome(&[], &rules, &llm, &no_principals(), &MatchInput::empty()),
Outcome::Allow
));
}
@@ -425,7 +505,13 @@ mod tests {
#[test]
fn explicit_allow_opens_the_same_level_default_block() {
let rules = vec![rule("open", 0, Action::Allow), default_rule(Action::Block)];
- match evaluate_outcome(&[], &rules, &injected_request(), &no_principals(), None) {
+ match evaluate_outcome(
+ &[],
+ &rules,
+ &injected_request(),
+ &no_principals(),
+ &MatchInput::empty(),
+ ) {
Outcome::Rule(r) => assert_eq!(r.id, "open"),
_ => panic!("expected the allow rule to win over its own default block"),
}
@@ -435,24 +521,273 @@ mod tests {
fn default_allow_is_neutral() {
let rules = vec![default_rule(Action::Allow)];
assert!(matches!(
- evaluate_outcome(&[], &rules, &injected_request(), &no_principals(), None),
+ evaluate_outcome(
+ &[],
+ &rules,
+ &injected_request(),
+ &no_principals(),
+ &MatchInput::empty()
+ ),
Outcome::Allow
));
}
- /// Test #11 (part): the OSS condition arm is the no-op — a conditioned block
- /// matches vacuously. This pins the Stage-G seam; if OSS ships real
- /// condition matching this test must flip with it.
+ // ── Stage-G: conditions are evaluated at both scopes ────────────────
+
+ fn conditioned(id: &str, priority: usize, action: Action, conditions: &str) -> Rule {
+ let mut r = rule(id, priority, action);
+ r.conditions = serde_json::from_str(conditions).ok();
+ r
+ }
+
+ fn body_input(body: &[u8]) -> MatchInput<'_> {
+ MatchInput {
+ body: Some(body),
+ body_truncated: false,
+ headers: None,
+ }
+ }
+
+ #[test]
+ fn conditioned_block_falls_through_when_body_lacks_the_needle() {
+ // OSS evaluates conditions since Tier 3a (this test used to pin the
+ // opposite no-op posture): a body-conditioned block whose needle is
+ // absent falls through and the next rule wins.
+ let rules = vec![
+ conditioned(
+ "cond",
+ 0,
+ Action::Block,
+ r#"[{"target":"body","operator":"contains","value":"needle"}]"#,
+ ),
+ rule("open", 1, Action::Allow),
+ ];
+ match evaluate_outcome(
+ &[],
+ &rules,
+ &request(),
+ &no_principals(),
+ &body_input(b"no match here"),
+ ) {
+ Outcome::Rule(r) => assert_eq!(r.id, "open"),
+ _ => panic!("expected the conditioned block to fall through"),
+ }
+ }
+
#[test]
- fn conditioned_rule_matches_with_no_body_in_oss() {
- let mut conditioned = rule("cond", 0, Action::Block);
- conditioned.conditions = serde_json::from_str(
- r#"[{"target":"body","operator":"contains","value":"never-present"}]"#,
- )
- .ok();
- match evaluate_outcome(&[], &[conditioned], &request(), &no_principals(), None) {
+ fn conditioned_block_matches_when_body_contains_the_needle() {
+ let rules = vec![
+ conditioned(
+ "cond",
+ 0,
+ Action::Block,
+ r#"[{"target":"body","operator":"contains","value":"needle"}]"#,
+ ),
+ rule("open", 1, Action::Allow),
+ ];
+ match evaluate_outcome(
+ &[],
+ &rules,
+ &request(),
+ &no_principals(),
+ &body_input(b"the needle is here"),
+ ) {
Outcome::Rule(r) => assert_eq!(r.id, "cond"),
- _ => panic!("expected the conditioned rule to match vacuously"),
+ _ => panic!("expected the conditioned block to match"),
+ }
+ }
+
+ #[test]
+ fn invalid_condition_on_a_v2_block_still_blocks() {
+ // Pins the pseudo-rule action mapping: the failure law is action-aware,
+ // so a Block rule with an uncompilable regex must still BLOCK. This
+ // test fails if `pseudo_rule` hardcodes Allow.
+ let rules = vec![
+ conditioned(
+ "broken",
+ 0,
+ Action::Block,
+ r#"[{"target":"body","operator":"regex","value":"(?<=x)["}]"#,
+ ),
+ rule("open", 1, Action::Allow),
+ ];
+ match evaluate_outcome(
+ &[],
+ &rules,
+ &request(),
+ &no_principals(),
+ &body_input(b"anything"),
+ ) {
+ Outcome::Rule(r) => assert_eq!(r.id, "broken", "Block must fail CLOSED"),
+ _ => panic!("expected the broken-condition block to match"),
+ }
+ // The symmetric guard: the same broken condition on an ALLOW rule
+ // falls through (it must not shadow a later block).
+ let rules = vec![
+ conditioned(
+ "broken-allow",
+ 0,
+ Action::Allow,
+ r#"[{"target":"body","operator":"regex","value":"(?<=x)["}]"#,
+ ),
+ rule("blocker", 1, Action::Block),
+ ];
+ match evaluate_outcome(
+ &[],
+ &rules,
+ &request(),
+ &no_principals(),
+ &body_input(b"anything"),
+ ) {
+ Outcome::Rule(r) => assert_eq!(r.id, "blocker"),
+ _ => panic!("expected the broken-condition allow to fall through"),
+ }
+ }
+
+ #[test]
+ fn conditions_are_enforced_at_the_org_scope_too() {
+ // The fail-closed-by-action law holds at BOTH scopes (F routes org and
+ // project through the same seam): an org-scope Block whose broken regex
+ // is unevaluable fails CLOSED, over-blocking even a project allow.
+ let org = vec![{
+ let mut r = conditioned(
+ "org-broken",
+ 0,
+ Action::Block,
+ r#"[{"target":"body","operator":"regex","value":"("}]"#,
+ );
+ r.scope = RuleScope::Organization;
+ r
+ }];
+ let project = vec![rule("proj-allow", 0, Action::Allow)];
+ match evaluate_outcome(
+ &org,
+ &project,
+ &request(),
+ &no_principals(),
+ &body_input(b"anything"),
+ ) {
+ Outcome::Rule(r) => {
+ assert_eq!(r.id, "org-broken", "org Block must fail closed");
+ assert_eq!(r.scope, RuleScope::Organization);
+ }
+ _ => panic!("expected the org broken-condition block"),
+ }
+ // The same org rule as an ALLOW falls through — its broken condition
+ // cannot widen or shadow the project block.
+ let org = vec![{
+ let mut r = conditioned(
+ "org-broken-allow",
+ 0,
+ Action::Allow,
+ r#"[{"target":"body","operator":"regex","value":"("}]"#,
+ );
+ r.scope = RuleScope::Organization;
+ r
+ }];
+ let project = vec![rule("proj-block", 0, Action::Block)];
+ match evaluate_outcome(
+ &org,
+ &project,
+ &request(),
+ &no_principals(),
+ &body_input(b"anything"),
+ ) {
+ Outcome::Rule(r) => assert_eq!(r.id, "proj-block"),
+ _ => panic!("the broken org allow must not shadow the project block"),
+ }
+ }
+
+ #[test]
+ fn secret_target_honors_conditions_and_fails_closed_by_action() {
+ let secret_target = || {
+ vec![Target::Secret {
+ host_patterns: vec!["api.example.com".to_string()],
+ }]
+ };
+ let cond = r#"[{"target":"body","operator":"contains","value":"needle"}]"#;
+ // A conditioned ALLOW on a secret must NOT match unconditionally — it
+ // would shadow the later Block (the widening the fail-closed law
+ // forbids).
+ let mut cond_allow = conditioned("sec-allow", 0, Action::Allow, cond);
+ cond_allow.targets = secret_target();
+ let mut blocker = rule("blocker", 1, Action::Block);
+ blocker.targets = secret_target();
+ let rules = vec![cond_allow, blocker];
+ match evaluate_outcome(
+ &[],
+ &rules,
+ &request(),
+ &no_principals(),
+ &body_input(b"no match here"),
+ ) {
+ Outcome::Rule(r) => assert_eq!(r.id, "blocker", "allow must fall through"),
+ _ => panic!("expected the block"),
+ }
+ // With the needle present the conditioned allow matches first.
+ match evaluate_outcome(
+ &[],
+ &rules,
+ &request(),
+ &no_principals(),
+ &body_input(b"has needle"),
+ ) {
+ Outcome::Rule(r) => assert_eq!(r.id, "sec-allow"),
+ _ => panic!("expected the conditioned allow"),
+ }
+ // An unevaluable condition on a secret-target Block fails CLOSED.
+ let mut broken_block = conditioned(
+ "broken",
+ 0,
+ Action::Block,
+ r#"[{"target":"body","operator":"regex","value":"("}]"#,
+ );
+ broken_block.targets = secret_target();
+ let rules = vec![broken_block, rule("open", 1, Action::Allow)];
+ match evaluate_outcome(
+ &[],
+ &rules,
+ &request(),
+ &no_principals(),
+ &body_input(b"anything"),
+ ) {
+ Outcome::Rule(r) => assert_eq!(r.id, "broken", "Block must fail closed"),
+ _ => panic!("expected the broken-condition block"),
+ }
+ }
+
+ #[test]
+ fn header_condition_narrows_a_v2_rule() {
+ let rules = vec![
+ conditioned(
+ "hdr",
+ 0,
+ Action::Block,
+ r#"[{"target":"header","operator":"equals","key":"X-Env","value":"prod"}]"#,
+ ),
+ rule("open", 1, Action::Allow),
+ ];
+ let mut headers = hyper::HeaderMap::new();
+ headers.insert("x-env", hyper::header::HeaderValue::from_static("prod"));
+ let input = MatchInput {
+ body: None,
+ body_truncated: false,
+ headers: Some(&headers),
+ };
+ match evaluate_outcome(&[], &rules, &request(), &no_principals(), &input) {
+ Outcome::Rule(r) => assert_eq!(r.id, "hdr", "matching header must block"),
+ _ => panic!("expected the header-conditioned block"),
+ }
+ let mut other = hyper::HeaderMap::new();
+ other.insert("x-env", hyper::header::HeaderValue::from_static("dev"));
+ let input = MatchInput {
+ body: None,
+ body_truncated: false,
+ headers: Some(&other),
+ };
+ match evaluate_outcome(&[], &rules, &request(), &no_principals(), &input) {
+ Outcome::Rule(r) => assert_eq!(r.id, "open", "non-matching header falls through"),
+ _ => panic!("expected the allow"),
}
}
@@ -484,16 +819,28 @@ mod tests {
// BLOCK: matching winner binds; no winner → no match (fail-closed).
let blk = vec![conn_rule("c1", Action::Block)];
assert!(matches!(
- evaluate_outcome(&[], &blk, &req_via(Some("c1")), &no_principals(), None),
+ evaluate_outcome(&[], &blk, &req_via(Some("c1")), &no_principals(), &MatchInput::empty()),
Outcome::Rule(r) if r.action == Action::Block
));
assert!(matches!(
- evaluate_outcome(&[], &blk, &req_via(None), &no_principals(), None),
+ evaluate_outcome(
+ &[],
+ &blk,
+ &req_via(None),
+ &no_principals(),
+ &MatchInput::empty()
+ ),
Outcome::Allow
));
// A same-provider sibling account → no match.
assert!(matches!(
- evaluate_outcome(&[], &blk, &req_via(Some("c2")), &no_principals(), None),
+ evaluate_outcome(
+ &[],
+ &blk,
+ &req_via(Some("c2")),
+ &no_principals(),
+ &MatchInput::empty()
+ ),
Outcome::Allow
));
@@ -506,7 +853,7 @@ mod tests {
&allow_over_block,
&req_via(Some("c1")),
&no_principals(),
- None,
+ &MatchInput::empty(),
) {
Outcome::Rule(r) => assert_eq!(r.action, Action::Allow),
_ => panic!("winner should open its own connection allow"),
@@ -517,7 +864,7 @@ mod tests {
&allow_over_block,
&req_via(None),
&no_principals(),
- None
+ &MatchInput::empty()
),
Outcome::DenyDefault(_)
));
@@ -528,7 +875,13 @@ mod tests {
#[test]
fn org_rule_is_enforced_and_carries_org_scope() {
let org = vec![org_rule("org-block", 0, Action::Block)];
- match evaluate_outcome(&org, &[], &request(), &no_principals(), None) {
+ match evaluate_outcome(
+ &org,
+ &[],
+ &request(),
+ &no_principals(),
+ &MatchInput::empty(),
+ ) {
Outcome::Rule(r) => {
assert_eq!(r.id, "org-block");
assert_eq!(r.scope, RuleScope::Organization);
@@ -549,13 +902,19 @@ mod tests {
scoped.identities = vec![identity];
let org = vec![scoped];
// Present in the principal set → the rule matches.
- match evaluate_outcome(&org, &[], &request(), &principals(), None) {
+ match evaluate_outcome(&org, &[], &request(), &principals(), &MatchInput::empty()) {
Outcome::Rule(r) => assert_eq!(r.id, id),
_ => panic!("expected {id} to match via principals"),
}
// Absent (empty/stale set) → the rule narrows to nothing.
assert!(matches!(
- evaluate_outcome(&org, &[], &request(), &no_principals(), None),
+ evaluate_outcome(
+ &org,
+ &[],
+ &request(),
+ &no_principals(),
+ &MatchInput::empty()
+ ),
Outcome::Allow
));
}
@@ -572,7 +931,7 @@ mod tests {
foreign_group.identities = vec![Identity::Group("g-other".to_string())];
let org = vec![foreign_user, foreign_group];
assert!(matches!(
- evaluate_outcome(&org, &[], &request(), &principals(), None),
+ evaluate_outcome(&org, &[], &request(), &principals(), &MatchInput::empty()),
Outcome::Allow
));
}
@@ -586,12 +945,24 @@ mod tests {
fn empty_org_fails_open_not_closed() {
// No project rules either → plain allow, even credentialed.
assert!(matches!(
- evaluate_outcome(&[], &[], &injected_request(), &no_principals(), None),
+ evaluate_outcome(
+ &[],
+ &[],
+ &injected_request(),
+ &no_principals(),
+ &MatchInput::empty()
+ ),
Outcome::Allow
));
// An empty org slice changes nothing vs the project-only walk.
let project = vec![rule("open", 0, Action::Allow), default_rule(Action::Block)];
- match evaluate_outcome(&[], &project, &injected_request(), &no_principals(), None) {
+ match evaluate_outcome(
+ &[],
+ &project,
+ &injected_request(),
+ &no_principals(),
+ &MatchInput::empty(),
+ ) {
Outcome::Rule(r) => assert_eq!(r.id, "open"),
_ => panic!("expected the project allow, not a phantom org block"),
}
@@ -602,7 +973,13 @@ mod tests {
#[test]
fn empty_project_lets_the_org_level_decide() {
let org = vec![org_rule("org-allow", 0, Action::Allow)];
- match evaluate_outcome(&org, &[], &request(), &no_principals(), None) {
+ match evaluate_outcome(
+ &org,
+ &[],
+ &request(),
+ &no_principals(),
+ &MatchInput::empty(),
+ ) {
Outcome::Rule(r) => {
assert_eq!(r.id, "org-allow");
assert_eq!(r.scope, RuleScope::Organization);
@@ -611,7 +988,13 @@ mod tests {
}
// An org default-Block over an empty project blocks under the carve.
let org = vec![org_default(Action::Block)];
- match evaluate_outcome(&org, &[], &injected_request(), &no_principals(), None) {
+ match evaluate_outcome(
+ &org,
+ &[],
+ &injected_request(),
+ &no_principals(),
+ &MatchInput::empty(),
+ ) {
Outcome::DenyDefault(d) => assert_eq!(d.scope, RuleScope::Organization),
_ => panic!("expected the org deny-default"),
}
@@ -624,14 +1007,26 @@ mod tests {
// Org guardrail Block beats a project allow…
let org = vec![org_rule("org-block", 0, Action::Block)];
let project = vec![rule("proj-allow", 0, Action::Allow)];
- match evaluate_outcome(&org, &project, &request(), &no_principals(), None) {
+ match evaluate_outcome(
+ &org,
+ &project,
+ &request(),
+ &no_principals(),
+ &MatchInput::empty(),
+ ) {
Outcome::Rule(r) => assert_eq!(r.id, "org-block"),
_ => panic!("expected the org block"),
}
// …and symmetrically a project Block survives an org allow.
let org = vec![org_rule("org-allow", 0, Action::Allow)];
let project = vec![rule("proj-block", 0, Action::Block)];
- match evaluate_outcome(&org, &project, &request(), &no_principals(), None) {
+ match evaluate_outcome(
+ &org,
+ &project,
+ &request(),
+ &no_principals(),
+ &MatchInput::empty(),
+ ) {
Outcome::Rule(r) => assert_eq!(r.id, "proj-block"),
_ => panic!("expected the project block"),
}
@@ -645,7 +1040,13 @@ mod tests {
r
}];
let project = vec![rate_rule("proj-rate", 0)];
- match evaluate_outcome(&org, &project, &request(), &no_principals(), None) {
+ match evaluate_outcome(
+ &org,
+ &project,
+ &request(),
+ &no_principals(),
+ &MatchInput::empty(),
+ ) {
Outcome::Rule(r) => assert_eq!(r.id, "org-approval"),
_ => panic!("expected the approval to outrank the rate limit"),
}
@@ -661,7 +1062,13 @@ mod tests {
r
}];
let project = vec![rate_rule("proj-rate", 0)];
- match evaluate_outcome(&org, &project, &request(), &no_principals(), None) {
+ match evaluate_outcome(
+ &org,
+ &project,
+ &request(),
+ &no_principals(),
+ &MatchInput::empty(),
+ ) {
Outcome::Rule(r) => {
assert_eq!(r.id, "org-rate");
assert_eq!(r.scope, RuleScope::Organization);
@@ -678,7 +1085,13 @@ mod tests {
malformed.identities = vec![Identity::Other];
let org = vec![malformed, org_rule("org-any", 1, Action::Block)];
let project = vec![rule("proj-any", 0, Action::Allow)];
- match evaluate_outcome(&org, &project, &request(), &principals(), None) {
+ match evaluate_outcome(
+ &org,
+ &project,
+ &request(),
+ &principals(),
+ &MatchInput::empty(),
+ ) {
Outcome::Rule(r) => assert_eq!(r.id, "org-any"),
_ => panic!("expected the any-identity org block"),
}
@@ -694,7 +1107,13 @@ mod tests {
// Direction 1: org default-Block + lone project allow → org deny-default.
let org = vec![org_default(Action::Block)];
let project = vec![rule("proj-allow", 0, Action::Allow)];
- match evaluate_outcome(&org, &project, &injected_request(), &no_principals(), None) {
+ match evaluate_outcome(
+ &org,
+ &project,
+ &injected_request(),
+ &no_principals(),
+ &MatchInput::empty(),
+ ) {
Outcome::DenyDefault(d) => {
assert!(d.is_default);
assert_eq!(d.scope, RuleScope::Organization);
@@ -702,7 +1121,13 @@ mod tests {
_ => panic!("the project allow must not punch the org floor"),
}
// Without the carve the org level allows — the project allow wins.
- match evaluate_outcome(&org, &project, &request(), &no_principals(), None) {
+ match evaluate_outcome(
+ &org,
+ &project,
+ &request(),
+ &no_principals(),
+ &MatchInput::empty(),
+ ) {
Outcome::Rule(r) => assert_eq!(r.id, "proj-allow"),
_ => panic!("expected the project allow off the carve"),
}
@@ -711,7 +1136,13 @@ mod tests {
// project deny-default.
let org = vec![org_rule("org-allow", 0, Action::Allow)];
let project = vec![default_rule(Action::Block)];
- match evaluate_outcome(&org, &project, &injected_request(), &no_principals(), None) {
+ match evaluate_outcome(
+ &org,
+ &project,
+ &injected_request(),
+ &no_principals(),
+ &MatchInput::empty(),
+ ) {
Outcome::DenyDefault(d) => {
assert!(d.is_default);
assert_eq!(d.scope, RuleScope::Project);
@@ -719,7 +1150,13 @@ mod tests {
_ => panic!("the org allow must not punch the project allowlist floor"),
}
// Without the carve the project level allows — the org allow wins.
- match evaluate_outcome(&org, &project, &request(), &no_principals(), None) {
+ match evaluate_outcome(
+ &org,
+ &project,
+ &request(),
+ &no_principals(),
+ &MatchInput::empty(),
+ ) {
Outcome::Rule(r) => assert_eq!(r.id, "org-allow"),
_ => panic!("expected the org allow off the carve"),
}
@@ -732,13 +1169,25 @@ mod tests {
// A project BLOCK applies even against an org default-Block…
let org = vec![org_default(Action::Block)];
let project = vec![rule("proj-block", 0, Action::Block)];
- match evaluate_outcome(&org, &project, &injected_request(), &no_principals(), None) {
+ match evaluate_outcome(
+ &org,
+ &project,
+ &injected_request(),
+ &no_principals(),
+ &MatchInput::empty(),
+ ) {
Outcome::Rule(r) => assert_eq!(r.id, "proj-block"),
_ => panic!("a block must survive the org floor"),
}
// …and with no org default-Block a lone org allow just wins.
let org = vec![org_rule("org-allow", 0, Action::Allow)];
- match evaluate_outcome(&org, &[], &injected_request(), &no_principals(), None) {
+ match evaluate_outcome(
+ &org,
+ &[],
+ &injected_request(),
+ &no_principals(),
+ &MatchInput::empty(),
+ ) {
Outcome::Rule(r) => assert_eq!(r.id, "org-allow"),
_ => panic!("expected the org allow"),
}
@@ -751,20 +1200,44 @@ mod tests {
// Org default-Block: spared off the carve, blocks under it.
let org = vec![org_default(Action::Block)];
assert!(matches!(
- evaluate_outcome(&org, &[], &request(), &no_principals(), None),
+ evaluate_outcome(
+ &org,
+ &[],
+ &request(),
+ &no_principals(),
+ &MatchInput::empty()
+ ),
Outcome::Allow
));
- match evaluate_outcome(&org, &[], &injected_request(), &no_principals(), None) {
+ match evaluate_outcome(
+ &org,
+ &[],
+ &injected_request(),
+ &no_principals(),
+ &MatchInput::empty(),
+ ) {
Outcome::DenyDefault(d) => assert_eq!(d.scope, RuleScope::Organization),
_ => panic!("expected the org deny-default under the carve"),
}
// Project default-Block: same carve, independently.
let project = vec![default_rule(Action::Block)];
assert!(matches!(
- evaluate_outcome(&[], &project, &request(), &no_principals(), None),
+ evaluate_outcome(
+ &[],
+ &project,
+ &request(),
+ &no_principals(),
+ &MatchInput::empty()
+ ),
Outcome::Allow
));
- match evaluate_outcome(&[], &project, &injected_request(), &no_principals(), None) {
+ match evaluate_outcome(
+ &[],
+ &project,
+ &injected_request(),
+ &no_principals(),
+ &MatchInput::empty(),
+ ) {
Outcome::DenyDefault(d) => assert_eq!(d.scope, RuleScope::Project),
_ => panic!("expected the project deny-default under the carve"),
}
@@ -774,7 +1247,13 @@ mod tests {
fn absent_project_default_with_org_default_allow_is_allow() {
let org = vec![org_default(Action::Allow)];
assert!(matches!(
- evaluate_outcome(&org, &[], &injected_request(), &no_principals(), None),
+ evaluate_outcome(
+ &org,
+ &[],
+ &injected_request(),
+ &no_principals(),
+ &MatchInput::empty()
+ ),
Outcome::Allow
));
}
diff --git a/apps/gateway/src/policy_engine/types.rs b/apps/gateway/src/policy_engine/types.rs
index 85470964..846a9f7f 100644
--- a/apps/gateway/src/policy_engine/types.rs
+++ b/apps/gateway/src/policy_engine/types.rs
@@ -113,9 +113,10 @@ pub(super) struct Rule {
pub require_approval: bool,
pub rate_limit: Option,
pub rate_limit_window: Option,
- /// Carried for structural fidelity and routed through the edition-swapped
- /// `condition_match` — which is the no-op arm in OSS, so conditions are
- /// never evaluated here (matching the legacy OSS gateway exactly).
+ /// The rule's behavioral conditions (body/header), routed through the
+ /// edition-swapped `condition_match`. In OSS (Tier 3a) they are EVALUATED
+ /// byte-level over the buffered body and request headers, carrying the
+ /// rule's Block-ness so an unevaluable condition fails closed by action.
pub conditions: Option,
}
diff --git a/apps/web/src/lib/api/types.ts b/apps/web/src/lib/api/types.ts
index 704d24b3..ee9e0f44 100644
--- a/apps/web/src/lib/api/types.ts
+++ b/apps/web/src/lib/api/types.ts
@@ -325,7 +325,9 @@ export interface CreateInvitationInput {
// ── Shared policy identity/condition shapes ──────────────────────────────────
// Used by the editor's PolicyRuleV2. Project rules target a specific agent or
// "any" (empty); org rules target directory identities (user / user-group).
-// Conditions are body-contains.
+// Conditions are body OR header matches (contains/equals/regex/exists) — the
+// wire shape mirrors the authoritative RuleCondition: `key` names the header
+// (header target only) and `value` is absent for `exists`.
export type ProjectionIdentity =
| { type: "agent"; id: string }
@@ -335,7 +337,8 @@ export type ProjectionIdentity =
export interface ProjectionCondition {
target: string;
operator: string;
- value: string;
+ value?: string;
+ key?: string;
}
// ── Editable policy rules (policy_rules_v2) ──────────────────────────────────
diff --git a/apps/web/src/lib/components/condition-builder.tsx b/apps/web/src/lib/components/condition-builder.tsx
deleted file mode 100644
index 1bb1d88f..00000000
--- a/apps/web/src/lib/components/condition-builder.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-"use client";
-
-import type { RuleCondition } from "@onecli/api/validations/policy-rule";
-
-export interface ConditionBuilderProps {
- conditions: RuleCondition[];
- onChange: (conditions: RuleCondition[]) => void;
-}
-
-export const ConditionBuilder = ({}: ConditionBuilderProps) => (
-
-
- Match conditions (body content, headers) are not yet available in this
- build.
-
-
-);
diff --git a/apps/web/src/lib/components/condition-builder/condition-builder.tsx b/apps/web/src/lib/components/condition-builder/condition-builder.tsx
new file mode 100644
index 00000000..13c55c75
--- /dev/null
+++ b/apps/web/src/lib/components/condition-builder/condition-builder.tsx
@@ -0,0 +1,86 @@
+"use client";
+
+import { Plus } from "lucide-react";
+import type { RuleCondition } from "@onecli/api/validations/policy-rule";
+import { Button } from "@onecli/ui/components/button";
+import { ConditionRow } from "./condition-row";
+
+/** Mirrors the API's `z.array(ruleConditionSchema).max(10)` cap. */
+const MAX_CONDITIONS = 10;
+
+export interface ConditionBuilderProps {
+ conditions: RuleCondition[];
+ onChange: (conditions: RuleCondition[]) => void;
+}
+
+/**
+ * Build the behavioral conditions (body/header matching) that narrow when a
+ * rule applies. The gateway evaluates them per request: ALL conditions must
+ * match for the rule to apply. Body matching runs over the buffered request
+ * body (up to 256 KB); a body over 256 KB can't be evaluated — Block rules
+ * treat it as matching (fail-closed), other rules don't apply. Header names
+ * are case-insensitive, header values case-sensitive (use a `(?i)` regex for
+ * case-insensitive values). Regex uses Rust syntax — no lookaround or
+ * backreferences.
+ */
+export const ConditionBuilder = ({
+ conditions,
+ onChange,
+}: ConditionBuilderProps) => {
+ const update = (index: number, condition: RuleCondition) =>
+ onChange(conditions.map((c, i) => (i === index ? condition : c)));
+ const remove = (index: number) =>
+ onChange(conditions.filter((_, i) => i !== index));
+ const add = () =>
+ onChange([...conditions, { target: "body", operator: "contains" }]);
+
+ return (
+
+ {conditions.length === 0 ? (
+
+ No conditions — the rule applies to every matching request. Add a
+ condition to also match on request body content or headers.
+
+ ) : (
+
+ {conditions.map((condition, index) => (
+ update(index, c)}
+ onRemove={() => remove(index)}
+ />
+ ))}
+
+ )}
+
+
= MAX_CONDITIONS}
+ >
+
+ Add condition
+
+ {conditions.length > 1 && (
+
+ All conditions must match.
+
+ )}
+
+ {conditions.length > 0 && (
+
+ Bodies over 256 KB can't be evaluated: Block rules treat them as
+ matching; other rules don't apply. Header values are
+ case-sensitive — use a (?i) regex
+ for case-insensitive matching. Regex uses Rust syntax (no lookaround
+ or backreferences).
+
+ )}
+
+ );
+};
diff --git a/apps/web/src/lib/components/condition-builder/condition-row.tsx b/apps/web/src/lib/components/condition-builder/condition-row.tsx
new file mode 100644
index 00000000..8f0d9dd8
--- /dev/null
+++ b/apps/web/src/lib/components/condition-builder/condition-row.tsx
@@ -0,0 +1,148 @@
+"use client";
+
+import { X } from "lucide-react";
+import type { RuleCondition } from "@onecli/api/validations/policy-rule";
+import { Button } from "@onecli/ui/components/button";
+import { Input } from "@onecli/ui/components/input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@onecli/ui/components/select";
+import { conditionError } from "./validate";
+
+const TARGET_OPTIONS: { value: RuleCondition["target"]; label: string }[] = [
+ { value: "body", label: "Request body" },
+ { value: "header", label: "Header" },
+];
+
+const OPERATOR_OPTIONS: {
+ value: RuleCondition["operator"];
+ label: string;
+ headerOnly?: boolean;
+}[] = [
+ { value: "contains", label: "contains" },
+ { value: "equals", label: "equals" },
+ { value: "regex", label: "matches regex" },
+ // `exists` is header-only ("has a body" is not a meaningful policy).
+ { value: "exists", label: "is present", headerOnly: true },
+];
+
+export interface ConditionRowProps {
+ condition: RuleCondition;
+ onChange: (condition: RuleCondition) => void;
+ onRemove: () => void;
+}
+
+export const ConditionRow = ({
+ condition,
+ onChange,
+ onRemove,
+}: ConditionRowProps) => {
+ const isHeader = condition.target === "header";
+ const needsValue = condition.operator !== "exists";
+ const error = conditionError(condition);
+
+ const setTarget = (target: RuleCondition["target"]) => {
+ onChange({
+ target,
+ // `exists` doesn't apply to bodies; `key` is the header name.
+ operator:
+ target === "body" && condition.operator === "exists"
+ ? "contains"
+ : condition.operator,
+ value: condition.value,
+ key: target === "header" ? condition.key : undefined,
+ });
+ };
+
+ const setOperator = (operator: RuleCondition["operator"]) => {
+ onChange({
+ ...condition,
+ operator,
+ value: operator === "exists" ? undefined : condition.value,
+ });
+ };
+
+ return (
+
+ );
+};
diff --git a/apps/web/src/lib/components/condition-builder/index.ts b/apps/web/src/lib/components/condition-builder/index.ts
new file mode 100644
index 00000000..480a23e1
--- /dev/null
+++ b/apps/web/src/lib/components/condition-builder/index.ts
@@ -0,0 +1,3 @@
+export { ConditionBuilder } from "./condition-builder";
+export type { ConditionBuilderProps } from "./condition-builder";
+export { conditionError, isConditionsValid } from "./validate";
diff --git a/apps/web/src/lib/components/condition-builder/validate.ts b/apps/web/src/lib/components/condition-builder/validate.ts
new file mode 100644
index 00000000..cd31b35e
--- /dev/null
+++ b/apps/web/src/lib/components/condition-builder/validate.ts
@@ -0,0 +1,38 @@
+import type { RuleCondition } from "@onecli/api/validations/policy-rule";
+import {
+ compilesAsConditionRegex,
+ isValidHeaderName,
+} from "@onecli/api/validations/condition-syntax";
+
+/** The row's inline validation message, mirroring the API's rules (the syntax
+ * checks are the SAME shared helpers, so the two can't drift) — so a 422 is
+ * never the first feedback. `null` = valid. */
+export const conditionError = (condition: RuleCondition): string | null => {
+ if (condition.target === "header") {
+ if (!condition.key?.trim()) {
+ return "Header name is required.";
+ }
+ // The gateway accepts RFC 9110 token names only — anything else (a
+ // copy-pasted trailing space, an embedded space) would save but be
+ // permanently unevaluable.
+ if (!isValidHeaderName(condition.key)) {
+ return "Invalid header name.";
+ }
+ }
+ if (condition.operator !== "exists" && !condition.value) {
+ return "Value is required.";
+ }
+ if (
+ condition.operator === "regex" &&
+ condition.value &&
+ !compilesAsConditionRegex(condition.value)
+ ) {
+ return "Invalid regular expression.";
+ }
+ return null;
+};
+
+/** Whether every condition would pass API validation — for consumers that want
+ * to gate submit (the API still backstops). */
+export const isConditionsValid = (conditions: RuleCondition[]): boolean =>
+ conditions.every((condition) => conditionError(condition) === null);
diff --git a/apps/web/src/lib/policy-editor/how-rules-evaluated.tsx b/apps/web/src/lib/policy-editor/how-rules-evaluated.tsx
index 753cc483..ad8e433f 100644
--- a/apps/web/src/lib/policy-editor/how-rules-evaluated.tsx
+++ b/apps/web/src/lib/policy-editor/how-rules-evaluated.tsx
@@ -77,6 +77,11 @@ export const HowRulesEvaluated = () => (
Only one rule ever decides — rate limits and approvals don’t
stack.
+
+ Conditions further narrow when a rule matches: ALL of a rule’s
+ conditions must hold. A condition that can’t be evaluated (e.g. an
+ oversized body) makes a Block rule match — it never weakens one.
+
);
diff --git a/apps/web/src/lib/policy-editor/policy-rule-form.tsx b/apps/web/src/lib/policy-editor/policy-rule-form.tsx
index 67aaf044..4150db7e 100644
--- a/apps/web/src/lib/policy-editor/policy-rule-form.tsx
+++ b/apps/web/src/lib/policy-editor/policy-rule-form.tsx
@@ -47,7 +47,10 @@ import { useScopedSecrets } from "@/hooks/use-secrets";
// them to the real editors; the OSS modules are locked "available in OneCLI
// Cloud" surfaces (conditions) or inert (the org picker — OSS mounts no org
// scope).
-import { ConditionBuilder } from "@/lib/components/condition-builder";
+import {
+ ConditionBuilder,
+ isConditionsValid,
+} from "@/lib/components/condition-builder";
// Alias key on purpose (see editor-chrome's note): a relative import would
// bypass the edition seam.
import { OrgIdentityPicker } from "@/lib/policy-editor/identity-picker";
@@ -882,29 +885,14 @@ export const PolicyRuleForm = ({
{!(targetKind === "app" && appTarget.mode === "specific") && (
Conditions
+ {/* Every target kind honors conditions in the gateway — network
+ verbatim, app targets through the catalog fan-out (whole-app
+ included), secrets on their resolved hosts — so no per-target
+ carve-out note is needed. */}
- {/* Whether conditions gate matching depends on the target: a
- secret, or a WHOLE-app (no-tools) app/connection target, matches
- host-only and ignores conditions; a tool-narrowed target runs
- the tool fan-out, which honors conditions like a network rule
- (so no note is shown then). */}
- {targetKind === "secret" && (
-
- Conditions don't apply to this target type — it matches
- its hosts regardless of request content.
-
- )}
- {targetKind === "app" &&
- appTarget.tools.length === 0 &&
- appTarget.mode === "all" && (
-
- Conditions don't apply to a whole-app target — it
- matches the app's hosts regardless of request content.
-
- )}
)}
{/* The Conditions editor is hidden for a specific-connection target, but
@@ -933,7 +921,16 @@ export const PolicyRuleForm = ({
{saving
? isEdit
diff --git a/packages/api/src/validations/condition-syntax.ts b/packages/api/src/validations/condition-syntax.ts
new file mode 100644
index 00000000..8455eddc
--- /dev/null
+++ b/packages/api/src/validations/condition-syntax.ts
@@ -0,0 +1,38 @@
+// Shared syntax checks for behavioral rule conditions, used by BOTH the API's
+// `ruleConditionSchema` and the web condition builder's inline validation
+// (`apps/web/src/lib/components/condition-builder/validate.ts`) so the two can
+// never drift. Deliberately zod-free: the web imports this at runtime.
+
+// RFC 9110 token charset — exactly the names the gateway's
+// `HeaderName::from_bytes` accepts. A key outside this set (embedded space,
+// trailing copy-paste whitespace) would save fine but be permanently
+// unevaluable on the gateway: a Block rule would over-block everything it
+// targets and an Allow rule would silently never apply.
+const HEADER_NAME_TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
+
+export const isValidHeaderName = (key: string): boolean =>
+ HEADER_NAME_TOKEN.test(key);
+
+/**
+ * Best-effort JS compile check for a gateway (Rust `regex`) pattern. The
+ * gateway is the authoritative fail-closed backstop; this exists purely so a
+ * typo surfaces before save. Rust-only syntax that JS `RegExp` lacks is
+ * normalized away first — a false ACCEPT is safe, a false REJECT would block
+ * valid gateway patterns:
+ * - standalone inline flag groups `(?i)` → removed
+ * - scoped flag groups `(?i:…)` → plain group `(?:…)`
+ * - named groups `(?P…)` → JS named groups `(?…)`
+ */
+export const compilesAsConditionRegex = (pattern: string): boolean => {
+ try {
+ new RegExp(
+ pattern
+ .replace(/\(\?[a-zA-Z-]+\)/g, "")
+ .replace(/\(\?[a-zA-Z-]+:/g, "(?:")
+ .replace(/\(\?P {
+ it("still parses the legacy body/contains row (stored-data compatibility)", () => {
+ expect(
+ ruleConditionSchema.parse({
+ target: "body",
+ operator: "contains",
+ value: "delete repo",
+ }),
+ ).toEqual({ target: "body", operator: "contains", value: "delete repo" });
+ // `key` was reserved from day one — accepted (and ignored) on body rows.
+ expect(
+ ruleConditionSchema.safeParse({
+ target: "body",
+ operator: "contains",
+ value: "x",
+ key: "ignored",
+ }).success,
+ ).toBe(true);
+ });
+
+ it("accepts body equals/regex conditions", () => {
+ expect(
+ ruleConditionSchema.safeParse({
+ target: "body",
+ operator: "equals",
+ value: "exact",
+ }).success,
+ ).toBe(true);
+ // Rust inline flag groups (`(?i)`) are gateway-valid — the JS best-effort
+ // check must not reject them (it strips them before compiling).
+ expect(
+ ruleConditionSchema.safeParse({
+ target: "body",
+ operator: "regex",
+ value: "(?i)delete\\s+repo",
+ }).success,
+ ).toBe(true);
+ // Scoped flag groups (`(?i:…)`) and named groups (`(?P…)`) are also
+ // gateway-valid Rust syntax JS lacks — normalized, never falsely rejected.
+ expect(
+ ruleConditionSchema.safeParse({
+ target: "body",
+ operator: "regex",
+ value: "(?i:delete) repo",
+ }).success,
+ ).toBe(true);
+ expect(
+ ruleConditionSchema.safeParse({
+ target: "body",
+ operator: "regex",
+ value: "(?Pdelete|remove) repo",
+ }).success,
+ ).toBe(true);
+ });
+
+ it("accepts header conditions with a key", () => {
+ expect(
+ ruleConditionSchema.safeParse({
+ target: "header",
+ operator: "equals",
+ key: "x-goog-user-project",
+ value: "prod",
+ }).success,
+ ).toBe(true);
+ });
+
+ it("rejects a header condition without a key", () => {
+ expect(
+ ruleConditionSchema.safeParse({
+ target: "header",
+ operator: "equals",
+ value: "prod",
+ }).success,
+ ).toBe(false);
+ // Whitespace-only keys count as missing.
+ expect(
+ ruleConditionSchema.safeParse({
+ target: "header",
+ operator: "equals",
+ key: " ",
+ value: "prod",
+ }).success,
+ ).toBe(false);
+ });
+
+ it("rejects header names outside the RFC 9110 token charset", () => {
+ // The gateway's `HeaderName::from_bytes` rejects these — saving them
+ // would create a permanently unevaluable condition (silent no-op Allow /
+ // over-blocking Block), so the API must refuse them up front.
+ for (const key of ["x-foo ", " x-foo", "X Foo", "x:foo", "héader"]) {
+ expect(
+ ruleConditionSchema.safeParse({
+ target: "header",
+ operator: "equals",
+ key,
+ value: "v",
+ }).success,
+ ).toBe(false);
+ }
+ // The full token charset is accepted.
+ expect(
+ ruleConditionSchema.safeParse({
+ target: "header",
+ operator: "exists",
+ key: "x-api_key.v2~test",
+ }).success,
+ ).toBe(true);
+ });
+
+ it("accepts exists on a header without a value, rejects it on body", () => {
+ expect(
+ ruleConditionSchema.safeParse({
+ target: "header",
+ operator: "exists",
+ key: "x-api-key",
+ }).success,
+ ).toBe(true);
+ expect(
+ ruleConditionSchema.safeParse({ target: "body", operator: "exists" })
+ .success,
+ ).toBe(false);
+ });
+
+ it("rejects a missing/empty value for the value-bearing operators", () => {
+ for (const operator of ["contains", "equals", "regex"] as const) {
+ expect(
+ ruleConditionSchema.safeParse({ target: "body", operator }).success,
+ ).toBe(false);
+ expect(
+ ruleConditionSchema.safeParse({ target: "body", operator, value: "" })
+ .success,
+ ).toBe(false);
+ }
+ });
+
+ it("rejects a regex that does not compile (best-effort UX check)", () => {
+ expect(
+ ruleConditionSchema.safeParse({
+ target: "body",
+ operator: "regex",
+ value: "([unclosed",
+ }).success,
+ ).toBe(false);
+ });
+
+ it("rejects unknown targets and operators", () => {
+ expect(
+ ruleConditionSchema.safeParse({
+ target: "cookies",
+ operator: "contains",
+ value: "x",
+ }).success,
+ ).toBe(false);
+ expect(
+ ruleConditionSchema.safeParse({
+ target: "body",
+ operator: "telepathy",
+ value: "x",
+ }).success,
+ ).toBe(false);
+ });
+});
diff --git a/packages/api/src/validations/policy-rule.ts b/packages/api/src/validations/policy-rule.ts
index a4030f25..56cfc97a 100644
--- a/packages/api/src/validations/policy-rule.ts
+++ b/packages/api/src/validations/policy-rule.ts
@@ -1,14 +1,65 @@
import { z } from "zod";
+import {
+ compilesAsConditionRegex,
+ isValidHeaderName,
+} from "./condition-syntax";
+
export const policyModeSchema = z.enum(["allow", "deny"]);
export type PolicyMode = z.infer;
-export const ruleConditionSchema = z.object({
- target: z.enum(["body"]),
- operator: z.enum(["contains"]),
- value: z.string().min(1).max(1000),
- key: z.string().max(500).optional(),
-});
+// A behavioral rule condition, evaluated by the gateway (`condition_match`):
+// `body` matches the buffered request body, `header` matches the named request
+// header (`key` = the header name, case-insensitive per RFC 9110; values are
+// compared case-sensitively — `(?i)` regex for insensitive). `exists` is
+// header-only; every other operator requires a `value`. A strict superset of
+// the original `{target:"body", operator:"contains"}` shape, so stored rows
+// keep validating. Regex compilation here (JS) is best-effort UX — the gateway
+// compiles Rust `regex` syntax (no lookaround/backreferences) and treats an
+// uncompilable pattern fail-closed (a Block rule over-blocks).
+export const ruleConditionSchema = z
+ .object({
+ target: z.enum(["body", "header"]),
+ operator: z.enum(["contains", "equals", "regex", "exists"]),
+ value: z.string().max(1000).optional(),
+ key: z.string().max(500).optional(),
+ })
+ .superRefine((c, ctx) => {
+ if (c.target === "header") {
+ if (!c.key?.trim()) {
+ ctx.addIssue({
+ code: "custom",
+ message: "header conditions require a header name (key)",
+ });
+ } else if (!isValidHeaderName(c.key)) {
+ // Validate the RAW key against the RFC 9110 token charset the
+ // gateway's `HeaderName::from_bytes` accepts — a key it rejects
+ // (embedded/trailing space) would save fine but be permanently
+ // unevaluable: a silent no-op Allow or an over-blocking Block.
+ ctx.addIssue({ code: "custom", message: "invalid header name" });
+ }
+ }
+ if (c.operator === "exists" && c.target !== "header") {
+ ctx.addIssue({
+ code: "custom",
+ message: "exists applies to headers only",
+ });
+ }
+ if (c.operator !== "exists" && !(c.value && c.value.length >= 1)) {
+ ctx.addIssue({ code: "custom", message: "value is required" });
+ }
+ if (
+ c.operator === "regex" &&
+ c.value &&
+ !compilesAsConditionRegex(c.value)
+ ) {
+ // Best-effort typo check only — the gateway compiles Rust `regex`
+ // syntax and is the authoritative (fail-closed) backstop. The shared
+ // helper normalizes Rust-only syntax before the JS compile; a false
+ // accept is safe, a false reject would block valid gateway patterns.
+ ctx.addIssue({ code: "custom", message: "invalid regular expression" });
+ }
+ });
export type RuleCondition = z.infer;
diff --git a/packages/api/src/validations/policy.test.ts b/packages/api/src/validations/policy.test.ts
index e04e8525..88d4e032 100644
--- a/packages/api/src/validations/policy.test.ts
+++ b/packages/api/src/validations/policy.test.ts
@@ -166,6 +166,30 @@ describe("createPolicyRuleSchema — conditions dual-use (behavioral | session p
).toBe(false);
});
+ it("accepts header conditions through the dual-use union (Tier 3a)", () => {
+ const parsed = createPolicyRuleSchema.parse({
+ ...base,
+ targets: [{ kind: "network", hostPattern: "api.example.com" }],
+ conditions: [
+ { target: "header", operator: "exists", key: "x-api-key" },
+ { target: "body", operator: "regex", value: "(?i)delete" },
+ ],
+ });
+ expect(Array.isArray(parsed.conditions)).toBe(true);
+ });
+
+ it("rejects a malformed header condition through the union", () => {
+ // Header without a key is neither a valid behavioral array element nor a
+ // session-policy object.
+ expect(
+ createPolicyRuleSchema.safeParse({
+ ...base,
+ targets: [{ kind: "network", hostPattern: "api.example.com" }],
+ conditions: [{ target: "header", operator: "equals", value: "v" }],
+ }).success,
+ ).toBe(false);
+ });
+
it("still enforces the behavioral .max(10) through the dual-use union", () => {
// The union must not let the behavioral-array bound leak — 11 conditions is
// rejected (it's not a valid session-policy object either).
From 5fa5509f62aa635f58d979307150efc29fc7264e Mon Sep 17 00:00:00 2001
From: marcorivm
Date: Wed, 29 Jul 2026 21:43:49 -0600
Subject: [PATCH 06/10] feat(gateway): re-land granular resource scoping onto
1.44.0
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Reconciliation Stage H. The gateway enforces a connection's session_policy
as a monotone tightening on the final two-level decision — GitHub repos
(from the URL path) and Dropbox folders (from the buffered JSON body or the
Dropbox-API-Arg header). Reuses Stage G's body buffer via an OR-composed
needs_scope_body (no second buffer; the content host never buffers the file
body). provider is threaded through BOTH ResolvedRules construction sites
(the http-proxy path was silently dropping it). Fail-closed throughout:
dot-segment path traversal, path-less content RPCs, unknown providers with
a scope set, and unparseable requests all deny; uncovered providers deny
while scoped. +31 tests, no agent-group, no migration.
---
apps/gateway/src/connect.rs | 7 +-
apps/gateway/src/gateway.rs | 5 +
apps/gateway/src/gateway/forward.rs | 29 +
apps/gateway/src/gateway/mitm.rs | 16 +-
apps/gateway/src/gateway/websocket.rs | 17 +
apps/gateway/src/policy_engine.rs | 6 +
apps/gateway/src/policy_engine/scope.rs | 1195 +++++++++++++++++
.../lib/granular-access/configs/dropbox.ts | 19 +
.../lib/granular-access/configs/github-app.ts | 7 +
apps/web/src/lib/granular-access/types.ts | 8 +
.../_components/scope-checklist.tsx | 56 +
.../_components/scope-text-list.tsx | 123 ++
.../src/lib/policy-editor/resource-scope.tsx | 83 +-
13 files changed, 1551 insertions(+), 20 deletions(-)
create mode 100644 apps/gateway/src/policy_engine/scope.rs
create mode 100644 apps/web/src/lib/policy-editor/_components/scope-checklist.tsx
create mode 100644 apps/web/src/lib/policy-editor/_components/scope-text-list.tsx
diff --git a/apps/gateway/src/connect.rs b/apps/gateway/src/connect.rs
index 085c0ec3..212a7f2d 100644
--- a/apps/gateway/src/connect.rs
+++ b/apps/gateway/src/connect.rs
@@ -745,8 +745,11 @@ impl PolicyEngine {
}
resolved_session_policy = session_policy;
resolved_connection_id = connection_id;
- }
- if resolved_provider.is_none() {
+ // Attribute the provider dispatched to `apply_resource_scope`
+ // to the SAME serving connection as its session_policy and
+ // connection_id — mirroring the single-connection paths.
+ // Setting it on the first-with-rules would decouple the
+ // provider from the scope actually enforced.
resolved_provider = Some(provider);
}
match (earliest_expires_at, token_expires_at) {
diff --git a/apps/gateway/src/gateway.rs b/apps/gateway/src/gateway.rs
index d4d83fd3..56ac91b7 100644
--- a/apps/gateway/src/gateway.rs
+++ b/apps/gateway/src/gateway.rs
@@ -985,6 +985,8 @@ async fn handle_http_proxy(
let mut resolved_body_transform: Option = None;
// Granular-access policy of the connection that wins injection (if any).
let mut resolved_session_policy: Option = None;
+ // Provider of that connection — dispatches the resource-scope gate.
+ let mut resolved_provider: Option = None;
// Id of the connection that wins injection — same attribution law as
// `resolved_session_policy`; unlike `connection_label` below, it MUST be
// threaded (policy decisions bind to it).
@@ -1014,6 +1016,7 @@ async fn handle_http_proxy(
finalizer,
body_transform,
session_policy,
+ provider,
connection_id: winning_connection_id,
..
}) => {
@@ -1021,6 +1024,7 @@ async fn handle_http_proxy(
resolved_finalizer = finalizer;
resolved_body_transform = body_transform;
resolved_session_policy = session_policy;
+ resolved_provider = Some(provider);
resolved_connection_id = winning_connection_id;
}
Ok(AppConnectionResult::Ambiguous { connections }) => {
@@ -1104,6 +1108,7 @@ async fn handle_http_proxy(
finalizer: resolved_finalizer,
body_transform: resolved_body_transform,
claim_token: resolved.claim_token,
+ provider: resolved_provider,
session_policy: resolved_session_policy,
winning_connection_id: resolved_connection_id,
budget_bindings: resolved.budget_bindings,
diff --git a/apps/gateway/src/gateway/forward.rs b/apps/gateway/src/gateway/forward.rs
index 06f36961..4b1fb66c 100644
--- a/apps/gateway/src/gateway/forward.rs
+++ b/apps/gateway/src/gateway/forward.rs
@@ -169,6 +169,11 @@ pub(crate) async fn forward_request(
// keeps streaming → zero overhead.
let (capture, req) =
if crate::policy_engine::needs_body_buffer(&rules.policy_rules_v2, policy_host)
+ || crate::policy_engine::needs_scope_body(
+ rules.provider.as_deref().unwrap_or(""),
+ host,
+ rules.session_policy.as_ref(),
+ )
|| hooks::needs_request_body(rules, host, method.as_str(), &path)
{
let (parts, incoming) = req.into_parts();
@@ -243,6 +248,30 @@ pub(crate) async fn forward_request(
)
.await;
+ // ── Granular resource-scope gate (Tier 3b) ────────────────────────────────
+ // Tighten the engine's decision by the winning connection's granular scope
+ // (GitHub repositories / Dropbox folders). Run unconditionally on the final
+ // decision from EITHER engine — session policy is a property of the
+ // connection, not the rule generation, so it must enforce on legacy /
+ // pre-cutover projects too. A monotone tightening: an allow-family verdict
+ // for an out-of-scope or indeterminate resource becomes `Blocked`; an
+ // existing block is untouched. A scope block is authored by no rule, so it
+ // drops the matched-rule attribution.
+ let (decision, matched_rule) = {
+ let (decision, scope_blocked) = policy_engine::apply_resource_scope(
+ decision,
+ rules.provider.as_deref().unwrap_or(""),
+ host,
+ rules.session_policy.as_ref(),
+ &path,
+ &match_input,
+ );
+ if scope_blocked {
+ warn!(method = %method, url = %url, "BLOCKED by resource scope");
+ }
+ (decision, if scope_blocked { None } else { matched_rule })
+ };
+
// ── Early return for block / rate-limit / default-deny (no body needed) ───
match &decision {
PolicyDecision::BlockedByDefaultPolicy => {
diff --git a/apps/gateway/src/gateway/mitm.rs b/apps/gateway/src/gateway/mitm.rs
index 47507143..2b6074eb 100644
--- a/apps/gateway/src/gateway/mitm.rs
+++ b/apps/gateway/src/gateway/mitm.rs
@@ -234,10 +234,15 @@ pub(crate) struct ResolvedRules {
/// Cloud-only: pending claim token when the org is in claim mode. Inert in OSS.
#[cfg_attr(not(edition_cloud), allow(dead_code))]
pub claim_token: Option,
+ /// Provider of the app connection that won injection (e.g. "github-app",
+ /// "dropbox"), or `None` when no app connection served the request (secret /
+ /// vault injection). Threaded so the request-time resource-scope gate can
+ /// dispatch `session_policy` to the right per-provider extractor.
+ pub provider: Option,
/// Per-agent resource policy (e.g. Dropbox folder allowlist) for the
- /// connection serving this host. Consumed by the cloud request guard to
- /// enforce granular access; `None` in the common, unrestricted case.
- #[cfg_attr(not(edition_cloud), allow(dead_code))]
+ /// connection serving this host. Enforced by the granular resource-scope
+ /// gate (`policy_engine::apply_resource_scope`); `None` in the common,
+ /// unrestricted case.
pub session_policy: Option,
/// Id of the app connection that won injection for this request; `None`
/// when no connection serves it (secret/vault/uncredentialed traffic, the
@@ -324,6 +329,8 @@ async fn resolve_rules(
let mut body_transform: Option = None;
// Granular-access policy of the connection that wins injection (if any).
let mut session_policy: Option = None;
+ // Provider of that connection — dispatches the resource-scope gate.
+ let mut provider: Option = None;
// Id of the connection that wins injection (if any) — rides with
// `session_policy` under the same attribution law.
let mut winning_connection_id: Option = None;
@@ -358,6 +365,7 @@ async fn resolve_rules(
finalizer: f,
body_transform: bt,
session_policy: sp,
+ provider: prov,
connection_id: cid,
..
}) => {
@@ -368,6 +376,7 @@ async fn resolve_rules(
finalizer = f;
body_transform = bt;
session_policy = sp;
+ provider = Some(prov);
winning_connection_id = cid;
}
Ok(AppConnectionResult::Ambiguous { connections }) => {
@@ -458,6 +467,7 @@ async fn resolve_rules(
finalizer,
body_transform,
claim_token: resp.claim_token,
+ provider,
session_policy,
winning_connection_id,
budget_bindings: resp.budget_bindings,
diff --git a/apps/gateway/src/gateway/websocket.rs b/apps/gateway/src/gateway/websocket.rs
index f5add1c4..e0397e1c 100644
--- a/apps/gateway/src/gateway/websocket.rs
+++ b/apps/gateway/src/gateway/websocket.rs
@@ -153,6 +153,23 @@ pub(super) async fn handle_websocket(
)
.await;
+ // Granular resource-scope gate (Tier 3b), applied for symmetry with
+ // `forward.rs` / defense-in-depth. No covered provider serves
+ // resource-addressed operations over WebSocket (GitHub is URL-only and has
+ // no repo-addressed WS surface; Dropbox has none), and a WS upgrade is a
+ // GET with no buffered body — so a Dropbox RPC scope would fail closed here
+ // rather than pass. WS blocks emit no telemetry, so the scope-block flag is
+ // not consumed.
+ let decision = crate::policy_engine::apply_resource_scope(
+ decision,
+ rules.provider.as_deref().unwrap_or(""),
+ host,
+ rules.session_policy.as_ref(),
+ &path,
+ &match_input,
+ )
+ .0;
+
match &decision {
PolicyDecision::BlockedByDefaultPolicy => {
warn!(host = %host, path = %path, "WebSocket BLOCKED by default deny policy");
diff --git a/apps/gateway/src/policy_engine.rs b/apps/gateway/src/policy_engine.rs
index 54eb2e9c..cf290977 100644
--- a/apps/gateway/src/policy_engine.rs
+++ b/apps/gateway/src/policy_engine.rs
@@ -21,6 +21,7 @@ mod enforce;
mod evaluate;
mod inject_select;
mod loaders;
+mod scope;
mod types;
// The corpus parity test lives in the PRIVATE tree (`src/ee/policy_engine/`)
@@ -34,3 +35,8 @@ mod oss_parity_test;
pub(crate) use enforce::{evaluate, load_available_apps, load_connect_v2, needs_body_buffer};
pub(crate) use inject_select::derive_inject_selection;
+// Tier 3b granular resource-scope enforcement: the request-time tightening gate
+// and its buffering predicate, applied in `gateway::forward` on the final
+// decision from either engine (independent of the v2 cutover — session policy
+// is a property of the connection, not the rule generation).
+pub(crate) use scope::{apply_resource_scope, needs_body as needs_scope_body};
diff --git a/apps/gateway/src/policy_engine/scope.rs b/apps/gateway/src/policy_engine/scope.rs
new file mode 100644
index 00000000..8b8a948d
--- /dev/null
+++ b/apps/gateway/src/policy_engine/scope.rs
@@ -0,0 +1,1195 @@
+//! Granular resource-scope enforcement (Tier 3b).
+//!
+//! A connection may carry a per-agent *granular session policy* that confines
+//! the injected credential to specific resources — a GitHub connection limited
+//! to certain repositories, a Dropbox connection limited to certain folders.
+//! The API validates and stores it (`agent_app_connections.session_policy`,
+//! the `sessionPolicySchema` union `{repositories:[…]}` | `{folders:[…]}`) and
+//! the gateway resolves it once per request into
+//! `ResolvedRules.session_policy`. This module is the enforcement the OSS build
+//! previously lacked: it parses that value and, per provider, extracts the
+//! resource a request addresses, then TIGHTENS the already-computed policy
+//! decision — an allow-family verdict for an out-of-scope or indeterminate
+//! resource becomes `Blocked`; an existing block is returned untouched. It is a
+//! monotone tightening (`final = max(engine_verdict, scope_verdict)`, Block the
+//! strictest), so it composes with the first-match / stricter-wins engine law
+//! and can never widen what the rules closed.
+//!
+//! ## Provider coverage
+//!
+//! Exactly the two providers the DB/API/UI model:
+//!
+//! - **GitHub** (`github-app`, `github`): `{repositories:["owner/repo", …]}`.
+//! The repo is read from the URL path — `/repos/{owner}/{repo}` on
+//! `api.github.com`, `/{owner}/{repo}(.git)?/…` for git-over-HTTPS and raw
+//! content on any other GitHub host. Case-insensitive (GitHub repo names are).
+//! - **Dropbox** (`dropbox`): `{folders:["/path", …]}`. The folder is read from
+//! the request JSON — the buffered body on `api.dropboxapi.com` RPC endpoints,
+//! the `Dropbox-API-Arg` header on `content.dropboxapi.com`. A request is in
+//! scope iff every path it names is equal to, or a descendant of, an allowed
+//! folder (segment-boundary prefix match; case-insensitive).
+//!
+//! Every other provider, GitHub GraphQL / numeric `/repositories/{id}`, and any
+//! resource axis other than repositories/folders are **not** covered — they hit
+//! the fail-closed indeterminate arm below. The web `granularAccessConfigs`
+//! register the same two providers, so there is no authoring surface for an
+//! axis this build cannot extract.
+//!
+//! ## Fail-closed (SECURITY)
+//!
+//! When a scope is set and the requested resource cannot be positively verified
+//! in scope, the request is DENIED. Indeterminate covers: an unparseable /
+//! numeric / GraphQL GitHub repo reference; a missing, unparseable, absent, or
+//! truncated Dropbox arg; a malformed session-policy object (unknown key / wrong
+//! value types); a scope whose shape does not match its provider; and any
+//! provider this build does not understand while a scope is present. The *only*
+//! allow paths are: no scope at all (`parse → None`, the gate is a no-op — the
+//! overwhelmingly common case), a positively verified in-scope resource, or an
+//! endpoint positively classified as not resource-addressed (GitHub
+//! account/search/meta endpoints; Dropbox RPC account endpoints).
+
+use serde_json::Value;
+
+use crate::gateway::strip_port;
+use crate::policy::{MatchInput, PolicyDecision};
+
+/// A parsed granular session policy. `Malformed` marks a scope that is present
+/// but garbled (unknown key, non-string list, both keys, extra keys); it maps
+/// to `Indeterminate` at evaluation so a garbled scope never reads as "all".
+#[derive(Debug, PartialEq, Eq)]
+enum ResourceScope {
+ Repositories(Vec),
+ Folders(Vec),
+ Malformed,
+}
+
+/// The per-request scope verdict. `Indeterminate` is the fail-closed arm: a
+/// scope is set but the resource cannot be determined.
+#[derive(Debug, PartialEq, Eq)]
+enum ScopeVerdict {
+ InScope,
+ OutOfScope,
+ Indeterminate,
+}
+
+/// Parse a stored `session_policy` value into a scope, mirroring the API
+/// `sessionPolicySchema`. Returns `None` for "no scope" — an empty/absent
+/// object, an empty `repositories`/`folders` list, `null`, or any non-object
+/// (all mean "all resources", so the gate is a no-op). Returns
+/// `Some(Malformed)` for a scope-present-but-garbled object.
+fn parse(sp: &Value) -> Option {
+ let obj = sp.as_object()?; // non-object → unscoped
+ if obj.is_empty() {
+ return None; // {} → all
+ }
+ let has_repos = obj.contains_key("repositories");
+ let has_folders = obj.contains_key("folders");
+ match (has_repos, has_folders, obj.len()) {
+ // Exactly one recognized key, nothing else — the strict union shape.
+ (true, false, 1) => Some(match string_list(&obj["repositories"]) {
+ ListShape::Values(v) => ResourceScope::Repositories(v),
+ ListShape::Empty => return None, // empty list = all
+ ListShape::Malformed => ResourceScope::Malformed,
+ }),
+ (false, true, 1) => Some(match string_list(&obj["folders"]) {
+ ListShape::Values(v) => ResourceScope::Folders(v),
+ ListShape::Empty => return None,
+ ListShape::Malformed => ResourceScope::Malformed,
+ }),
+ // Unknown key, both keys, or extra keys alongside a recognized one.
+ _ => Some(ResourceScope::Malformed),
+ }
+}
+
+enum ListShape {
+ Values(Vec),
+ Empty,
+ Malformed,
+}
+
+/// A JSON value must be an array of strings. A non-array, or any non-string
+/// element, is malformed (fail-closed); an empty array is "all".
+fn string_list(v: &Value) -> ListShape {
+ let Some(arr) = v.as_array() else {
+ return ListShape::Malformed;
+ };
+ if arr.is_empty() {
+ return ListShape::Empty;
+ }
+ let mut out = Vec::with_capacity(arr.len());
+ for el in arr {
+ match el.as_str() {
+ Some(s) => out.push(s.to_string()),
+ None => return ListShape::Malformed,
+ }
+ }
+ ListShape::Values(out)
+}
+
+/// Whether the request body must be buffered for scope extraction. True only
+/// for a `dropbox` connection with a non-empty `{folders}` policy on the RPC
+/// host `api.dropboxapi.com` (the folder rides in the JSON body). GitHub is
+/// URL-only and `content.dropboxapi.com` reads the `Dropbox-API-Arg` header, so
+/// neither buffers — critically, the content host must NOT buffer, its body is
+/// the uploaded/downloaded file, not the folder argument.
+pub(crate) fn needs_body(provider: &str, host: &str, session_policy: Option<&Value>) -> bool {
+ provider == "dropbox"
+ && strip_port(host) == "api.dropboxapi.com"
+ && matches!(
+ session_policy.and_then(parse),
+ Some(ResourceScope::Folders(_))
+ )
+}
+
+/// Tighten an already-computed policy decision by the connection's granular
+/// resource scope. An existing Block (rule or default) is returned untouched —
+/// scope never re-attributes or loosens a denial. Otherwise an out-of-scope or
+/// indeterminate resource maps the allow-family verdict (Allow / ManualApproval
+/// / RateLimited) to `Blocked { rule_name: "resource scope" }`. The returned
+/// bool is `scope_blocked`, so the caller can drop rule attribution (the block
+/// is scope-authored, not rule-authored).
+pub(crate) fn apply_resource_scope(
+ decision: PolicyDecision,
+ provider: &str,
+ host: &str,
+ session_policy: Option<&Value>,
+ path: &str,
+ input: &MatchInput<'_>,
+) -> (PolicyDecision, bool) {
+ // Already denied → never loosen, never re-attribute.
+ if matches!(
+ decision,
+ PolicyDecision::Blocked { .. } | PolicyDecision::BlockedByDefaultPolicy
+ ) {
+ return (decision, false);
+ }
+ match evaluate_scope(provider, host, session_policy, path, input) {
+ ScopeVerdict::InScope => (decision, false),
+ ScopeVerdict::OutOfScope | ScopeVerdict::Indeterminate => (
+ PolicyDecision::Blocked {
+ rule_name: "resource scope".to_string(),
+ },
+ true,
+ ),
+ }
+}
+
+/// The pure verdict: does this request address a resource the scope allows? No
+/// scope present → `InScope` (the gate is a no-op). Dispatch is by provider AND
+/// validates the scope shape matches (github ⇒ Repositories, dropbox ⇒ Folders);
+/// any mismatch, a `Malformed` scope, or an unknown provider carrying a scope →
+/// `Indeterminate` (a scope authored for an axis this build cannot extract must
+/// never pass).
+fn evaluate_scope(
+ provider: &str,
+ host: &str,
+ session_policy: Option<&Value>,
+ path: &str,
+ input: &MatchInput<'_>,
+) -> ScopeVerdict {
+ let scope = match session_policy.and_then(parse) {
+ None => return ScopeVerdict::InScope, // unscoped → no-op
+ Some(s) => s,
+ };
+ match (provider, scope) {
+ ("github-app" | "github", ResourceScope::Repositories(allowed)) => {
+ github_scope(strip_port(host), path, &allowed)
+ }
+ ("dropbox", ResourceScope::Folders(allowed)) => dropbox_scope(host, path, input, &allowed),
+ _ => ScopeVerdict::Indeterminate,
+ }
+}
+
+// ── Path traversal (SECURITY) ───────────────────────────────────────────────
+
+/// Whether a single `/`-delimited segment is a `.`/`..` dot-segment, including
+/// its percent-encoded forms (`%2e`, `%2E`, `%2e%2e`, …). Decoded lossily so a
+/// non-UTF-8 segment simply fails to match rather than panicking.
+fn is_dot_segment(seg: &str) -> bool {
+ let decoded = percent_encoding::percent_decode_str(seg).decode_utf8_lossy();
+ decoded == "." || decoded == ".."
+}
+
+/// Whether a path contains any dot-segment. The forwarding layer builds the
+/// upstream URL with the `url` crate, which collapses `.`/`..` (and their
+/// `%2e` encodings) per WHATWG *before* the request is sent, so a scope check
+/// run on the RAW request path would extract a resource from a path GitHub
+/// never sees. Any such path is therefore treated as unverifiable (fail closed)
+/// rather than parsed at face value.
+fn has_traversal(path: &str) -> bool {
+ path.split('/').any(is_dot_segment)
+}
+
+// ── GitHub ────────────────────────────────────────────────────────────────
+
+/// What repository, if any, a GitHub request path addresses.
+enum RepoRef {
+ /// `owner`, `repo` (repo case-folded at compare time).
+ Repo(String, String),
+ /// Account/search/meta endpoint — cannot name an out-of-scope repo.
+ NotRepoAddressed,
+ /// Repo-addressed but unverifiable at the URL layer (numeric id, GraphQL,
+ /// a `/repos/` prefix we cannot split) — fail closed.
+ Indeterminate,
+}
+
+fn github_scope(host: &str, path: &str, allowed: &[String]) -> ScopeVerdict {
+ match github_repo_ref(host, path) {
+ RepoRef::Repo(owner, repo) => {
+ if repo_in_scope(&owner, &repo, allowed) {
+ ScopeVerdict::InScope
+ } else {
+ ScopeVerdict::OutOfScope
+ }
+ }
+ RepoRef::NotRepoAddressed => ScopeVerdict::InScope,
+ RepoRef::Indeterminate => ScopeVerdict::Indeterminate,
+ }
+}
+
+fn github_repo_ref(host: &str, path: &str) -> RepoRef {
+ // Drop query / fragment before splitting.
+ let path = path.split(['?', '#']).next().unwrap_or(path);
+ // A dot-segment (`.`/`..`, raw or `%2e`-encoded) is collapsed by the
+ // forwarding layer's URL builder before the request reaches GitHub, so the
+ // repo we would extract here is not the repo that gets served. Fail closed.
+ if has_traversal(path) {
+ return RepoRef::Indeterminate;
+ }
+ let segs: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
+
+ if host == "api.github.com" {
+ match segs.first().copied() {
+ Some("repos") => match (segs.get(1), segs.get(2)) {
+ (Some(owner), Some(repo)) if !owner.is_empty() && !repo.is_empty() => {
+ RepoRef::Repo((*owner).to_string(), (*repo).to_string())
+ }
+ // `/repos` or `/repos/{owner}` — repo-prefixed, no repo named.
+ _ => RepoRef::Indeterminate,
+ },
+ // Numeric legacy id and GraphQL name the repo somewhere we can't
+ // confine at the URL layer.
+ Some("repositories") => RepoRef::Indeterminate,
+ Some("graphql") => RepoRef::Indeterminate,
+ // Everything else (`/user*`, `/orgs*`, `/search*`, `/rate_limit`,
+ // `/installation/repositories`, `/meta`, root, …) cannot name an
+ // out-of-scope repo (enumeration is bounded by GitHub's repo-scoped
+ // installation token server-side).
+ _ => RepoRef::NotRepoAddressed,
+ }
+ } else {
+ // git-over-HTTPS (`github.com`) and raw content
+ // (`raw.githubusercontent.com`): `/{owner}/{repo}(.git)?/…`.
+ match (segs.first(), segs.get(1)) {
+ (Some(owner), Some(repo)) if !owner.is_empty() && !repo.is_empty() => {
+ let repo = repo.strip_suffix(".git").unwrap_or(repo);
+ if repo.is_empty() {
+ RepoRef::Indeterminate
+ } else {
+ RepoRef::Repo((*owner).to_string(), repo.to_string())
+ }
+ }
+ // Fewer than two segments (root, `/settings`, …) → not a repo path.
+ _ => RepoRef::NotRepoAddressed,
+ }
+ }
+}
+
+fn repo_in_scope(owner: &str, repo: &str, allowed: &[String]) -> bool {
+ let target = format!(
+ "{}/{}",
+ owner.to_ascii_lowercase(),
+ repo.to_ascii_lowercase()
+ );
+ allowed.iter().any(|a| a.to_ascii_lowercase() == target)
+}
+
+// ── Dropbox ─────────────────────────────────────────────────────────────────
+
+/// The folder path(s) a Dropbox request addresses.
+enum PathSet {
+ /// No resource named — an account / no-arg endpoint.
+ None,
+ /// Concrete folder paths (all must be in scope).
+ Paths(Vec),
+ /// A path-shaped field is present but not a string, or a batch entry we
+ /// cannot extract a path from — fail closed.
+ Unparseable,
+}
+
+fn dropbox_scope(
+ host: &str,
+ path: &str,
+ input: &MatchInput<'_>,
+ allowed: &[String],
+) -> ScopeVerdict {
+ let host = strip_port(host);
+ let json = if host == "content.dropboxapi.com" {
+ // File-content endpoints carry the folder in the `Dropbox-API-Arg`
+ // header; the body is the file itself and is never buffered.
+ match dropbox_arg_header(input.headers) {
+ Some(v) => v,
+ None => return ScopeVerdict::Indeterminate,
+ }
+ } else if host == "api.dropboxapi.com" {
+ // RPC endpoints carry the folder in the JSON body.
+ if input.body_truncated {
+ return ScopeVerdict::Indeterminate; // over-cap → unevaluable
+ }
+ match input.body {
+ // Scoped RPC that reached here unbuffered → fail closed (should not
+ // happen: `needs_body` buffers these).
+ None => return ScopeVerdict::Indeterminate,
+ // A no-arg (empty) body names no folder; whether that is allowed is
+ // decided by the endpoint allowlist in the `PathSet::None` arm.
+ Some([]) => Value::Null,
+ Some(b) => match serde_json::from_slice::(b) {
+ Ok(v) => v,
+ Err(_) => return ScopeVerdict::Indeterminate,
+ },
+ }
+ } else {
+ // Any other Dropbox host carrying a scope: unrecognized → fail closed.
+ return ScopeVerdict::Indeterminate;
+ };
+
+ match dropbox_paths(&json) {
+ PathSet::Paths(paths) => {
+ if paths.iter().all(|p| folder_in_scope(p, allowed)) {
+ ScopeVerdict::InScope
+ } else {
+ ScopeVerdict::OutOfScope
+ }
+ }
+ // No path field found. On the content host every op addresses a
+ // resource, so a path we could not find is fail-closed. On the RPC host
+ // a path-less body is in scope ONLY for endpoints known to address no
+ // folder (account/space/check and `*/continue` cursor pagination); any
+ // other path-less scoped RPC may address a resource through a field we
+ // don't parse (e.g. `shared_folder_id`, `options.path`), so it is
+ // fail-closed — mirroring GitHub's numeric-id / GraphQL treatment.
+ PathSet::None => {
+ if host == "api.dropboxapi.com" && is_non_resource_rpc(path) {
+ ScopeVerdict::InScope
+ } else {
+ ScopeVerdict::Indeterminate
+ }
+ }
+ PathSet::Unparseable => ScopeVerdict::Indeterminate,
+ }
+}
+
+/// Dropbox RPC endpoints that address no folder resource, so a path-less body
+/// on them is in scope even while a folder scope is set: the account / space /
+/// check endpoints, and `*/continue` cursor-pagination calls (the opaque cursor
+/// — obtained from an already-scope-checked listing — identifies the page, not
+/// a path). Every other RPC endpoint is treated as potentially
+/// resource-addressed and fails closed on a path-less body.
+fn is_non_resource_rpc(path: &str) -> bool {
+ let path = path.split(['?', '#']).next().unwrap_or(path);
+ let path = path.trim_end_matches('/');
+ matches!(
+ path,
+ "/2/users/get_current_account"
+ | "/2/users/get_space_usage"
+ | "/2/check/user"
+ | "/2/check/app"
+ ) || path.ends_with("/continue")
+}
+
+fn dropbox_arg_header(headers: Option<&hyper::HeaderMap>) -> Option {
+ let s = headers?.get("dropbox-api-arg")?.to_str().ok()?;
+ serde_json::from_str(s).ok()
+}
+
+/// Extract every folder path a Dropbox arg object names — `path`, `from_path`,
+/// `to_path` (move/copy check BOTH), and each `entries[]` element (batch). A
+/// non-object arg (e.g. `null` for get_current_account) names no path.
+fn dropbox_paths(json: &Value) -> PathSet {
+ let Some(obj) = json.as_object() else {
+ return PathSet::None;
+ };
+ let mut paths = Vec::new();
+ for key in ["path", "from_path", "to_path"] {
+ if let Some(v) = obj.get(key) {
+ match v.as_str() {
+ Some(s) => paths.push(s.to_string()),
+ None => return PathSet::Unparseable,
+ }
+ }
+ }
+ if let Some(entries) = obj.get("entries") {
+ let Some(arr) = entries.as_array() else {
+ return PathSet::Unparseable;
+ };
+ for entry in arr {
+ let Some(eo) = entry.as_object() else {
+ return PathSet::Unparseable;
+ };
+ let mut found = false;
+ for key in ["path", "from_path", "to_path"] {
+ if let Some(v) = eo.get(key) {
+ match v.as_str() {
+ Some(s) => {
+ paths.push(s.to_string());
+ found = true;
+ }
+ None => return PathSet::Unparseable,
+ }
+ }
+ }
+ if !found {
+ // A batch entry we cannot extract a path from — fail closed.
+ return PathSet::Unparseable;
+ }
+ }
+ }
+ if paths.is_empty() {
+ PathSet::None
+ } else if paths.iter().any(|p| has_traversal(p)) {
+ // A Dropbox path carrying a `.`/`..` segment cannot be confined by the
+ // segment-prefix match (`/proj/../evil` prefix-matches `/proj` yet may
+ // resolve elsewhere), so fail closed.
+ PathSet::Unparseable
+ } else {
+ PathSet::Paths(paths)
+ }
+}
+
+/// A request folder is in scope iff it equals, or is a descendant of, some
+/// allowed folder. Dropbox paths are case-insensitive and `/`-delimited; the
+/// prefix match is on whole segments (`/foo` allows `/foo` and `/foo/bar` but
+/// not `/foobar`). An allowed entry that normalizes to the root ("") allows
+/// everything.
+fn folder_in_scope(req: &str, allowed: &[String]) -> bool {
+ let req = norm_folder(req);
+ allowed.iter().any(|a| {
+ let a = norm_folder(a);
+ a.is_empty() || req == a || req.starts_with(&format!("{a}/"))
+ })
+}
+
+fn norm_folder(p: &str) -> String {
+ p.trim_end_matches('/').to_ascii_lowercase()
+}
+
+// ── Tests ────────────────────────────────────────────────────────────────
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use serde_json::json;
+
+ fn headers(pairs: &[(&str, &str)]) -> hyper::HeaderMap {
+ let mut map = hyper::HeaderMap::new();
+ for (name, value) in pairs {
+ map.append(
+ hyper::header::HeaderName::from_bytes(name.as_bytes()).expect("header name"),
+ hyper::header::HeaderValue::from_str(value).expect("header value"),
+ );
+ }
+ map
+ }
+
+ /// A `MatchInput` carrying only a buffered body.
+ fn body_input(body: &[u8]) -> MatchInput<'_> {
+ MatchInput {
+ body: Some(body),
+ body_truncated: false,
+ headers: None,
+ }
+ }
+
+ // ── parse ────────────────────────────────────────────────────────────
+
+ #[test]
+ fn parse_recognizes_the_two_shapes() {
+ assert_eq!(
+ parse(&json!({"repositories": ["a/b"]})),
+ Some(ResourceScope::Repositories(vec!["a/b".to_string()]))
+ );
+ assert_eq!(
+ parse(&json!({"folders": ["/x"]})),
+ Some(ResourceScope::Folders(vec!["/x".to_string()]))
+ );
+ }
+
+ #[test]
+ fn parse_treats_empty_and_absent_as_unscoped() {
+ assert_eq!(parse(&json!({})), None);
+ assert_eq!(parse(&json!({"repositories": []})), None);
+ assert_eq!(parse(&json!({"folders": []})), None);
+ assert_eq!(parse(&Value::Null), None);
+ assert_eq!(parse(&json!(["a/b"])), None); // top-level array
+ assert_eq!(parse(&json!("str")), None); // non-object
+ }
+
+ #[test]
+ fn parse_flags_garbled_objects_as_malformed() {
+ assert_eq!(
+ parse(&json!({"unknownKey": ["x"]})),
+ Some(ResourceScope::Malformed)
+ );
+ // Extra key alongside a recognized one.
+ assert_eq!(
+ parse(&json!({"repositories": ["a/b"], "folders": ["/x"]})),
+ Some(ResourceScope::Malformed)
+ );
+ // Non-string list element.
+ assert_eq!(
+ parse(&json!({"repositories": [1, 2]})),
+ Some(ResourceScope::Malformed)
+ );
+ // Value is not a list.
+ assert_eq!(
+ parse(&json!({"folders": "/x"})),
+ Some(ResourceScope::Malformed)
+ );
+ }
+
+ // ── GitHub extraction ────────────────────────────────────────────────
+
+ fn gh(host: &str, path: &str, allowed: &[&str]) -> ScopeVerdict {
+ let scope = json!({ "repositories": allowed });
+ evaluate_scope("github-app", host, Some(&scope), path, &MatchInput::empty())
+ }
+
+ #[test]
+ fn github_in_and_out_of_scope_by_repo() {
+ assert_eq!(
+ gh("api.github.com", "/repos/acme/app/pulls", &["acme/app"]),
+ ScopeVerdict::InScope
+ );
+ assert_eq!(
+ gh("api.github.com", "/repos/acme/app/pulls", &["acme/other"]),
+ ScopeVerdict::OutOfScope
+ );
+ }
+
+ #[test]
+ fn github_is_case_insensitive() {
+ assert_eq!(
+ gh("api.github.com", "/repos/ACME/App", &["acme/app"]),
+ ScopeVerdict::InScope
+ );
+ }
+
+ #[test]
+ fn github_git_over_https_path() {
+ assert_eq!(
+ gh("github.com", "/acme/app.git/info/refs", &["acme/app"]),
+ ScopeVerdict::InScope
+ );
+ assert_eq!(
+ gh("github.com", "/acme/app.git/info/refs", &["acme/other"]),
+ ScopeVerdict::OutOfScope
+ );
+ }
+
+ #[test]
+ fn github_raw_content_host_is_repo_addressed() {
+ assert_eq!(
+ gh(
+ "raw.githubusercontent.com",
+ "/acme/app/main/README.md",
+ &["acme/app"]
+ ),
+ ScopeVerdict::InScope
+ );
+ assert_eq!(
+ gh(
+ "raw.githubusercontent.com",
+ "/acme/secret/main/x",
+ &["acme/app"]
+ ),
+ ScopeVerdict::OutOfScope
+ );
+ }
+
+ #[test]
+ fn github_account_endpoints_are_in_scope() {
+ for path in [
+ "/user/repos",
+ "/orgs/acme/repos",
+ "/rate_limit",
+ "/",
+ "/meta",
+ ] {
+ assert_eq!(
+ gh("api.github.com", path, &["acme/app"]),
+ ScopeVerdict::InScope,
+ "account endpoint {path} must not be repo-scoped"
+ );
+ }
+ }
+
+ #[test]
+ fn github_unverifiable_repo_references_are_indeterminate() {
+ assert_eq!(
+ gh("api.github.com", "/repositories/12345", &["acme/app"]),
+ ScopeVerdict::Indeterminate
+ );
+ assert_eq!(
+ gh("api.github.com", "/graphql", &["acme/app"]),
+ ScopeVerdict::Indeterminate
+ );
+ // `/repos/` prefix with no repo named.
+ assert_eq!(
+ gh("api.github.com", "/repos/acme", &["acme/app"]),
+ ScopeVerdict::Indeterminate
+ );
+ }
+
+ #[test]
+ fn github_dot_segment_traversal_fails_closed() {
+ // The forwarding layer's URL builder collapses `..` before the request
+ // reaches GitHub, so a raw path that prefixes an in-scope repo but
+ // traverses out of it must NOT read as in scope. Both API and git hosts.
+ assert_eq!(
+ gh(
+ "api.github.com",
+ "/repos/acme/app/../../evil/target/contents/secret",
+ &["acme/app"]
+ ),
+ ScopeVerdict::Indeterminate
+ );
+ // Percent-encoded dot-segments are collapsed identically.
+ assert_eq!(
+ gh(
+ "api.github.com",
+ "/repos/acme/app/%2e%2e/%2e%2e/evil/repo",
+ &["acme/app"]
+ ),
+ ScopeVerdict::Indeterminate
+ );
+ assert_eq!(
+ gh(
+ "github.com",
+ "/acme/app/../../evil/repo.git/info/refs",
+ &["acme/app"]
+ ),
+ ScopeVerdict::Indeterminate
+ );
+ }
+
+ #[test]
+ fn dropbox_path_with_dot_segment_fails_closed() {
+ // `/proj/../evil` prefix-matches `/proj` but resolves elsewhere.
+ assert_eq!(
+ dbx_rpc(br#"{"path":"/proj/../evil"}"#, &["/proj"]),
+ ScopeVerdict::Indeterminate
+ );
+ }
+
+ // ── Dropbox extraction ───────────────────────────────────────────────
+
+ fn dbx_rpc(body: &[u8], allowed: &[&str]) -> ScopeVerdict {
+ let scope = json!({ "folders": allowed });
+ evaluate_scope(
+ "dropbox",
+ "api.dropboxapi.com",
+ Some(&scope),
+ "/2/files/list_folder",
+ &body_input(body),
+ )
+ }
+
+ #[test]
+ fn dropbox_rpc_in_and_out_of_scope() {
+ assert_eq!(
+ dbx_rpc(br#"{"path":"/proj/sub"}"#, &["/proj"]),
+ ScopeVerdict::InScope
+ );
+ assert_eq!(
+ dbx_rpc(br#"{"path":"/proj/sub"}"#, &["/other"]),
+ ScopeVerdict::OutOfScope
+ );
+ // Segment boundary: /projX is not under /proj.
+ assert_eq!(
+ dbx_rpc(br#"{"path":"/projX"}"#, &["/proj"]),
+ ScopeVerdict::OutOfScope
+ );
+ }
+
+ #[test]
+ fn dropbox_move_checks_all_path_fields() {
+ assert_eq!(
+ dbx_rpc(
+ br#"{"from_path":"/proj/a","to_path":"/other/b"}"#,
+ &["/proj"]
+ ),
+ ScopeVerdict::OutOfScope
+ );
+ assert_eq!(
+ dbx_rpc(
+ br#"{"from_path":"/proj/a","to_path":"/proj/b"}"#,
+ &["/proj"]
+ ),
+ ScopeVerdict::InScope
+ );
+ }
+
+ #[test]
+ fn dropbox_batch_entries_are_checked() {
+ assert_eq!(
+ dbx_rpc(
+ br#"{"entries":[{"from_path":"/proj/a","to_path":"/proj/b"}]}"#,
+ &["/proj"]
+ ),
+ ScopeVerdict::InScope
+ );
+ assert_eq!(
+ dbx_rpc(
+ br#"{"entries":[{"from_path":"/proj/a","to_path":"/evil/b"}]}"#,
+ &["/proj"]
+ ),
+ ScopeVerdict::OutOfScope
+ );
+ // An entry with no extractable path is fail-closed.
+ assert_eq!(
+ dbx_rpc(br#"{"entries":[{"cursor":"x"}]}"#, &["/proj"]),
+ ScopeVerdict::Indeterminate
+ );
+ }
+
+ #[test]
+ fn dropbox_content_host_reads_the_header() {
+ let scope = json!({ "folders": ["/proj"] });
+ let input = MatchInput {
+ body: None,
+ body_truncated: false,
+ headers: Some(&headers(&[(
+ "dropbox-api-arg",
+ r#"{"path":"/proj/f.txt"}"#,
+ )])),
+ };
+ assert_eq!(
+ evaluate_scope(
+ "dropbox",
+ "content.dropboxapi.com",
+ Some(&scope),
+ "/2/files/download",
+ &input
+ ),
+ ScopeVerdict::InScope
+ );
+ }
+
+ #[test]
+ fn dropbox_content_host_out_of_scope_and_missing_header() {
+ let scope = json!({ "folders": ["/proj"] });
+ // Out of scope.
+ let hit = MatchInput {
+ body: None,
+ body_truncated: false,
+ headers: Some(&headers(&[(
+ "dropbox-api-arg",
+ r#"{"path":"/evil/f.txt"}"#,
+ )])),
+ };
+ assert_eq!(
+ evaluate_scope(
+ "dropbox",
+ "content.dropboxapi.com",
+ Some(&scope),
+ "/2/files/download",
+ &hit
+ ),
+ ScopeVerdict::OutOfScope
+ );
+ // No header at all while scoped → fail closed.
+ let miss = MatchInput {
+ body: None,
+ body_truncated: false,
+ headers: Some(&headers(&[])),
+ };
+ assert_eq!(
+ evaluate_scope(
+ "dropbox",
+ "content.dropboxapi.com",
+ Some(&scope),
+ "/2/files/download",
+ &miss
+ ),
+ ScopeVerdict::Indeterminate
+ );
+ // A content op whose arg names no path is fail-closed (every content op
+ // addresses a resource).
+ let no_path = MatchInput {
+ body: None,
+ body_truncated: false,
+ headers: Some(&headers(&[("dropbox-api-arg", r#"{"query":"x"}"#)])),
+ };
+ assert_eq!(
+ evaluate_scope(
+ "dropbox",
+ "content.dropboxapi.com",
+ Some(&scope),
+ "/2/files/download",
+ &no_path
+ ),
+ ScopeVerdict::Indeterminate
+ );
+ }
+
+ #[test]
+ fn dropbox_truncated_and_unparseable_body_fail_closed() {
+ let scope = json!({ "folders": ["/proj"] });
+ let truncated = MatchInput {
+ body: None,
+ body_truncated: true,
+ headers: None,
+ };
+ assert_eq!(
+ evaluate_scope(
+ "dropbox",
+ "api.dropboxapi.com",
+ Some(&scope),
+ "/2/files/list_folder",
+ &truncated
+ ),
+ ScopeVerdict::Indeterminate
+ );
+ assert_eq!(
+ dbx_rpc(b"not json", &["/proj"]),
+ ScopeVerdict::Indeterminate
+ );
+ // Absent (unbuffered) body while scoped → fail closed.
+ let absent = MatchInput::empty();
+ assert_eq!(
+ evaluate_scope(
+ "dropbox",
+ "api.dropboxapi.com",
+ Some(&scope),
+ "/2/files/list_folder",
+ &absent
+ ),
+ ScopeVerdict::Indeterminate
+ );
+ }
+
+ /// Evaluate a Dropbox RPC body against `/proj` at an arbitrary endpoint path.
+ fn dbx_rpc_at(path: &str, body: &[u8]) -> ScopeVerdict {
+ let scope = json!({ "folders": ["/proj"] });
+ evaluate_scope(
+ "dropbox",
+ "api.dropboxapi.com",
+ Some(&scope),
+ path,
+ &body_input(body),
+ )
+ }
+
+ #[test]
+ fn dropbox_account_endpoint_is_in_scope() {
+ // `/2/users/get_current_account` sends a `null` body — no folder.
+ assert_eq!(
+ dbx_rpc_at("/2/users/get_current_account", b"null"),
+ ScopeVerdict::InScope
+ );
+ // A no-arg (empty) body on an account endpoint is likewise in scope.
+ assert_eq!(
+ dbx_rpc_at("/2/users/get_current_account", b""),
+ ScopeVerdict::InScope
+ );
+ assert_eq!(
+ dbx_rpc_at("/2/users/get_space_usage", b"null"),
+ ScopeVerdict::InScope
+ );
+ // Cursor-pagination `*/continue` inherits the original listing's scope.
+ assert_eq!(
+ dbx_rpc_at("/2/files/list_folder/continue", br#"{"cursor":"x"}"#),
+ ScopeVerdict::InScope
+ );
+ }
+
+ #[test]
+ fn dropbox_path_less_body_on_a_resource_rpc_fails_closed() {
+ // A path-less body (or an empty/null body) on any endpoint NOT on the
+ // non-resource allowlist may address a resource through a field we do
+ // not parse, so it is fail-closed rather than allowed.
+ assert_eq!(
+ dbx_rpc_at("/2/files/list_folder", b"null"),
+ ScopeVerdict::Indeterminate
+ );
+ assert_eq!(
+ dbx_rpc_at("/2/files/list_folder", b""),
+ ScopeVerdict::Indeterminate
+ );
+ // Addressed by shared_folder_id — unconfinable at this layer → deny.
+ assert_eq!(
+ dbx_rpc_at(
+ "/2/sharing/list_folder_members",
+ br#"{"shared_folder_id":"123"}"#
+ ),
+ ScopeVerdict::Indeterminate
+ );
+ // A nested `options.path` we don't parse must not slip through.
+ assert_eq!(
+ dbx_rpc_at(
+ "/2/files/search_v2",
+ br#"{"query":"x","options":{"path":"/secret"}}"#
+ ),
+ ScopeVerdict::Indeterminate
+ );
+ }
+
+ #[test]
+ fn dropbox_root_path_escapes_a_folder_scope() {
+ // list_folder on the whole Dropbox ("") is broader than any folder.
+ assert_eq!(
+ dbx_rpc(br#"{"path":""}"#, &["/proj"]),
+ ScopeVerdict::OutOfScope
+ );
+ }
+
+ // ── No scope + unknown provider / shape mismatch ─────────────────────
+
+ #[test]
+ fn no_scope_is_always_in_scope() {
+ for provider in ["github-app", "dropbox", "slack"] {
+ assert_eq!(
+ evaluate_scope(
+ provider,
+ "api.example.com",
+ None,
+ "/anything",
+ &MatchInput::empty()
+ ),
+ ScopeVerdict::InScope
+ );
+ // An empty object is "all" → still a no-op.
+ assert_eq!(
+ evaluate_scope(
+ provider,
+ "api.example.com",
+ Some(&json!({})),
+ "/anything",
+ &MatchInput::empty()
+ ),
+ ScopeVerdict::InScope
+ );
+ }
+ }
+
+ #[test]
+ fn unknown_provider_with_a_scope_is_indeterminate() {
+ let scope = json!({ "repositories": ["acme/app"] });
+ assert_eq!(
+ evaluate_scope(
+ "slack",
+ "slack.com",
+ Some(&scope),
+ "/api/x",
+ &MatchInput::empty()
+ ),
+ ScopeVerdict::Indeterminate
+ );
+ }
+
+ #[test]
+ fn shape_mismatch_is_indeterminate() {
+ // GitHub provider carrying a folders scope, or vice versa.
+ let folders = json!({ "folders": ["/x"] });
+ assert_eq!(
+ evaluate_scope(
+ "github-app",
+ "api.github.com",
+ Some(&folders),
+ "/repos/a/b",
+ &MatchInput::empty()
+ ),
+ ScopeVerdict::Indeterminate
+ );
+ let repos = json!({ "repositories": ["a/b"] });
+ assert_eq!(
+ evaluate_scope(
+ "dropbox",
+ "api.dropboxapi.com",
+ Some(&repos),
+ "/2/files/list_folder",
+ &body_input(b"{}")
+ ),
+ ScopeVerdict::Indeterminate
+ );
+ }
+
+ #[test]
+ fn malformed_scope_is_indeterminate() {
+ let malformed = json!({ "unknownKey": ["x"] });
+ assert_eq!(
+ evaluate_scope(
+ "github-app",
+ "api.github.com",
+ Some(&malformed),
+ "/repos/a/b",
+ &MatchInput::empty()
+ ),
+ ScopeVerdict::Indeterminate
+ );
+ }
+
+ // ── needs_body ───────────────────────────────────────────────────────
+
+ #[test]
+ fn needs_body_only_for_dropbox_rpc_folders() {
+ let folders = json!({ "folders": ["/x"] });
+ let repos = json!({ "repositories": ["a/b"] });
+ assert!(needs_body("dropbox", "api.dropboxapi.com", Some(&folders)));
+ assert!(needs_body(
+ "dropbox",
+ "api.dropboxapi.com:443",
+ Some(&folders)
+ ));
+ // Content host reads the header, never the body.
+ assert!(!needs_body(
+ "dropbox",
+ "content.dropboxapi.com",
+ Some(&folders)
+ ));
+ // GitHub is URL-only.
+ assert!(!needs_body("github-app", "api.github.com", Some(&repos)));
+ // No scope → no buffering.
+ assert!(!needs_body("dropbox", "api.dropboxapi.com", None));
+ assert!(!needs_body(
+ "dropbox",
+ "api.dropboxapi.com",
+ Some(&json!({}))
+ ));
+ }
+
+ // ── apply_resource_scope (the tightening gate) ───────────────────────
+
+ fn out_of_scope_repo() -> Value {
+ json!({ "repositories": ["acme/app"] })
+ }
+
+ #[test]
+ fn gate_blocks_an_out_of_scope_allow() {
+ let scope = out_of_scope_repo();
+ let (decision, blocked) = apply_resource_scope(
+ PolicyDecision::Allow,
+ "github-app",
+ "api.github.com",
+ Some(&scope),
+ "/repos/acme/secret/pulls",
+ &MatchInput::empty(),
+ );
+ assert!(matches!(decision, PolicyDecision::Blocked { .. }));
+ assert!(blocked);
+ }
+
+ #[test]
+ fn gate_leaves_an_in_scope_allow_untouched() {
+ let scope = out_of_scope_repo();
+ let (decision, blocked) = apply_resource_scope(
+ PolicyDecision::Allow,
+ "github-app",
+ "api.github.com",
+ Some(&scope),
+ "/repos/acme/app/pulls",
+ &MatchInput::empty(),
+ );
+ assert!(matches!(decision, PolicyDecision::Allow));
+ assert!(!blocked);
+ }
+
+ #[test]
+ fn gate_returns_an_existing_block_untouched() {
+ // Already denied: never re-attributed, never a scope block.
+ let (decision, blocked) = apply_resource_scope(
+ PolicyDecision::Blocked {
+ rule_name: "some rule".to_string(),
+ },
+ "github-app",
+ "api.github.com",
+ Some(&out_of_scope_repo()),
+ "/repos/acme/secret/pulls",
+ &MatchInput::empty(),
+ );
+ match decision {
+ PolicyDecision::Blocked { rule_name } => assert_eq!(rule_name, "some rule"),
+ other => panic!("expected the original block, got {other:?}"),
+ }
+ assert!(!blocked);
+ // Default-policy blocks are equally untouched.
+ let (decision, blocked) = apply_resource_scope(
+ PolicyDecision::BlockedByDefaultPolicy,
+ "github-app",
+ "api.github.com",
+ Some(&out_of_scope_repo()),
+ "/repos/acme/secret/pulls",
+ &MatchInput::empty(),
+ );
+ assert!(matches!(decision, PolicyDecision::BlockedByDefaultPolicy));
+ assert!(!blocked);
+ }
+
+ #[test]
+ fn gate_tightens_manual_approval_and_rate_limit_out_of_scope() {
+ // The tightening beats an approval / rate-limit modifier (stricter-wins).
+ let scope = out_of_scope_repo();
+ let (decision, blocked) = apply_resource_scope(
+ PolicyDecision::ManualApproval {
+ rule_id: "r".to_string(),
+ },
+ "github-app",
+ "api.github.com",
+ Some(&scope),
+ "/repos/acme/secret/pulls",
+ &MatchInput::empty(),
+ );
+ assert!(matches!(decision, PolicyDecision::Blocked { .. }));
+ assert!(blocked);
+
+ let (decision, blocked) = apply_resource_scope(
+ PolicyDecision::RateLimited {
+ rule_name: "r".to_string(),
+ limit: 1,
+ window: "minute",
+ retry_after_secs: 1,
+ },
+ "github-app",
+ "api.github.com",
+ Some(&scope),
+ "/repos/acme/secret/pulls",
+ &MatchInput::empty(),
+ );
+ assert!(matches!(decision, PolicyDecision::Blocked { .. }));
+ assert!(blocked);
+ }
+
+ #[test]
+ fn gate_is_a_noop_when_no_scope_is_set() {
+ // The common case: an approval verdict with no scope passes through
+ // unchanged so the approval flow still runs.
+ let (decision, blocked) = apply_resource_scope(
+ PolicyDecision::ManualApproval {
+ rule_id: "r".to_string(),
+ },
+ "github-app",
+ "api.github.com",
+ None,
+ "/repos/acme/secret/pulls",
+ &MatchInput::empty(),
+ );
+ assert!(matches!(decision, PolicyDecision::ManualApproval { .. }));
+ assert!(!blocked);
+ }
+
+ #[test]
+ fn gate_indeterminate_out_of_scope_provider_blocks_an_allow() {
+ // A scope for a provider this build cannot extract must never pass.
+ let scope = out_of_scope_repo();
+ let (decision, blocked) = apply_resource_scope(
+ PolicyDecision::Allow,
+ "slack",
+ "slack.com",
+ Some(&scope),
+ "/api/chat.postMessage",
+ &MatchInput::empty(),
+ );
+ assert!(matches!(decision, PolicyDecision::Blocked { .. }));
+ assert!(blocked);
+ }
+}
diff --git a/apps/web/src/lib/granular-access/configs/dropbox.ts b/apps/web/src/lib/granular-access/configs/dropbox.ts
index 1e8d59fd..95871a63 100644
--- a/apps/web/src/lib/granular-access/configs/dropbox.ts
+++ b/apps/web/src/lib/granular-access/configs/dropbox.ts
@@ -11,6 +11,25 @@ export const dropboxConfig: GranularAccessConfig = {
buildPolicy: (folders) => (folders.length > 0 ? { folders } : {}),
getSelectedItems: (policy) => (policy.folders as string[]) ?? [],
itemLabel: { singular: "folder", plural: "folders" },
+ // The gateway's `folder_in_scope` requires a leading-slash, `/`-delimited
+ // Dropbox path. A bare name never matches (fail-closed brick); the root `/`
+ // normalizes to "" and silently allows everything (a no-op restriction).
+ validateEntry: (value) => {
+ if (!value.startsWith("/")) {
+ return "Folder must be an absolute path starting with /";
+ }
+ if (value.replace(/\/+$/, "") === "") {
+ return "Use no restriction instead of / (the root allows everything)";
+ }
+ // Dot-segments never collapse on the allow side (they're literal
+ // segment-prefixes), while request paths carrying `.`/`..` are rejected as
+ // Unparseable — so such an entry silently matches nothing (a fail-closed
+ // brick). Reject it up front.
+ if (value.split("/").some((s) => s === "." || s === "..")) {
+ return "Folder path can't contain . or .. segments";
+ }
+ return null;
+ },
Icon: Folder,
formatSummary: (policy) => {
const folders = (policy?.folders as string[] | undefined) ?? [];
diff --git a/apps/web/src/lib/granular-access/configs/github-app.ts b/apps/web/src/lib/granular-access/configs/github-app.ts
index 07f114cf..044f59c5 100644
--- a/apps/web/src/lib/granular-access/configs/github-app.ts
+++ b/apps/web/src/lib/granular-access/configs/github-app.ts
@@ -19,5 +19,12 @@ export const githubAppConfig: GranularAccessConfig = {
buildPolicy: (repos) => (repos.length > 0 ? { repositories: repos } : {}),
getSelectedItems: (policy) => (policy.repositories as string[]) ?? [],
itemLabel: { singular: "repository", plural: "repositories" },
+ // Free-text authoring path (installations that grant all repos without an
+ // enumerated list). The gateway's `repo_in_scope` matches exactly
+ // `owner/repo`, so a bare repo name can never match (fail-closed brick).
+ validateEntry: (value) =>
+ /^[^/\s]+\/[^/\s]+$/.test(value)
+ ? null
+ : "Use owner/repo format (e.g. acme/app)",
Icon: GitBranch,
};
diff --git a/apps/web/src/lib/granular-access/types.ts b/apps/web/src/lib/granular-access/types.ts
index 41abeb21..cb20037e 100644
--- a/apps/web/src/lib/granular-access/types.ts
+++ b/apps/web/src/lib/granular-access/types.ts
@@ -23,6 +23,14 @@ export interface GranularAccessConfig {
buildPolicy: (selectedItemIds: string[]) => Record;
getSelectedItems: (policy: Record) => string[];
itemLabel: { singular: string; plural: string };
+ /** Optional validator for free-text entries authored via the text-list path
+ * (providers whose resources aren't enumerated at connect time). Returns an
+ * error message for an entry the gateway parser could not enforce, or `null`
+ * when the entry is acceptable. Without it, an entry the parser can't match
+ * silently fail-closes (bricking the resource) or — worse — no-ops (a
+ * restriction the user believes is enforced), so covered providers must
+ * supply one. */
+ validateEntry?: (value: string) => string | null;
Icon: ComponentType<{ className?: string }>;
PolicyDialogContent?: ComponentType;
/** Optional override for the one-line access summary shown on the row.
diff --git a/apps/web/src/lib/policy-editor/_components/scope-checklist.tsx b/apps/web/src/lib/policy-editor/_components/scope-checklist.tsx
new file mode 100644
index 00000000..ce76d3b1
--- /dev/null
+++ b/apps/web/src/lib/policy-editor/_components/scope-checklist.tsx
@@ -0,0 +1,56 @@
+"use client";
+
+import { Checkbox } from "@onecli/ui/components/checkbox";
+import type { GranularAccessItem } from "@/lib/granular-access";
+
+export interface ScopeChecklistProps {
+ items: GranularAccessItem[];
+ selectedIds: string[];
+ itemLabel: { singular: string; plural: string };
+ onChange: (ids: string[]) => void;
+}
+
+/** Enumerable resource picker (e.g. GitHub repositories from connect-time
+ * metadata): a multi-select checklist. An empty selection means "all"
+ * (unrestricted) — the same as no policy. */
+export const ScopeChecklist = ({
+ items,
+ selectedIds,
+ itemLabel,
+ onChange,
+}: ScopeChecklistProps): React.JSX.Element => {
+ const selected = new Set(selectedIds);
+ const toggle = (id: string) => {
+ const next = new Set(selected);
+ if (next.has(id)) {
+ next.delete(id);
+ } else {
+ next.add(id);
+ }
+ onChange([...next]);
+ };
+
+ return (
+
+
+ {selected.size === 0
+ ? `All ${itemLabel.plural} (no restriction)`
+ : `Limited to ${selected.size} of ${Math.max(items.length, selected.size)} ${itemLabel.plural}`}
+
+
+ {items.map((item) => (
+
+ toggle(item.id)}
+ />
+ {item.label}
+
+ ))}
+
+
+ );
+};
diff --git a/apps/web/src/lib/policy-editor/_components/scope-text-list.tsx b/apps/web/src/lib/policy-editor/_components/scope-text-list.tsx
new file mode 100644
index 00000000..de91046b
--- /dev/null
+++ b/apps/web/src/lib/policy-editor/_components/scope-text-list.tsx
@@ -0,0 +1,123 @@
+"use client";
+
+import { useState } from "react";
+import { Plus, X } from "lucide-react";
+import { Button } from "@onecli/ui/components/button";
+import { Input } from "@onecli/ui/components/input";
+
+export interface ScopeTextListProps {
+ values: string[];
+ itemLabel: { singular: string; plural: string };
+ placeholder?: string;
+ /** Provider-specific format validator: returns an error message for an entry
+ * the gateway parser can't enforce, or `null`/`undefined` when acceptable.
+ * Invalid entries are refused with an inline error rather than added — an
+ * unenforceable entry would silently brick or no-op the restriction. */
+ validate?: (value: string) => string | null;
+ onChange: (values: string[]) => void;
+}
+
+/** Free-text resource list for providers that can't be enumerated at
+ * connect time (e.g. Dropbox folders): one path per row, add/remove. An empty
+ * list means "all" (unrestricted). */
+export const ScopeTextList = ({
+ values,
+ itemLabel,
+ placeholder,
+ validate,
+ onChange,
+}: ScopeTextListProps): React.JSX.Element => {
+ const [draft, setDraft] = useState("");
+ const [error, setError] = useState(null);
+
+ const add = () => {
+ const value = draft.trim();
+ if (!value) {
+ setDraft("");
+ setError(null);
+ return;
+ }
+ if (values.includes(value)) {
+ setDraft("");
+ setError(null);
+ return;
+ }
+ const validationError = validate?.(value) ?? null;
+ if (validationError) {
+ setError(validationError);
+ return;
+ }
+ onChange([...values, value]);
+ setDraft("");
+ setError(null);
+ };
+
+ const remove = (value: string) =>
+ onChange(values.filter((entry) => entry !== value));
+
+ return (
+
+ {values.length > 0 ? (
+
+ {values.map((value) => (
+
+ {value}
+ remove(value)}
+ >
+
+
+
+ ))}
+
+ ) : (
+
+ All {itemLabel.plural} (no restriction)
+
+ )}
+
+
{
+ setDraft(event.target.value);
+ if (error) {
+ setError(null);
+ }
+ }}
+ onKeyDown={(event) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ add();
+ }
+ }}
+ />
+
+
+ Add
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ );
+};
diff --git a/apps/web/src/lib/policy-editor/resource-scope.tsx b/apps/web/src/lib/policy-editor/resource-scope.tsx
index d905e195..432cec57 100644
--- a/apps/web/src/lib/policy-editor/resource-scope.tsx
+++ b/apps/web/src/lib/policy-editor/resource-scope.tsx
@@ -1,15 +1,23 @@
"use client";
import type { Connection } from "@/lib/api";
+import { granularAccessConfigs } from "@/lib/granular-access";
+import { ScopeChecklist } from "./_components/scope-checklist";
+import { ScopeTextList } from "./_components/scope-text-list";
/**
- * The OSS resource-scope seam (step 9.5): granular per-resource scoping
- * (GitHub repositories / Dropbox folders on a connection's injected
- * credential) is not implemented in this build — the gateway has no guard to
- * enforce it (Tier 3). Rendered only where the real editor would appear (a
- * single specific connection on an Allow), as a locked capability hint. The
- * EE editions alias this file to `@/ee/policy-editor/resource-scope` (the
- * real fields).
+ * The OSS resource-scope editor: granular per-resource scoping (GitHub
+ * repositories / Dropbox folders) on a connection's injected credential. Driven
+ * by the shared `granularAccessConfigs` — a checklist for providers that
+ * enumerate their resources at connect time (GitHub repos), a free-text path
+ * list for those that can't (Dropbox folders). The gateway enforces the emitted
+ * `{repositories}` / `{folders}` policy (`policy_engine::scope`).
+ *
+ * Rendered only where scoping is meaningful (a single specific connection on an
+ * Allow without behavioral conditions — see the consumer in
+ * `_components/app-target-fields.tsx`). Providers with no granular config, or a
+ * connection that can't be scoped, render nothing. The EE editions alias this
+ * file to `@/ee/policy-editor/resource-scope`.
*/
export interface ResourceScopeFieldsProps {
@@ -18,11 +26,56 @@ export interface ResourceScopeFieldsProps {
onChange: (policy: Record | null) => void;
}
-export const ResourceScopeFields: (
- props: ResourceScopeFieldsProps,
-) => React.JSX.Element = () => (
-
- Resource scoping (limit this connection to specific repositories or folders)
- is not yet available in this build.
-
-);
+const asRecord = (value: unknown): Record =>
+ value != null && typeof value === "object" && !Array.isArray(value)
+ ? (value as Record)
+ : {};
+
+export const ResourceScopeFields = ({
+ connection,
+ policy,
+ onChange,
+}: ResourceScopeFieldsProps): React.JSX.Element | null => {
+ const config = granularAccessConfigs.get(connection.provider);
+ const metadata = asRecord(connection.metadata);
+
+ // No granular axis for this provider, or this connection can't be scoped:
+ // there is nothing to narrow, so render nothing (the whole connection is
+ // reachable, matching a null policy).
+ if (!config || !config.isSupported(metadata)) {
+ return null;
+ }
+
+ const items = config.getItems(metadata);
+ const selectedIds = config.getSelectedItems(policy ?? {});
+ // Coerce an empty policy to null at the emit boundary: "no restriction"
+ // must clear the scope, not send `{}` — the API's strict `sessionPolicySchema`
+ // rejects an empty object (it requires a `repositories`/`folders` key), so a
+ // user deselecting all repos / removing all folders would otherwise 400.
+ const emit = (ids: string[]) => {
+ const p = config.buildPolicy(ids);
+ onChange(Object.keys(p).length ? p : null);
+ };
+
+ return (
+
+
Resource access
+ {items.length > 0 ? (
+
+ ) : (
+
+ )}
+
+ );
+};
From 5581e1e6a3e400d326d47eafe5256a7098f52d7c Mon Sep 17 00:00:00 2001
From: marcorivm
Date: Wed, 29 Jul 2026 22:25:26 -0600
Subject: [PATCH 07/10] feat(web): org policy page, identity picker,
role-mappings UI, dead-code sweep
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Reconciliation Stage E (final feature stage). Mounts the OSS org policy
route via the eeRoutes seam (the write path and org-capable editor already
existed upstream — only the mount was missing, and removed-routes pointed
at a dangling path); adds the org policy page at /policy. The identity
picker is real again — people and user-groups only, no agent-groups — and
safe now that the gateway (Stages F/G/H) enforces those principals, so it
never writes rules the gateway ignores. Adds the role-mappings management
section to /groups (create/edit/reorder/delete, raise-only copy, live
blast-radius preview) that the backend had shipped without a UI. Sweeps the
unused SSO/SCIM/domain clients, hooks, types, and query keys. No
orphan-neutralization pass (inert under grants), no agent-group, no
migration.
---
.../groups/_components/groups-content.tsx | 11 +-
.../_components/role-mapping-dialog.tsx | 194 +++
.../_components/role-mapping-row-actions.tsx | 158 ++
.../_components/role-mappings-section.tsx | 179 +++
.../src/app/(dashboard)/policy/loading.tsx | 30 +
apps/web/src/app/(dashboard)/policy/page.tsx | 34 +
apps/web/src/hooks/use-domains.ts | 53 -
apps/web/src/hooks/use-scim-tokens.ts | 41 -
apps/web/src/hooks/use-sso-connections.ts | 71 -
apps/web/src/hooks/use-sso-enforcement.ts | 33 -
apps/web/src/lib/api/domains.ts | 14 -
apps/web/src/lib/api/index.ts | 16 -
apps/web/src/lib/api/keys.ts | 16 -
apps/web/src/lib/api/scim-tokens.ts | 12 -
apps/web/src/lib/api/sso-connections.ts | 24 -
apps/web/src/lib/api/sso-enforcement.ts | 10 -
apps/web/src/lib/api/types.ts | 85 --
apps/web/src/lib/nav-config.ts | 4 +
.../_components/identity-picker-section.tsx | 97 ++
.../src/lib/policy-editor/identity-picker.tsx | 322 +++-
packages/api/src/routes/org/index.ts | 2 +
packages/api/src/routes/org/policy.test.ts | 1342 +++++++++++++++++
packages/api/src/routes/org/policy.ts | 57 +
23 files changed, 2421 insertions(+), 384 deletions(-)
create mode 100644 apps/web/src/app/(dashboard)/groups/_components/role-mapping-dialog.tsx
create mode 100644 apps/web/src/app/(dashboard)/groups/_components/role-mapping-row-actions.tsx
create mode 100644 apps/web/src/app/(dashboard)/groups/_components/role-mappings-section.tsx
create mode 100644 apps/web/src/app/(dashboard)/policy/loading.tsx
create mode 100644 apps/web/src/app/(dashboard)/policy/page.tsx
delete mode 100644 apps/web/src/hooks/use-domains.ts
delete mode 100644 apps/web/src/hooks/use-scim-tokens.ts
delete mode 100644 apps/web/src/hooks/use-sso-connections.ts
delete mode 100644 apps/web/src/hooks/use-sso-enforcement.ts
delete mode 100644 apps/web/src/lib/api/domains.ts
delete mode 100644 apps/web/src/lib/api/scim-tokens.ts
delete mode 100644 apps/web/src/lib/api/sso-connections.ts
delete mode 100644 apps/web/src/lib/api/sso-enforcement.ts
create mode 100644 apps/web/src/lib/policy-editor/_components/identity-picker-section.tsx
create mode 100644 packages/api/src/routes/org/policy.test.ts
create mode 100644 packages/api/src/routes/org/policy.ts
diff --git a/apps/web/src/app/(dashboard)/groups/_components/groups-content.tsx b/apps/web/src/app/(dashboard)/groups/_components/groups-content.tsx
index 5479fe50..480cc11e 100644
--- a/apps/web/src/app/(dashboard)/groups/_components/groups-content.tsx
+++ b/apps/web/src/app/(dashboard)/groups/_components/groups-content.tsx
@@ -6,6 +6,7 @@ import { useGroups } from "@/hooks/use-groups";
import { AdminOnlyNotice } from "./admin-only-notice";
import { LocalModeNotice } from "./local-mode-notice";
import { GroupsTable } from "./groups-table";
+import { RoleMappingsSection } from "./role-mappings-section";
export interface GroupsContentProps {
/** Threaded from the RSC page (server-only auth mode); false = local mode. */
@@ -43,5 +44,13 @@ export const GroupsContent = ({ groupsEnabled }: GroupsContentProps) => {
if (groups.isError) return ;
- return ;
+ return (
+
+
+ {/* Role mappings live below the groups table: they map these groups to
+ org roles, so authoring them alongside the groups they reference keeps
+ the whole group-based access model on one page. */}
+
+
+ );
};
diff --git a/apps/web/src/app/(dashboard)/groups/_components/role-mapping-dialog.tsx b/apps/web/src/app/(dashboard)/groups/_components/role-mapping-dialog.tsx
new file mode 100644
index 00000000..dfbab07b
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/groups/_components/role-mapping-dialog.tsx
@@ -0,0 +1,194 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@onecli/ui/components/dialog";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@onecli/ui/components/select";
+import { Button } from "@onecli/ui/components/button";
+import { Label } from "@onecli/ui/components/label";
+import {
+ useCreateRoleMapping,
+ useUpdateRoleMapping,
+ useRoleMappingPreview,
+} from "@/hooks/use-role-mappings";
+import type { GroupRow, RoleMappingRow } from "@/lib/api";
+
+export interface RoleMappingDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ /** Groups selectable for a NEW mapping — the ones without a mapping already
+ * (a group maps to at most one role). Ignored when editing. */
+ availableGroups: GroupRow[];
+ /** Present = edit an existing mapping (group is fixed, only the role changes);
+ * absent = create. */
+ mapping?: RoleMappingRow;
+}
+
+type Role = "admin" | "member";
+
+/**
+ * Create or edit a group→role mapping. On create the admin picks a group and a
+ * role; on edit the group is fixed (a mapping's group is its identity — only
+ * the granted role is editable) and the select is disabled.
+ *
+ * The live preview is the honest part: it asks the server how many members
+ * WOULD change role under the proposed mapping before it is written, so an
+ * admin sees the blast radius (a raise-only change, but still a change) up
+ * front rather than from a surprised teammate.
+ */
+export const RoleMappingDialog = ({
+ open,
+ onOpenChange,
+ availableGroups,
+ mapping,
+}: RoleMappingDialogProps) => {
+ const isEdit = mapping !== undefined;
+ const [groupId, setGroupId] = useState(mapping?.groupId ?? "");
+ const [role, setRole] = useState(mapping?.role ?? "member");
+
+ const create = useCreateRoleMapping();
+ const update = useUpdateRoleMapping();
+ const pending = create.isPending || update.isPending;
+
+ // Reset the form whenever the dialog (re)opens — a create after an edit must
+ // not inherit the edited mapping's group/role.
+ useEffect(() => {
+ if (open) {
+ setGroupId(mapping?.groupId ?? "");
+ setRole(mapping?.role ?? "member");
+ }
+ }, [open, mapping]);
+
+ // Only preview once a group is chosen — the hook already no-ops on an empty
+ // groupId, but this keeps the query key stable.
+ const previewInput = useMemo(
+ () => (groupId ? { groupId, role } : null),
+ [groupId, role],
+ );
+ const preview = useRoleMappingPreview(previewInput);
+
+ const handleSubmit = () => {
+ if (!groupId || pending) return;
+ if (isEdit) {
+ update.mutate(
+ { id: mapping.id, input: { role } },
+ { onSuccess: () => onOpenChange(false) },
+ );
+ } else {
+ create.mutate(
+ { groupId, role },
+ { onSuccess: () => onOpenChange(false) },
+ );
+ }
+ };
+
+ const affected = preview.data?.affectedCount ?? 0;
+
+ return (
+
+
+
+
+ {isEdit ? "Edit role mapping" : "Add role mapping"}
+
+
+ Members of the group are granted at least this role. Mappings only
+ raise a member's role — they never lower it.
+
+
+
+
+
+
Group
+
+
+
+
+
+ {isEdit ? (
+
+ {mapping.groupName}
+
+ ) : availableGroups.length === 0 ? (
+
+ Every group already has a mapping.
+
+ ) : (
+ availableGroups.map((g) => (
+
+ {g.name}
+
+ ))
+ )}
+
+
+
+
+
+ Role
+ setRole(v as Role)}>
+
+
+
+
+ Member
+ Admin
+
+
+
+
+
+ {!groupId
+ ? "Select a group to preview the impact."
+ : preview.isPending
+ ? "Checking impact…"
+ : preview.isError
+ ? "Couldn't preview the impact."
+ : affected === 0
+ ? "No members would change role."
+ : `${affected} member${affected === 1 ? "" : "s"} would be raised to ${role}.`}
+
+
+
+
+ onOpenChange(false)}>
+ Cancel
+
+
+ {pending
+ ? isEdit
+ ? "Saving..."
+ : "Adding..."
+ : isEdit
+ ? "Save"
+ : "Add mapping"}
+
+
+
+
+ );
+};
diff --git a/apps/web/src/app/(dashboard)/groups/_components/role-mapping-row-actions.tsx b/apps/web/src/app/(dashboard)/groups/_components/role-mapping-row-actions.tsx
new file mode 100644
index 00000000..e77b2f82
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/groups/_components/role-mapping-row-actions.tsx
@@ -0,0 +1,158 @@
+"use client";
+
+import { useState } from "react";
+import { ArrowDown, ArrowUp, Loader2, MoreHorizontal } from "lucide-react";
+import { Button } from "@onecli/ui/components/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@onecli/ui/components/dropdown-menu";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@onecli/ui/components/alert-dialog";
+import { useDeleteRoleMapping } from "@/hooks/use-role-mappings";
+import type { GroupRow, RoleMappingRow } from "@/lib/api";
+import { RoleMappingDialog } from "./role-mapping-dialog";
+
+export interface RoleMappingRowActionsProps {
+ mapping: RoleMappingRow;
+ /** Groups selectable when editing — the edit dialog fixes the group, so this
+ * only needs to name the current one, but the dialog shares the prop. */
+ groups: GroupRow[];
+ canMoveUp: boolean;
+ canMoveDown: boolean;
+ /** Move this mapping one step higher/lower in priority. Owned by the section
+ * (it holds the single reorder mutation over the full ordered set). */
+ onMove: (direction: "up" | "down") => void;
+ /** A reorder is in flight for the whole list — lock the move controls. */
+ reordering: boolean;
+}
+
+/**
+ * Per-row controls for a role mapping: reorder (priority is first-match, so the
+ * order is load-bearing), edit the granted role, and delete. Reordering is
+ * lifted to the section so a single `reorder` call carries the whole ordered
+ * id set (a partial order 409s server-side).
+ */
+export const RoleMappingRowActions = ({
+ mapping,
+ groups,
+ canMoveUp,
+ canMoveDown,
+ onMove,
+ reordering,
+}: RoleMappingRowActionsProps) => {
+ const [editOpen, setEditOpen] = useState(false);
+ const [deleteOpen, setDeleteOpen] = useState(false);
+ const remove = useDeleteRoleMapping();
+
+ return (
+
+
onMove("up")}
+ >
+
+
+
onMove("down")}
+ >
+
+
+
+
+
+
+ {remove.isPending ? (
+
+ ) : (
+
+ )}
+
+
+
+ setEditOpen(true)}>
+ Edit role
+
+
+ setDeleteOpen(true)}
+ >
+ Delete
+
+
+
+
+
+
+
+
+
+
+ Delete the {mapping.groupName} mapping?
+
+
+ Members of {mapping.groupName} will no longer be raised to{" "}
+ {mapping.role} through this mapping. Anyone whose role was granted
+ only by it reverts to their base role. This cannot be undone.
+
+
+
+
+ Cancel
+
+ {
+ e.preventDefault();
+ remove.mutate(mapping.id, {
+ onSuccess: () => setDeleteOpen(false),
+ });
+ }}
+ disabled={remove.isPending}
+ >
+ {remove.isPending ? (
+ <>
+
+ Deleting...
+ >
+ ) : (
+ "Delete"
+ )}
+
+
+
+
+
+ );
+};
diff --git a/apps/web/src/app/(dashboard)/groups/_components/role-mappings-section.tsx b/apps/web/src/app/(dashboard)/groups/_components/role-mappings-section.tsx
new file mode 100644
index 00000000..3459cc5a
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/groups/_components/role-mappings-section.tsx
@@ -0,0 +1,179 @@
+"use client";
+
+import { useState } from "react";
+import { Lock, Plus, Shuffle } from "lucide-react";
+import { Badge } from "@onecli/ui/components/badge";
+import { Button } from "@onecli/ui/components/button";
+import { Card } from "@onecli/ui/components/card";
+import { Skeleton } from "@onecli/ui/components/skeleton";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@onecli/ui/components/table";
+import { useGroups } from "@/hooks/use-groups";
+import {
+ useReorderRoleMappings,
+ useRoleMappings,
+} from "@/hooks/use-role-mappings";
+import { RoleMappingDialog } from "./role-mapping-dialog";
+import { RoleMappingRowActions } from "./role-mapping-row-actions";
+
+export interface RoleMappingsSectionProps {
+ /** Threaded from the RSC page — false = local auth mode (no org backend). */
+ groupsEnabled: boolean;
+}
+
+/**
+ * Group→role mappings: members of a mapped group are granted at least its role.
+ *
+ * Ordering is load-bearing. Mappings are first-match by priority (top of the
+ * list wins), and the effect is MONOTONIC — a mapping can only RAISE a member's
+ * role, never lower it. So a member in several mapped groups lands on the
+ * highest role any applicable mapping grants, and reordering only matters where
+ * mappings would otherwise disagree.
+ *
+ * The section sits below the groups table on `/groups`. It renders for admins
+ * only: the parent already replaces the whole surface with the admin-only
+ * notice when the groups directory 403s, so a non-admin never reaches this.
+ * The defensive error branch stays for the rare case the two reads disagree.
+ */
+export const RoleMappingsSection = ({
+ groupsEnabled,
+}: RoleMappingsSectionProps) => {
+ const mappings = useRoleMappings(groupsEnabled);
+ // The create dialog offers groups that DON'T already have a mapping (a group
+ // maps to at most one role — a second create 409s server-side).
+ const groups = useGroups(groupsEnabled);
+ const reorder = useReorderRoleMappings();
+ const [createOpen, setCreateOpen] = useState(false);
+
+ // Inert without an org backend — the parent returns before this in local mode,
+ // but guard anyway so an accidental mount fires no doomed request.
+ if (!groupsEnabled) return null;
+
+ const rows = mappings.data ?? [];
+ const mappedGroupIds = new Set(rows.map((m) => m.groupId));
+ const availableGroups = (groups.data ?? []).filter(
+ (g) => !mappedGroupIds.has(g.id),
+ );
+
+ const move = (index: number, direction: "up" | "down") => {
+ const target = direction === "up" ? index - 1 : index + 1;
+ if (target < 0 || target >= rows.length) return;
+ const orderedIds = rows.map((m) => m.id);
+ const moved = orderedIds.splice(index, 1);
+ orderedIds.splice(target, 0, ...moved);
+ reorder.mutate(orderedIds);
+ };
+
+ return (
+
+
+
+
Role mappings
+
+ Grant members of a group an organization role automatically.
+ Mappings only raise a
+ member's role, never lower it. When several apply, the
+ highest-priority mapping wins — top of the list first.
+
+
+
setCreateOpen(true)}
+ disabled={mappings.isError}
+ >
+
+ Add mapping
+
+
+
+ {mappings.isPending ? (
+
+
+
+
+
+
+ ) : mappings.isError ? (
+
+
+
+
+ Admins only
+
+ Managing role mappings requires an organization admin.
+
+
+ ) : rows.length === 0 ? (
+
+
+
+
+ No role mappings yet
+
+ Map a group to a role so its members are granted it automatically.
+
+
+ ) : (
+
+
+
+
+ Priority
+ Group
+ Grants role
+ Members
+ Order
+
+
+
+ {rows.map((mapping, index) => (
+
+
+ {index + 1}
+
+
+ {mapping.groupName}
+
+
+
+ {mapping.role}
+
+
+
+ {mapping.memberCount}
+
+
+ 0}
+ canMoveDown={index < rows.length - 1}
+ onMove={(direction) => move(index, direction)}
+ reordering={reorder.isPending}
+ />
+
+
+ ))}
+
+
+
+ )}
+
+
+
+ );
+};
diff --git a/apps/web/src/app/(dashboard)/policy/loading.tsx b/apps/web/src/app/(dashboard)/policy/loading.tsx
new file mode 100644
index 00000000..2b7a7a0f
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/policy/loading.tsx
@@ -0,0 +1,30 @@
+import { Card } from "@onecli/ui/components/card";
+import { Skeleton } from "@onecli/ui/components/skeleton";
+
+/**
+ * Route-level skeleton. Mirrors the page frame (heading + rule cards) so the
+ * layout doesn't jump when the client editor mounts.
+ */
+export default function PolicyLoading() {
+ return (
+
+
+
+
+
+
+ {[1, 2].map((i) => (
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/apps/web/src/app/(dashboard)/policy/page.tsx b/apps/web/src/app/(dashboard)/policy/page.tsx
new file mode 100644
index 00000000..f41c687b
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/policy/page.tsx
@@ -0,0 +1,34 @@
+import { Suspense } from "react";
+import type { Metadata } from "next";
+import { PageHeader } from "@dashboard/page-header";
+import { PolicyEditor } from "@/lib/policy-editor";
+
+export const metadata: Metadata = {
+ title: "Policy",
+};
+
+/**
+ * The ORGANIZATION policy surface — the guardrails every project is evaluated
+ * against. The gateway evaluates these rules alongside each project's own
+ * policy and takes the stricter verdict (`policy_engine/evaluate.rs`), so they
+ * override nothing and can only tighten.
+ *
+ * A single scope: project-scope authoring retired in attach-model step 6
+ * (`/v1/policy/*` is 410'd; project rules compile from agent grants), so there
+ * is no scope switcher. No server-side role resolution — the API's 403 is the
+ * authority on who is an admin, and `PolicyEditor` renders the degrade when the
+ * org policy read fails.
+ */
+export default function PolicyPage() {
+ return (
+
+ );
+}
diff --git a/apps/web/src/hooks/use-domains.ts b/apps/web/src/hooks/use-domains.ts
deleted file mode 100644
index b1fc29e4..00000000
--- a/apps/web/src/hooks/use-domains.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-"use client";
-
-// No gateway-cache involvement: domains never affect agent traffic routing,
-// so neither these hooks nor the API routes flush the gateway.
-
-import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
-import { toast } from "sonner";
-import { domains } from "@/lib/api";
-import { queryKeys } from "@/lib/api/keys";
-
-export const useDomains = () =>
- useQuery({
- queryKey: queryKeys.domains.list(),
- queryFn: () => domains.list(),
- });
-
-export const useCreateDomain = () => {
- const qc = useQueryClient();
- return useMutation({
- mutationFn: (domain: string) => domains.create(domain),
- onSuccess: () => {
- qc.invalidateQueries({ queryKey: queryKeys.domains.all() });
- toast.success("Domain added — publish the TXT record to verify it");
- },
- // Surface the server reason (blocklist, already claimed, invalid shape).
- onError: (err) => toast.error(err.message),
- });
-};
-
-export const useVerifyDomain = () => {
- const qc = useQueryClient();
- return useMutation({
- mutationFn: (domainId: string) => domains.verify(domainId),
- onSuccess: () => {
- qc.invalidateQueries({ queryKey: queryKeys.domains.all() });
- toast.success("Domain verified");
- },
- // Usually "TXT record not found yet" — show the server message.
- onError: (err) => toast.error(err.message),
- });
-};
-
-export const useDeleteDomain = () => {
- const qc = useQueryClient();
- return useMutation({
- mutationFn: (domainId: string) => domains.remove(domainId),
- onSuccess: () => {
- qc.invalidateQueries({ queryKey: queryKeys.domains.all() });
- toast.success("Domain removed");
- },
- onError: () => toast.error("Failed to remove domain"),
- });
-};
diff --git a/apps/web/src/hooks/use-scim-tokens.ts b/apps/web/src/hooks/use-scim-tokens.ts
deleted file mode 100644
index 4338d8c6..00000000
--- a/apps/web/src/hooks/use-scim-tokens.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-"use client";
-
-// No gateway involvement: SCIM tokens gate the provisioning endpoint, not
-// agent traffic — nothing here flushes the gateway cache.
-
-import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
-import { toast } from "sonner";
-import { scimTokens } from "@/lib/api";
-import { queryKeys } from "@/lib/api/keys";
-
-export const useScimTokens = () =>
- useQuery({
- queryKey: queryKeys.scimTokens.list(),
- queryFn: () => scimTokens.list(),
- });
-
-export const useCreateScimToken = () => {
- const qc = useQueryClient();
- return useMutation({
- mutationFn: (label: string) => scimTokens.create(label),
- onSuccess: () => {
- // No success toast — the show-once dialog IS the confirmation.
- qc.invalidateQueries({ queryKey: queryKeys.scimTokens.all() });
- },
- onError: (err) => toast.error(err.message),
- });
-};
-
-export const useRevokeScimToken = () => {
- const qc = useQueryClient();
- return useMutation({
- mutationFn: (tokenId: string) => scimTokens.revoke(tokenId),
- onSuccess: () => {
- qc.invalidateQueries({ queryKey: queryKeys.scimTokens.all() });
- toast.success(
- "Token revoked — provisioning requests with it stop immediately",
- );
- },
- onError: () => toast.error("Failed to revoke token"),
- });
-};
diff --git a/apps/web/src/hooks/use-sso-connections.ts b/apps/web/src/hooks/use-sso-connections.ts
deleted file mode 100644
index 8fc49332..00000000
--- a/apps/web/src/hooks/use-sso-connections.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-"use client";
-
-// No gateway-cache involvement: SSO connections configure login, not agent
-// traffic — neither these hooks nor the API routes flush the gateway.
-
-import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
-import { toast } from "sonner";
-import { ssoConnections } from "@/lib/api";
-import type {
- CreateSsoConnectionInput,
- UpdateSsoConnectionInput,
-} from "@/lib/api";
-import { queryKeys } from "@/lib/api/keys";
-
-export const useSsoConnections = () =>
- useQuery({
- queryKey: queryKeys.ssoConnections.list(),
- queryFn: () => ssoConnections.list(),
- });
-
-export const useCreateSsoConnection = () => {
- const qc = useQueryClient();
- return useMutation({
- mutationFn: (input: CreateSsoConnectionInput) =>
- ssoConnections.create(input),
- onSuccess: () => {
- qc.invalidateQueries({ queryKey: queryKeys.ssoConnections.all() });
- toast.success("SSO connection created");
- },
- // Surface the server reason (duplicate, Cognito rejection, lock busy).
- onError: (err) => toast.error(err.message),
- });
-};
-
-export const useUpdateSsoConnection = () => {
- const qc = useQueryClient();
- return useMutation({
- mutationFn: ({
- connectionId,
- input,
- }: {
- connectionId: string;
- input: UpdateSsoConnectionInput;
- }) => ssoConnections.update(connectionId, input),
- onSuccess: () => {
- qc.invalidateQueries({ queryKey: queryKeys.ssoConnections.all() });
- toast.success("SSO connection updated");
- },
- onError: (err) => toast.error(err.message),
- });
-};
-
-export const useDeleteSsoConnection = () => {
- const qc = useQueryClient();
- return useMutation({
- mutationFn: (connectionId: string) => ssoConnections.remove(connectionId),
- onSuccess: () => {
- qc.invalidateQueries({ queryKey: queryKeys.ssoConnections.all() });
- toast.success("SSO connection removed");
- },
- onError: (err) => toast.error(err.message),
- });
-};
-
-// Returns the per-check results for inline rendering — the caller decides
-// how to present them; only hard errors toast.
-export const useTestSsoConnection = () =>
- useMutation({
- mutationFn: (connectionId: string) => ssoConnections.test(connectionId),
- onError: (err) => toast.error(err.message),
- });
diff --git a/apps/web/src/hooks/use-sso-enforcement.ts b/apps/web/src/hooks/use-sso-enforcement.ts
deleted file mode 100644
index a0e576c1..00000000
--- a/apps/web/src/hooks/use-sso-enforcement.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-"use client";
-
-// Require-SSO is a LOGIN policy — no gateway-cache involvement (agent
-// traffic is unaffected), so neither these hooks nor the API routes flush
-// the gateway. Same posture as use-domains / use-sso-connections.
-
-import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
-import { toast } from "sonner";
-import { ssoEnforcement } from "@/lib/api";
-import { queryKeys } from "@/lib/api/keys";
-
-export const useSsoEnforcement = () =>
- useQuery({
- queryKey: queryKeys.ssoEnforcement.get(),
- queryFn: () => ssoEnforcement.get(),
- });
-
-export const useUpdateSsoEnforcement = () => {
- const qc = useQueryClient();
- return useMutation({
- mutationFn: (ssoRequired: boolean) => ssoEnforcement.update(ssoRequired),
- onSuccess: (state) => {
- qc.invalidateQueries({ queryKey: queryKeys.ssoEnforcement.all() });
- toast.success(
- state.ssoRequired
- ? "Single sign-on is now required for this organization"
- : "Single sign-on is no longer required",
- );
- },
- // Surface the server reason (missing precondition, plan gate).
- onError: (err) => toast.error(err.message),
- });
-};
diff --git a/apps/web/src/lib/api/domains.ts b/apps/web/src/lib/api/domains.ts
deleted file mode 100644
index d348acd7..00000000
--- a/apps/web/src/lib/api/domains.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { apiGet, apiPost, apiDelete } from "./client";
-import type { OrgDomain } from "./types";
-
-// Org email domains are organization-scoped only — no project variant.
-const base = "/v1/org/domains";
-
-export const list = () => apiGet(base);
-
-export const create = (domain: string) => apiPost(base, { domain });
-
-export const verify = (domainId: string) =>
- apiPost(`${base}/${domainId}/verify`, {});
-
-export const remove = (domainId: string) => apiDelete(`${base}/${domainId}`);
diff --git a/apps/web/src/lib/api/index.ts b/apps/web/src/lib/api/index.ts
index 08c53b62..c13edee4 100644
--- a/apps/web/src/lib/api/index.ts
+++ b/apps/web/src/lib/api/index.ts
@@ -5,14 +5,10 @@ import * as connections from "./connections";
import * as grants from "./grants";
import * as projects from "./projects";
import * as projectAccess from "./project-access";
-import * as domains from "./domains";
import * as orgMembers from "./org-members";
import * as invitations from "./invitations";
import * as groups from "./groups";
import * as roleMappings from "./role-mappings";
-import * as ssoConnections from "./sso-connections";
-import * as ssoEnforcement from "./sso-enforcement";
-import * as scimTokens from "./scim-tokens";
import * as counts from "./counts";
import * as appBlocklist from "./app-blocklist";
import * as appConfig from "./app-config";
@@ -29,14 +25,10 @@ export {
grants,
projects,
projectAccess,
- domains,
orgMembers,
invitations,
groups,
roleMappings,
- ssoConnections,
- ssoEnforcement,
- scimTokens,
counts,
appBlocklist,
appConfig,
@@ -56,8 +48,6 @@ export type {
ProjectAccessUserRow,
ProjectAccessGroupRow,
SetProjectAccessInput,
- OrgDomain,
- OrgSsoEnforcement,
OrgMemberRow,
UpdatedOrgMember,
UpdateOrgMemberInput,
@@ -72,12 +62,6 @@ export type {
UpdateRoleMappingInput,
RoleMappingImpact,
OrgMemberListRow,
- OrgSsoConnection,
- SsoTestResult,
- CreateSsoConnectionInput,
- UpdateSsoConnectionInput,
- ScimToken,
- CreatedScimToken,
ResourceCounts,
CreateAgentInput,
CreateSecretInput,
diff --git a/apps/web/src/lib/api/keys.ts b/apps/web/src/lib/api/keys.ts
index cecc88c7..52784fc3 100644
--- a/apps/web/src/lib/api/keys.ts
+++ b/apps/web/src/lib/api/keys.ts
@@ -22,10 +22,6 @@ export const queryKeys = {
lastPublish: (pageScope: PageScope = "project") =>
[...queryKeys.policy.all(), "last-publish", pageScope] as const,
},
- domains: {
- all: () => ["domains", ...scope()] as const,
- list: () => [...queryKeys.domains.all(), "list"] as const,
- },
groups: {
all: () => ["groups", ...scope()] as const,
list: () => [...queryKeys.groups.all(), "list"] as const,
@@ -44,18 +40,6 @@ export const queryKeys = {
all: () => ["invitations", ...scope()] as const,
list: () => [...queryKeys.invitations.all(), "list"] as const,
},
- ssoConnections: {
- all: () => ["sso-connections", ...scope()] as const,
- list: () => [...queryKeys.ssoConnections.all(), "list"] as const,
- },
- ssoEnforcement: {
- all: () => ["sso-enforcement", ...scope()] as const,
- get: () => [...queryKeys.ssoEnforcement.all(), "get"] as const,
- },
- scimTokens: {
- all: () => ["scim-tokens", ...scope()] as const,
- list: () => [...queryKeys.scimTokens.all(), "list"] as const,
- },
grants: {
all: () => ["grants", ...scope()] as const,
agent: (agentId: string) =>
diff --git a/apps/web/src/lib/api/scim-tokens.ts b/apps/web/src/lib/api/scim-tokens.ts
deleted file mode 100644
index 7b60331e..00000000
--- a/apps/web/src/lib/api/scim-tokens.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { apiGet, apiPost, apiDelete } from "./client";
-import type { ScimToken, CreatedScimToken } from "./types";
-
-// SCIM provisioning tokens are organization-scoped only — no project variant.
-const base = "/v1/org/scim/tokens";
-
-export const list = () => apiGet(base);
-
-export const create = (label: string) =>
- apiPost(base, { label });
-
-export const revoke = (tokenId: string) => apiDelete(`${base}/${tokenId}`);
diff --git a/apps/web/src/lib/api/sso-connections.ts b/apps/web/src/lib/api/sso-connections.ts
deleted file mode 100644
index f326fb90..00000000
--- a/apps/web/src/lib/api/sso-connections.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { apiGet, apiPost, apiPatch, apiDelete } from "./client";
-import type {
- CreateSsoConnectionInput,
- OrgSsoConnection,
- SsoTestResult,
- UpdateSsoConnectionInput,
-} from "./types";
-
-// SSO connections are organization-scoped only — no project variant.
-const base = "/v1/org/sso/connections";
-
-export const list = () => apiGet(base);
-
-export const create = (input: CreateSsoConnectionInput) =>
- apiPost(base, input);
-
-export const update = (connectionId: string, input: UpdateSsoConnectionInput) =>
- apiPatch(`${base}/${connectionId}`, input);
-
-export const remove = (connectionId: string) =>
- apiDelete(`${base}/${connectionId}`);
-
-export const test = (connectionId: string) =>
- apiPost(`${base}/${connectionId}/test`, {});
diff --git a/apps/web/src/lib/api/sso-enforcement.ts b/apps/web/src/lib/api/sso-enforcement.ts
deleted file mode 100644
index 4b34a7a6..00000000
--- a/apps/web/src/lib/api/sso-enforcement.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { apiGet, apiPatch } from "./client";
-import type { OrgSsoEnforcement } from "./types";
-
-// Organization-scoped only — the require-SSO login policy.
-const base = "/v1/org/sso/enforcement";
-
-export const get = () => apiGet(base);
-
-export const update = (ssoRequired: boolean) =>
- apiPatch(base, { ssoRequired });
diff --git a/apps/web/src/lib/api/types.ts b/apps/web/src/lib/api/types.ts
index ee9e0f44..aca6766c 100644
--- a/apps/web/src/lib/api/types.ts
+++ b/apps/web/src/lib/api/types.ts
@@ -102,91 +102,6 @@ export interface SetProjectAccessInput {
groupIds: string[];
}
-export type SsoConnectionStatus = "pending" | "active" | "disabled";
-
-// An org's SSO/IdP connection — the redacted API shape (the OIDC client
-// secret never leaves the server).
-export interface OrgSsoConnection {
- id: string;
- type: "saml" | "oidc";
- status: SsoConnectionStatus;
- displayName: string;
- cognitoProviderName: string;
- config: {
- metadataUrl?: string;
- metadataXml?: string;
- issuer?: string;
- clientId?: string;
- certExpiresAt?: string | null;
- };
- createdAt: string;
- updatedAt: string;
-}
-
-export interface SsoTestCheck {
- name: string;
- ok: boolean;
- detail?: string;
-}
-
-export interface SsoTestResult {
- ok: boolean;
- checks: SsoTestCheck[];
-}
-
-export interface CreateSsoConnectionInput {
- type: "saml" | "oidc";
- displayName: string;
- metadataUrl?: string;
- metadataXml?: string;
- issuer?: string;
- clientId?: string;
- clientSecret?: string;
-}
-
-export interface UpdateSsoConnectionInput {
- displayName?: string;
- enabled?: boolean;
- metadataUrl?: string;
- metadataXml?: string;
- issuer?: string;
- clientId?: string;
- clientSecret?: string;
-}
-
-// An org's claimed email domain. `verifiedAt` null = pending the DNS TXT
-// check; the token is published in DNS, so it's safe to expose here.
-export interface OrgDomain {
- id: string;
- domain: string;
- verificationToken: string;
- verifiedAt: string | null;
- createdAt: string;
-}
-
-// A bearer token for the org's /scim/v2 provisioning endpoint. Reads only
-// ever carry metadata — the plaintext exists solely in the create response.
-export interface ScimToken {
- id: string;
- label: string;
- lastUsedAt: string | null;
- createdAt: string;
-}
-
-// POST /v1/org/scim/tokens — `token` is shown once and never retrievable.
-export interface CreatedScimToken extends ScimToken {
- token: string;
-}
-
-// Require-SSO enforcement state (GET/PATCH /v1/org/sso/enforcement).
-export interface OrgSsoEnforcement {
- ssoRequired: boolean;
- hasActiveConnection: boolean;
- hasVerifiedDomain: boolean;
- canRequire: boolean;
- exemptMemberCount: number;
-}
-
// PATCH /v1/org/members/:userId — exactly one change per request. `owner` is
// not assignable here (owner transfer is a separate operation); the `ssoExempt`
// arm is gone with the SSO feature it belonged to.
diff --git a/apps/web/src/lib/nav-config.ts b/apps/web/src/lib/nav-config.ts
index 0cb6bb93..fafc6dad 100644
--- a/apps/web/src/lib/nav-config.ts
+++ b/apps/web/src/lib/nav-config.ts
@@ -28,6 +28,10 @@ export const navItems: NavItem[] = [
{ title: "Overview", url: "/overview", icon: LayoutDashboard },
{ title: "Agents", url: "/agents", icon: Bot },
{ title: "Connections", url: "/connections", icon: Plug },
+ // Always visible: the organization policy surface degrades for non-admins
+ // (the API's 403 is the authority), so hiding it would require a session role
+ // field. Org rules are the guardrails every project is evaluated against.
+ { title: "Policy", url: "/policy", icon: ShieldCheck },
{ title: "Activity", url: "/activity", icon: Activity },
// Always visible (D-J): the page itself degrades for non-admins and in
// local auth mode — hiding the item would require a session role field.
diff --git a/apps/web/src/lib/policy-editor/_components/identity-picker-section.tsx b/apps/web/src/lib/policy-editor/_components/identity-picker-section.tsx
new file mode 100644
index 00000000..e99b292f
--- /dev/null
+++ b/apps/web/src/lib/policy-editor/_components/identity-picker-section.tsx
@@ -0,0 +1,97 @@
+"use client";
+
+import { Checkbox } from "@onecli/ui/components/checkbox";
+import type { ProjectionIdentity } from "@/lib/api";
+
+/** One selectable directory row: a stable id, a display label, and an optional
+ * second line (member counts / emails). */
+export interface IdentityPickerRow {
+ id: string;
+ label: string;
+ hint?: string | null;
+}
+
+export interface IdentityPickerSectionProps {
+ /** The identity kind every row in this section produces. */
+ type: Extract;
+ title: string;
+ /** The "project audience" caveat, rendered under People / Groups. */
+ note?: string;
+ rows: IdentityPickerRow[];
+ selected: Set;
+ onToggle: (type: IdentityPickerSectionProps["type"], id: string) => void;
+ /** Shown instead of the rows when the section resolved to nothing. */
+ emptyLabel: string;
+ /** The section's directory read is still in flight. Distinct from an empty
+ * directory: `emptyLabel` asserts the org HAS no groups/people, which would
+ * be a false statement (and a wrong call to action) while loading. */
+ pending?: boolean;
+ /** The section's directory read failed (a non-admin's 403, typically). */
+ failed: boolean;
+}
+
+/**
+ * One labelled block of the org identity picker. Kept in its own file (one
+ * component per file) and deliberately dumb: the picker owns the selection,
+ * the search filter, and every query.
+ */
+export const IdentityPickerSection = ({
+ type,
+ title,
+ note,
+ rows,
+ selected,
+ onToggle,
+ emptyLabel,
+ pending = false,
+ failed,
+}: IdentityPickerSectionProps) => (
+
+
{title}
+ {note &&
{note}
}
+ {failed ? (
+
+ Not available — organization directories are admin-only.
+
+ ) : pending ? (
+
+ Loading…
+
+ ) : rows.length === 0 ? (
+
{emptyLabel}
+ ) : (
+
+ {rows.map((row) => {
+ const inputId = `identity-${type}-${row.id}`;
+ return (
+
+ onToggle(type, row.id)}
+ />
+
+ {row.label}
+ {row.hint && (
+
+ {row.hint}
+
+ )}
+
+
+ );
+ })}
+
+ )}
+
+);
diff --git a/apps/web/src/lib/policy-editor/identity-picker.tsx b/apps/web/src/lib/policy-editor/identity-picker.tsx
index 2979506b..efd35392 100644
--- a/apps/web/src/lib/policy-editor/identity-picker.tsx
+++ b/apps/web/src/lib/policy-editor/identity-picker.tsx
@@ -1,14 +1,39 @@
"use client";
+import { useMemo, useState } from "react";
+import { Search } from "lucide-react";
+import { Badge } from "@onecli/ui/components/badge";
+import { Button } from "@onecli/ui/components/button";
+import { Input } from "@onecli/ui/components/input";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@onecli/ui/components/popover";
+import { useGroups } from "@/hooks/use-groups";
+import { useOrgMembersList } from "@/hooks/use-org-members";
import type { ProjectionIdentity } from "@/lib/api";
+import {
+ IdentityPickerSection,
+ type IdentityPickerRow,
+} from "./_components/identity-picker-section";
/**
- * The OSS identity-picker seam (step 9.5). Directory identities (users,
- * user-groups) are not implemented in this build, and since attach-model step 6
- * the only policy console left is the ORG one — which OSS does not mount at
- * all. So this stub can never render; it exists to keep the shared rule form
- * compiling in an OSS build. The EE editions alias this file to
- * `@/ee/policy-editor/identity-picker`.
+ * The OSS identity-picker seam. OSS mounts the organization policy scope
+ * (`/v1/org/policy`) and the Rust gateway ENFORCES org rules against a resolved
+ * principal set (`policy_engine/{loaders,assemble,evaluate}.rs`), so this
+ * picker authors real, enforced targeting.
+ *
+ * It offers exactly the identity kinds the API accepts at org scope and the
+ * gateway matches: `user`, `group`, and "none" (= any agent). Specific AGENTS
+ * are deliberately absent — `assertIdentitiesValid` 422s an `agent` identity on
+ * an org rule in every edition, so offering them would build a selection the
+ * server rejects. There is no agent-group kind on this base.
+ *
+ * The EE editions alias this file to `@/ee/policy-editor/identity-picker`; the
+ * path and the export name are the turbopack alias key
+ * (`next.config.js` → `POLICY_EDITOR_ALIASES`) and must not move or change
+ * shape.
*/
export interface OrgIdentityPickerProps {
@@ -18,5 +43,286 @@ export interface OrgIdentityPickerProps {
id?: string;
}
-export const OrgIdentityPicker: (props: OrgIdentityPickerProps) => null = () =>
- null;
+type DirectoryKind = "user" | "group";
+
+/** Proxied traffic carries NO connecting-user identity — `ProxyContext` is
+ * agent-only. A `user` / `group` principal therefore resolves to the projects
+ * that person (or group) can access, and matches EVERY agent in them. Said
+ * plainly here rather than left for an operator to discover from a block. */
+const AUDIENCE_NOTE =
+ "Matches any agent in a project this person or group can access — proxied requests carry no signed-in user.";
+
+const ALL_AGENTS_LABEL = "All agents in the organization";
+/** How many chips the trigger shows before collapsing into a "+N". */
+const CHIP_LIMIT = 3;
+
+/** Placeholder chip text for a principal whose directory never loaded — the
+ * kind is known from the identity row, the name is not. */
+const KIND_LABEL: Record = {
+ group: "Group",
+ user: "Person",
+};
+
+const idsOfKind = (value: ProjectionIdentity[], kind: DirectoryKind) =>
+ new Set(value.flatMap((i) => (i.type === kind ? [i.id] : [])));
+
+const matches = (q: string, ...fields: (string | null | undefined)[]) =>
+ !q || fields.some((f) => (f ?? "").toLowerCase().includes(q));
+
+export const OrgIdentityPicker = ({
+ value,
+ onChange,
+ id,
+}: OrgIdentityPickerProps) => {
+ const [open, setOpen] = useState(false);
+ const [search, setSearch] = useState("");
+
+ // Both feeds are the admin-only directories and both are `retry: false`, so a
+ // non-admin's 403 is deterministic and cheap. Only fetched while the popover
+ // is open — the rule drawer mounts this for every org rule, and the
+ // directories are otherwise unused there.
+ const groups = useGroups(open);
+ const members = useOrgMembersList(open);
+
+ const groupRows = useMemo(() => groups.data ?? [], [groups.data]);
+ // Kept COMPLETE (suspended included) — it is what resolves chip names, and a
+ // suspended teammate an older rule already names must render as themselves,
+ // not as "Unknown (removed)". The selectable set is narrowed below.
+ const memberRows = useMemo(() => members.data ?? [], [members.data]);
+
+ // Every directory failed → there is nothing to pick from. NOT gated on
+ // `open`: the notice under the trigger has to survive the popover closing,
+ // else the picker silently reads "All agents in the organization" with no
+ // hint that the feeds failed.
+ const allFailed = groups.isError && members.isError;
+ /** Per-kind resolution: a chip is only "removed" when ITS OWN directory
+ * loaded and did not contain it. One feed succeeding says nothing about the
+ * other. */
+ const loaded: Record = {
+ group: groups.isSuccess,
+ user: members.isSuccess,
+ };
+
+ const selected = useMemo(
+ () => ({
+ group: idsOfKind(value, "group"),
+ user: idsOfKind(value, "user"),
+ }),
+ [value],
+ );
+
+ const toggle = (type: DirectoryKind, rowId: string) => {
+ const present = value.some((i) => i.type === type && i.id === rowId);
+ onChange(
+ present
+ ? value.filter((i) => !(i.type === type && i.id === rowId))
+ : [...value, { type, id: rowId }],
+ );
+ };
+
+ const q = search.trim().toLowerCase();
+
+ const groupOptions: IdentityPickerRow[] = groupRows
+ .filter((g) => matches(q, g.name))
+ .map((g) => ({
+ id: g.id,
+ label: g.name,
+ hint: `${g.memberCount} member${g.memberCount === 1 ? "" : "s"}`,
+ }));
+ // SUSPENDED members are not OFFERED: `assertIdentitiesValid` counts users
+ // with `status: { not: "suspended" }` and 422s a miss with the generic "A
+ // referenced identity does not belong to this organization", so picking one
+ // would build a selection the server refuses — for a reason the toast gets
+ // wrong. One already on the rule stays listed (labelled), so it can be
+ // unselected — which is exactly what makes the rule saveable again.
+ const memberOptions: IdentityPickerRow[] = memberRows
+ .filter((m) => m.status !== "suspended" || selected.user.has(m.userId))
+ .filter((m) => matches(q, m.email, m.name))
+ .map((m) => ({
+ id: m.userId,
+ label: m.name ?? m.email,
+ hint:
+ m.status === "suspended"
+ ? "Suspended — remove to save this rule"
+ : m.name
+ ? m.email
+ : null,
+ }));
+
+ const nameOf = (identity: ProjectionIdentity): string | null => {
+ switch (identity.type) {
+ case "group":
+ return groupRows.find((g) => g.id === identity.id)?.name ?? null;
+ case "user": {
+ const row = memberRows.find((m) => m.userId === identity.id);
+ return row ? (row.name ?? row.email) : null;
+ }
+ default:
+ return null;
+ }
+ };
+
+ /**
+ * A chip's text and whether it may claim the principal is GONE. "Removed" is
+ * asserted only when the principal's OWN directory loaded and did not contain
+ * it — the visible face of the identity cascade (the server disables
+ * wholly-orphaned rules, but a multi-principal rule keeps its remaining
+ * targets and loses this one). A directory that 403'd or failed in transport
+ * renders a neutral kind placeholder instead: the principal exists, this
+ * build just cannot name it.
+ */
+ const chip = (
+ identity: ProjectionIdentity,
+ ): { label: string; removed: boolean; title?: string } => {
+ const kind = identity.type;
+ if (kind !== "group" && kind !== "user")
+ return { label: identity.id, removed: false };
+ if (!loaded[kind])
+ return {
+ label: KIND_LABEL[kind],
+ removed: false,
+ title:
+ "Still targeted — this directory could not be loaded, so its name is unavailable.",
+ };
+ const name = nameOf(identity);
+ return name
+ ? { label: name, removed: false }
+ : {
+ label: "Unknown (removed)",
+ removed: true,
+ title:
+ "This principal no longer exists — the rule no longer targets it.",
+ };
+ };
+ // NOTHING loaded (no feed has succeeded yet) → render the count rather than a
+ // row of bare kind placeholders.
+ const resolvable = loaded.group || loaded.user;
+
+ const shown = value.slice(0, CHIP_LIMIT);
+ const overflow = value.length - shown.length;
+
+ return (
+
+
+
+
+ {value.length === 0 ? (
+ {ALL_AGENTS_LABEL}
+ ) : !resolvable ? (
+ {value.length} selected
+ ) : (
+
+ {shown.map((identity) => {
+ const { label, removed, title } = chip(identity);
+ return (
+
+ {label}
+
+ );
+ })}
+ {overflow > 0 && (
+
+ +{overflow}
+
+ )}
+
+ )}
+
+
+
+
+
+
+ setSearch(e.target.value)}
+ placeholder="Filter groups, people..."
+ aria-label="Filter identities"
+ className="h-8 pl-8 text-sm"
+ autoComplete="off"
+ spellCheck={false}
+ />
+
+
+
+ {value.length === 0
+ ? ALL_AGENTS_LABEL
+ : `${value.length} selected`}
+
+
onChange([])}
+ disabled={value.length === 0}
+ className="text-muted-foreground hover:text-foreground text-xs transition-colors disabled:opacity-50"
+ >
+ Clear (all agents)
+
+
+
+
+
+
+
+
+
+
+ {allFailed && (
+
+ Organization directories are admin-only.{" "}
+ {value.length === 0
+ ? "This rule applies to all agents in the organization."
+ : "This rule's existing targets are preserved, but they can't be listed or changed here."}
+
+ )}
+
+ );
+};
diff --git a/packages/api/src/routes/org/index.ts b/packages/api/src/routes/org/index.ts
index d14c5b6c..49ae7484 100644
--- a/packages/api/src/routes/org/index.ts
+++ b/packages/api/src/routes/org/index.ts
@@ -4,6 +4,7 @@ import { orgMemberRoutes } from "./members";
import { orgInvitationRoutes } from "./invitations";
import { orgGroupRoutes } from "./groups";
import { orgRoleMappingRoutes } from "./role-mappings";
+import { ossOrgPolicyRoutes } from "./policy";
import { ossProjectRoutes } from "./projects";
/**
@@ -33,5 +34,6 @@ export const registerOssOrgRoutes = (app: Hono) => {
app.route("/org/invitations", orgInvitationRoutes());
app.route("/org/groups", orgGroupRoutes());
app.route("/org/role-mappings", orgRoleMappingRoutes());
+ app.route("/org/policy", ossOrgPolicyRoutes());
app.route("/projects", ossProjectRoutes());
};
diff --git a/packages/api/src/routes/org/policy.test.ts b/packages/api/src/routes/org/policy.test.ts
new file mode 100644
index 00000000..71b5b5c0
--- /dev/null
+++ b/packages/api/src/routes/org/policy.test.ts
@@ -0,0 +1,1342 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { Hono } from "hono";
+import type { ApiEnv } from "../../types";
+
+// `/v1/org/policy` end-to-end through the real app: the OSS org routes mounted
+// on the `eeRoutes` seam, the OSS role resolver wired as the RoleResolver, and
+// `CAPS.rbac` on. Admin callers arrive with an ORG API key; the non-admin cases
+// use a session, since a non-admin's org key fails key authentication outright.
+// (Same harness shape as groups.test.ts — cloned, not shared — with an
+// in-memory `policy_rules_v2` + identity/target row store, because the whole
+// point of this router is which SCOPE the shared policy handlers write in.)
+
+const ORG = "org-1";
+const OTHER_ORG = "org-2";
+const PROJECT = "proj-1";
+const OWNER = "user-owner";
+const ADMIN = "user-admin";
+const MEMBER = "user-member";
+const SUSPENDED = "user-suspended";
+const OUTSIDER = "user-outsider";
+const ADMIN_KEY = "oc_org_admin-key";
+const OTHER_ADMIN_KEY = "oc_org_outsider-key";
+const PROJECT_KEY = "oc_project-key-of-owner";
+
+vi.hoisted(() => {
+ process.env.NEXT_PUBLIC_EDITION = "oss";
+ process.env.SECRET_ENCRYPTION_KEY = "test-secret";
+ process.env.OAUTH_STATE_SECRET = "test-secret";
+});
+
+interface MemberRow {
+ organizationId: string;
+ userId: string;
+ userEmail: string;
+ role: string;
+ status: string;
+ ssoExempt: boolean;
+ suspendedAt: Date | null;
+ createdAt: Date;
+}
+
+interface UserRow {
+ id: string;
+ externalAuthId: string;
+ email: string;
+ name: string | null;
+}
+
+interface RuleRow {
+ id: string;
+ scope: string;
+ organizationId: string | null;
+ projectId: string | null;
+ status: string;
+ generation: number;
+ priority: number;
+ enabled: boolean;
+ isDefault: boolean;
+ logicalId: string;
+ source: string;
+ name: string;
+ description: string | null;
+ action: string;
+ rateLimit: number | null;
+ rateLimitWindow: string | null;
+ requireApproval: boolean;
+ conditions: unknown;
+ createdByUserId: string | null;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+interface IdentityRow {
+ id: string;
+ ruleId: string;
+ agentId: string | null;
+ userId: string | null;
+ groupId: string | null;
+}
+
+interface TargetRow {
+ id: string;
+ ruleId: string;
+ kind: string;
+ appProvider: string | null;
+ appTools: string[];
+ appConnectionScope: string | null;
+ appConnectionId: string | null;
+ secretId: string | null;
+ secretScope: string | null;
+ hostPattern: string | null;
+ pathPattern: string | null;
+ method: string | null;
+}
+
+interface DirectoryRow {
+ id: string;
+ organizationId: string;
+}
+
+interface ResourceRow {
+ id: string;
+ organizationId: string | null;
+ projectId: string | null;
+ scope: string;
+}
+
+interface AuditRow {
+ organizationId?: string;
+ projectId?: string;
+ userId: string;
+ action: string;
+ service: string;
+ source: string;
+ metadata: Record;
+}
+
+const store = vi.hoisted(() => ({
+ members: [] as MemberRow[],
+ users: [] as UserRow[],
+ rules: [] as RuleRow[],
+ identities: [] as IdentityRow[],
+ targets: [] as TargetRow[],
+ groups: [] as DirectoryRow[],
+ connections: [] as ResourceRow[],
+ secrets: [] as ResourceRow[],
+ audits: [] as AuditRow[],
+ seq: 0,
+ /** Which user the session provider resolves to (null = no session). */
+ sessionUserId: null as string | null,
+}));
+
+vi.mock("@onecli/db", () => {
+ class PrismaClientKnownRequestError extends Error {
+ code: string;
+ constructor(message: string, code: string) {
+ super(message);
+ this.code = code;
+ }
+ }
+
+ // The subset of `where` shapes the policy service actually builds.
+ interface RuleWhere {
+ id?: string;
+ scope?: string;
+ organizationId?: string;
+ projectId?: string;
+ status?: string;
+ isDefault?: boolean;
+ generation?: number | { lte: number };
+ }
+ interface OrgMemberWhere {
+ organizationId?: string;
+ userId?: string | { in: string[] };
+ role?: string | { not?: string };
+ status?: string | { not?: string };
+ }
+ interface IdWhere {
+ id?: { in: string[] };
+ organizationId?: string;
+ projectId?: string;
+ scope?: string;
+ }
+
+ const nextId = (prefix: string) => `${prefix}-${++store.seq}`;
+
+ const matchesRule = (row: RuleRow, where: RuleWhere = {}) => {
+ if (where.id !== undefined && row.id !== where.id) return false;
+ if (where.scope !== undefined && row.scope !== where.scope) return false;
+ // `undefined` means "not fenced"; the service always passes exactly one of
+ // organizationId / projectId (policyScope's invariant).
+ if (
+ where.organizationId !== undefined &&
+ row.organizationId !== where.organizationId
+ )
+ return false;
+ if (where.projectId !== undefined && row.projectId !== where.projectId)
+ return false;
+ if (where.status !== undefined && row.status !== where.status) return false;
+ if (where.isDefault !== undefined && row.isDefault !== where.isDefault)
+ return false;
+ if (where.generation !== undefined) {
+ if (typeof where.generation === "number") {
+ if (row.generation !== where.generation) return false;
+ } else if (row.generation > where.generation.lte) return false;
+ }
+ return true;
+ };
+
+ const filterRules = (where?: RuleWhere) =>
+ store.rules.filter((row) => matchesRule(row, where));
+
+ const withRelations = (
+ row: RuleRow,
+ include?: { identities?: boolean; targets?: boolean },
+ ) => ({
+ ...row,
+ ...(include?.identities
+ ? { identities: store.identities.filter((i) => i.ruleId === row.id) }
+ : {}),
+ ...(include?.targets
+ ? { targets: store.targets.filter((t) => t.ruleId === row.id) }
+ : {}),
+ });
+
+ const pickRule = (
+ row: RuleRow,
+ select?: Record,
+ include?: { identities?: boolean; targets?: boolean },
+ ) => {
+ if (!select) return withRelations(row, include);
+ const picked: Record = {};
+ for (const key of Object.keys(select)) {
+ if (select[key]) picked[key] = row[key as keyof RuleRow];
+ }
+ return picked;
+ };
+
+ /** A nested identity `create` entry — `{ group: { connect: { id } } }`. */
+ interface IdentityCreate {
+ agent?: { connect: { id: string } };
+ user?: { connect: { id: string } };
+ group?: { connect: { id: string } };
+ }
+ /** A nested target `create` entry — scalars plus optional relation connects. */
+ interface TargetCreate {
+ kind: string;
+ appProvider?: string | null;
+ appTools?: string[];
+ appConnectionScope?: string | null;
+ appConnection?: { connect: { id: string } };
+ secret?: { connect: { id: string } };
+ secretId?: string | null;
+ secretScope?: string | null;
+ hostPattern?: string | null;
+ pathPattern?: string | null;
+ method?: string | null;
+ }
+ interface RuleCreateData {
+ scope: string;
+ organizationId?: string;
+ projectId?: string;
+ status?: string;
+ generation?: number;
+ priority: number;
+ enabled?: boolean;
+ isDefault?: boolean;
+ logicalId?: string;
+ source?: string;
+ name: string;
+ description?: string | null;
+ action: string;
+ rateLimit?: number | null;
+ rateLimitWindow?: string | null;
+ requireApproval?: boolean;
+ conditions?: unknown;
+ createdByUserId?: string | null;
+ identities?: { create: IdentityCreate[] };
+ targets?: { create: TargetCreate[] };
+ }
+
+ const writeIdentities = (ruleId: string, entries: IdentityCreate[]) => {
+ for (const entry of entries) {
+ // The DB CHECK allows exactly one principal per row; the mock mirrors it
+ // so a malformed nested create surfaces here instead of silently storing
+ // an all-null row that would decode as "any".
+ const row: IdentityRow = {
+ id: nextId("pri"),
+ ruleId,
+ agentId: entry.agent?.connect.id ?? null,
+ userId: entry.user?.connect.id ?? null,
+ groupId: entry.group?.connect.id ?? null,
+ };
+ store.identities.push(row);
+ }
+ };
+
+ const writeTargets = (ruleId: string, entries: TargetCreate[]) => {
+ for (const entry of entries) {
+ store.targets.push({
+ id: nextId("tgt"),
+ ruleId,
+ kind: entry.kind,
+ appProvider: entry.appProvider ?? null,
+ appTools: entry.appTools ?? [],
+ appConnectionScope: entry.appConnectionScope ?? null,
+ appConnectionId: entry.appConnection?.connect.id ?? null,
+ secretId: entry.secret?.connect.id ?? entry.secretId ?? null,
+ secretScope: entry.secretScope ?? null,
+ hostPattern: entry.hostPattern ?? null,
+ pathPattern: entry.pathPattern ?? null,
+ method: entry.method ?? null,
+ });
+ }
+ };
+
+ const createRule = ({
+ data,
+ include,
+ }: {
+ data: RuleCreateData;
+ include?: { identities?: boolean; targets?: boolean };
+ }) => {
+ const now = new Date();
+ const row: RuleRow = {
+ id: nextId("r"),
+ scope: data.scope,
+ organizationId: data.organizationId ?? null,
+ projectId: data.projectId ?? null,
+ status: data.status ?? "draft",
+ generation: data.generation ?? 0,
+ priority: data.priority,
+ enabled: data.enabled ?? true,
+ isDefault: data.isDefault ?? false,
+ // `@default(gen_random_uuid())` in the schema — a snapshot copies its
+ // source draft's, which is what keeps rate counters stable.
+ logicalId: data.logicalId ?? nextId("logical"),
+ source: data.source ?? "custom",
+ name: data.name,
+ description: data.description ?? null,
+ action: data.action,
+ rateLimit: data.rateLimit ?? null,
+ rateLimitWindow: data.rateLimitWindow ?? null,
+ requireApproval: data.requireApproval ?? false,
+ conditions: data.conditions ?? null,
+ createdByUserId: data.createdByUserId ?? null,
+ createdAt: now,
+ updatedAt: now,
+ };
+ store.rules.push(row);
+ writeIdentities(row.id, data.identities?.create ?? []);
+ writeTargets(row.id, data.targets?.create ?? []);
+ return withRelations(row, include);
+ };
+
+ const dropRules = (ids: Set) => {
+ store.rules = store.rules.filter((r) => !ids.has(r.id));
+ // The FK cascade on PolicyRuleIdentity / PolicyRuleTarget.
+ store.identities = store.identities.filter((i) => !ids.has(i.ruleId));
+ store.targets = store.targets.filter((t) => !ids.has(t.ruleId));
+ };
+
+ const policyRuleV2 = {
+ aggregate: async ({
+ where,
+ _max,
+ }: {
+ where?: RuleWhere;
+ _max?: { generation?: boolean; priority?: boolean };
+ }) => {
+ const rows = filterRules(where);
+ const max = (values: number[]) =>
+ values.length === 0 ? null : Math.max(...values);
+ return {
+ _max: {
+ ...(_max?.generation
+ ? { generation: max(rows.map((r) => r.generation)) }
+ : {}),
+ ...(_max?.priority
+ ? { priority: max(rows.map((r) => r.priority)) }
+ : {}),
+ },
+ };
+ },
+ count: async ({ where }: { where?: RuleWhere }) =>
+ filterRules(where).length,
+ findMany: async ({
+ where,
+ include,
+ select,
+ }: {
+ where?: RuleWhere;
+ orderBy?: unknown;
+ include?: { identities?: boolean; targets?: boolean };
+ select?: Record;
+ }) =>
+ filterRules(where)
+ .slice()
+ // The service always asks for [{ priority: asc }, { id: asc }] — the
+ // exact order the gateway's loader mirrors, so it is hard-coded here
+ // rather than interpreted.
+ .sort((a, b) => a.priority - b.priority || a.id.localeCompare(b.id))
+ .map((row) => pickRule(row, select, include)),
+ findFirst: async ({
+ where,
+ include,
+ select,
+ }: {
+ where?: RuleWhere;
+ include?: { identities?: boolean; targets?: boolean };
+ select?: Record;
+ }) => {
+ const row = filterRules(where)
+ .slice()
+ .sort((a, b) => a.priority - b.priority || a.id.localeCompare(b.id))[0];
+ return row ? pickRule(row, select, include) : null;
+ },
+ create: createRule,
+ update: async ({
+ where,
+ data,
+ include,
+ }: {
+ where: { id: string };
+ data: Partial & {
+ identities?: { create: IdentityCreate[] };
+ targets?: { create: TargetCreate[] };
+ };
+ include?: { identities?: boolean; targets?: boolean };
+ }) => {
+ const row = store.rules.find((r) => r.id === where.id);
+ if (!row) {
+ throw new PrismaClientKnownRequestError("Record not found", "P2025");
+ }
+ for (const [key, value] of Object.entries(data)) {
+ if (key === "identities" || key === "targets") continue;
+ (row as unknown as Record)[key] = value;
+ }
+ row.updatedAt = new Date();
+ if (data.identities) writeIdentities(row.id, data.identities.create);
+ if (data.targets) writeTargets(row.id, data.targets.create);
+ return withRelations(row, include);
+ },
+ delete: async ({ where }: { where: { id: string } }) => {
+ const row = store.rules.find((r) => r.id === where.id);
+ if (!row) {
+ throw new PrismaClientKnownRequestError("Record not found", "P2025");
+ }
+ dropRules(new Set([row.id]));
+ return row;
+ },
+ deleteMany: async ({ where }: { where?: RuleWhere }) => {
+ const rows = filterRules(where);
+ dropRules(new Set(rows.map((r) => r.id)));
+ return { count: rows.length };
+ },
+ };
+
+ const policyRuleIdentity = {
+ findMany: async ({ where }: { where: Partial }) =>
+ store.identities.filter((row) =>
+ Object.entries(where).every(
+ ([key, value]) => row[key as keyof IdentityRow] === value,
+ ),
+ ),
+ deleteMany: async ({ where }: { where: { ruleId: string } }) => {
+ const before = store.identities.length;
+ store.identities = store.identities.filter(
+ (i) => i.ruleId !== where.ruleId,
+ );
+ return { count: before - store.identities.length };
+ },
+ };
+
+ const policyRuleTarget = {
+ deleteMany: async ({ where }: { where: { ruleId: string } }) => {
+ const before = store.targets.length;
+ store.targets = store.targets.filter((t) => t.ruleId !== where.ruleId);
+ return { count: before - store.targets.length };
+ },
+ };
+
+ const countDirectory = (rows: DirectoryRow[], where: IdWhere) =>
+ rows.filter(
+ (row) =>
+ (where.id?.in ?? []).includes(row.id) &&
+ (where.organizationId === undefined ||
+ row.organizationId === where.organizationId),
+ ).length;
+
+ const countResources = (rows: ResourceRow[], where: IdWhere) =>
+ rows.filter(
+ (row) =>
+ (where.id?.in ?? []).includes(row.id) &&
+ (where.organizationId === undefined ||
+ row.organizationId === where.organizationId) &&
+ (where.projectId === undefined || row.projectId === where.projectId) &&
+ (where.scope === undefined || row.scope === where.scope),
+ ).length;
+
+ const findMember = (organizationId: string, userId: string) =>
+ store.members.find(
+ (row) => row.organizationId === organizationId && row.userId === userId,
+ );
+
+ const filterOrgMembers = (where: OrgMemberWhere) =>
+ store.members.filter((row) => {
+ if (
+ where.organizationId !== undefined &&
+ row.organizationId !== where.organizationId
+ )
+ return false;
+ if (typeof where.userId === "string" && row.userId !== where.userId)
+ return false;
+ if (
+ typeof where.userId === "object" &&
+ where.userId !== null &&
+ !where.userId.in.includes(row.userId)
+ )
+ return false;
+ if (where.status !== undefined) {
+ const ok =
+ typeof where.status === "string"
+ ? row.status === where.status
+ : where.status.not === undefined || row.status !== where.status.not;
+ if (!ok) return false;
+ }
+ if (where.role !== undefined) {
+ const ok =
+ typeof where.role === "string"
+ ? row.role === where.role
+ : where.role.not === undefined || row.role !== where.role.not;
+ if (!ok) return false;
+ }
+ return true;
+ });
+
+ const db = {
+ apiKey: {
+ findUnique: async ({ where }: { where: { key?: string } }) => {
+ if (where.key === "oc_org_admin-key")
+ return {
+ userId: "user-admin",
+ organizationId: "org-1",
+ scope: "organization",
+ };
+ if (where.key === "oc_org_outsider-key")
+ return {
+ userId: "user-outsider",
+ organizationId: "org-2",
+ scope: "organization",
+ };
+ // A PROJECT-scoped key owned by the org's OWNER: it authenticates
+ // fine, which is exactly why the router needs its own scope guard.
+ if (where.key === "oc_project-key-of-owner")
+ return { userId: "user-owner", projectId: "proj-1" };
+ return null;
+ },
+ findFirst: async () => null,
+ findMany: async () => [],
+ },
+ user: {
+ findUnique: async ({
+ where,
+ select,
+ }: {
+ where: { id?: string; externalAuthId?: string; email?: string };
+ select?: Record;
+ }) => {
+ if (select?.organizationMemberships) {
+ return {
+ organizationMemberships: store.members
+ .filter((m) => m.userId === where.id)
+ .map((m) => ({ organizationId: m.organizationId })),
+ };
+ }
+ return (
+ store.users.find(
+ (u) =>
+ (where.id !== undefined && u.id === where.id) ||
+ (where.externalAuthId !== undefined &&
+ u.externalAuthId === where.externalAuthId) ||
+ (where.email !== undefined && u.email === where.email),
+ ) ?? null
+ );
+ },
+ },
+ organizationMember: {
+ findUnique: async ({
+ where,
+ }: {
+ where: {
+ organizationId_userId: { organizationId: string; userId: string };
+ };
+ }) => {
+ const { organizationId, userId } = where.organizationId_userId;
+ return findMember(organizationId, userId) ?? null;
+ },
+ findFirst: async ({ where }: { where: OrgMemberWhere }) =>
+ filterOrgMembers(where)[0] ?? null,
+ findMany: async ({ where }: { where: OrgMemberWhere }) =>
+ filterOrgMembers(where),
+ // The identity ownership check: a suspended member is excluded by
+ // `status: { not: "suspended" }`, so this count is what makes a rule
+ // targeting a suspended user a 422.
+ count: async ({ where }: { where: OrgMemberWhere & IdWhere }) =>
+ filterOrgMembers(where).filter((row) =>
+ (where.id?.in ?? [row.userId]).includes(row.userId),
+ ).length,
+ },
+ group: {
+ count: async ({ where }: { where: IdWhere }) =>
+ countDirectory(store.groups, where),
+ },
+ agent: { count: async () => 0 },
+ appConnection: {
+ count: async ({ where }: { where: IdWhere }) =>
+ countResources(store.connections, where),
+ findMany: async () => [],
+ },
+ secret: {
+ count: async ({ where }: { where: IdWhere }) =>
+ countResources(store.secrets, where),
+ },
+ project: {
+ findFirst: async () => ({ id: "proj-1", organizationId: "org-1" }),
+ findUnique: async () => ({ id: "proj-1", organizationId: "org-1" }),
+ },
+ projectAccess: { findFirst: async () => null },
+ policyRuleV2,
+ policyRuleIdentity,
+ policyRuleTarget,
+ auditLog: {
+ create: async ({ data }: { data: AuditRow }) => {
+ store.audits.push(data);
+ return data;
+ },
+ },
+ // The policy service uses the INTERACTIVE form; the org group/agent-group
+ // services (not exercised here) use the array form. Support both.
+ $transaction: async (arg: unknown) => {
+ if (typeof arg === "function") {
+ const tx = {
+ // lockScope's advisory lock — a tagged template.
+ $executeRaw: async () => 0,
+ policyRuleV2,
+ policyRuleIdentity,
+ policyRuleTarget,
+ };
+ return (arg as (tx: unknown) => Promise)(tx);
+ }
+ return Promise.all(arg as Promise[]);
+ },
+ };
+
+ return {
+ Prisma: { JsonNull: null, PrismaClientKnownRequestError },
+ db,
+ };
+});
+
+import { createApiApp } from "../../app";
+import { registerOssOrgRoutes } from "./index";
+import { ossRoleResolver } from "../../services/org-role-resolver";
+
+const sessionProvider = {
+ getSession: async () => {
+ const user = store.users.find((u) => u.id === store.sessionUserId);
+ return user ? { id: user.externalAuthId, email: user.email } : null;
+ },
+};
+
+const app: Hono = createApiApp(sessionProvider, {
+ eeRoutes: registerOssOrgRoutes,
+ roleResolver: ossRoleResolver,
+});
+
+const at = (minutes: number) => new Date(Date.UTC(2026, 0, 1, 0, minutes));
+
+const member = (
+ userId: string,
+ role: string,
+ createdAt: Date,
+ organizationId = ORG,
+ status = "active",
+): MemberRow => ({
+ organizationId,
+ userId,
+ userEmail: `${userId}@example.com`,
+ role,
+ status,
+ ssoExempt: false,
+ suspendedAt: status === "suspended" ? createdAt : null,
+ createdAt,
+});
+
+const rule = (id: string, overrides: Partial = {}): RuleRow => ({
+ id,
+ scope: "organization",
+ organizationId: ORG,
+ projectId: null,
+ status: "draft",
+ generation: 0,
+ priority: 1,
+ enabled: true,
+ isDefault: false,
+ logicalId: `logical-${id}`,
+ source: "custom",
+ name: id,
+ description: null,
+ action: "block",
+ rateLimit: null,
+ rateLimitWindow: null,
+ requireApproval: false,
+ conditions: null,
+ createdByUserId: ADMIN,
+ createdAt: at(1),
+ updatedAt: at(1),
+ ...overrides,
+});
+
+beforeEach(() => {
+ store.users = [
+ {
+ id: OWNER,
+ externalAuthId: "ext-owner",
+ email: "owner@example.com",
+ name: "Olive Owner",
+ },
+ {
+ id: ADMIN,
+ externalAuthId: "ext-admin",
+ email: "admin@example.com",
+ name: "Adam Admin",
+ },
+ {
+ id: MEMBER,
+ externalAuthId: "ext-member",
+ email: "member@example.com",
+ name: null,
+ },
+ {
+ id: SUSPENDED,
+ externalAuthId: "ext-suspended",
+ email: "suspended@example.com",
+ name: null,
+ },
+ {
+ id: OUTSIDER,
+ externalAuthId: "ext-outsider",
+ email: "outsider@other.test",
+ name: "Odette Outsider",
+ },
+ ];
+ store.members = [
+ member(OWNER, "owner", at(0)),
+ member(ADMIN, "admin", at(1)),
+ member(MEMBER, "member", at(2)),
+ member(SUSPENDED, "member", at(3), ORG, "suspended"),
+ member(OUTSIDER, "admin", at(4), OTHER_ORG),
+ ];
+ store.groups = [
+ { id: "g-1", organizationId: ORG },
+ { id: "g-2", organizationId: ORG },
+ { id: "g-x", organizationId: OTHER_ORG },
+ ];
+ store.connections = [
+ {
+ id: "conn-org",
+ organizationId: ORG,
+ projectId: null,
+ scope: "organization",
+ },
+ {
+ id: "conn-proj",
+ organizationId: ORG,
+ projectId: PROJECT,
+ scope: "project",
+ },
+ ];
+ store.secrets = [
+ {
+ id: "sec-org",
+ organizationId: ORG,
+ projectId: null,
+ scope: "organization",
+ },
+ {
+ id: "sec-proj",
+ organizationId: ORG,
+ projectId: PROJECT,
+ scope: "project",
+ },
+ ];
+ store.rules = [
+ // Two org-1 draft rules, deliberately seeded out of priority order.
+ rule("r-b", { priority: 2, name: "Block paste sites" }),
+ rule("r-a", { priority: 1, name: "Block internal admin API" }),
+ // Another org's rule, and this org's PROJECT rule — neither may ever
+ // appear through /v1/org/policy.
+ rule("r-foreign", { organizationId: OTHER_ORG, name: "Foreign" }),
+ rule("r-project", {
+ scope: "project",
+ organizationId: null,
+ projectId: PROJECT,
+ name: "Project rule",
+ }),
+ ];
+ store.identities = [
+ {
+ id: "pri-a",
+ ruleId: "r-a",
+ agentId: null,
+ userId: null,
+ groupId: "g-1",
+ },
+ ];
+ store.targets = [
+ {
+ id: "tgt-a",
+ ruleId: "r-a",
+ kind: "network",
+ appProvider: null,
+ appTools: [],
+ appConnectionScope: null,
+ appConnectionId: null,
+ secretId: null,
+ secretScope: null,
+ hostPattern: "admin.internal",
+ pathPattern: null,
+ method: null,
+ },
+ ];
+ store.audits = [];
+ store.seq = 1000;
+ store.sessionUserId = null;
+});
+
+const asAdmin = { headers: { Authorization: `Bearer ${ADMIN_KEY}` } };
+const asOtherAdmin = {
+ headers: { Authorization: `Bearer ${OTHER_ADMIN_KEY}` },
+};
+const asProjectKey = { headers: { Authorization: `Bearer ${PROJECT_KEY}` } };
+
+interface RuleBody {
+ id: string;
+ scope: string;
+ name: string;
+ priority: number;
+ action: string;
+ isDefault: boolean;
+ identities: { type: string; id: string }[];
+ targets: Record[];
+}
+
+const NETWORK_TARGET = { kind: "network", hostPattern: "example.com" };
+
+const listRules = async (
+ init: RequestInit = asAdmin,
+ query = "",
+): Promise => {
+ const res = await app.request(`/v1/org/policy/rules${query}`, init);
+ expect(res.status).toBe(200);
+ return (await res.json()) as RuleBody[];
+};
+
+const create = (body: unknown, init: RequestInit = asAdmin) =>
+ app.request("/v1/org/policy/rules", {
+ ...init,
+ method: "POST",
+ body: JSON.stringify(body),
+ });
+
+const patch = (id: string, body: unknown, init: RequestInit = asAdmin) =>
+ app.request(`/v1/org/policy/rules/${id}`, {
+ ...init,
+ method: "PATCH",
+ body: JSON.stringify(body),
+ });
+
+const remove = (id: string, init: RequestInit = asAdmin) =>
+ app.request(`/v1/org/policy/rules/${id}`, { ...init, method: "DELETE" });
+
+const reorder = (orderedIds: string[], init: RequestInit = asAdmin) =>
+ app.request("/v1/org/policy/rules/order", {
+ ...init,
+ method: "PUT",
+ body: JSON.stringify({ orderedIds }),
+ });
+
+const setDefault = (action: string, init: RequestInit = asAdmin) =>
+ app.request("/v1/org/policy/default", {
+ ...init,
+ method: "PATCH",
+ body: JSON.stringify({ action }),
+ });
+
+const publish = (init: RequestInit = asAdmin) =>
+ app.request("/v1/org/policy/publish", {
+ ...init,
+ method: "POST",
+ body: "{}",
+ });
+
+const orgRules = (status = "draft") =>
+ store.rules.filter((r) => r.organizationId === ORG && r.status === status);
+
+const identitiesOf = (ruleId: string) =>
+ store.identities.filter((i) => i.ruleId === ruleId);
+
+describe("mount + scope fencing", () => {
+ it("serves the org's own organization-scope rules, in priority order", async () => {
+ const body = await listRules();
+ expect(body.map((r) => r.id)).toEqual(["r-a", "r-b"]);
+ expect(body[0]).toMatchObject({
+ id: "r-a",
+ scope: "organization",
+ name: "Block internal admin API",
+ identities: [{ type: "group", id: "g-1" }],
+ });
+ });
+
+ it("never returns another org's rules, nor this org's PROJECT rules", async () => {
+ const body = await listRules();
+ const ids = body.map((r) => r.id);
+ expect(ids).not.toContain("r-foreign");
+ expect(ids).not.toContain("r-project");
+ });
+
+ it("writes rules into the ORGANIZATION scope, never a project", async () => {
+ const res = await create({
+ name: "Block pastebin",
+ action: "block",
+ targets: [NETWORK_TARGET],
+ });
+ expect(res.status).toBe(201);
+ const created = (await res.json()) as RuleBody;
+ const row = store.rules.find((r) => r.id === created.id);
+ expect(row).toMatchObject({
+ scope: "organization",
+ organizationId: ORG,
+ projectId: null,
+ status: "draft",
+ isDefault: false,
+ });
+ });
+
+ it("appends a new rule below the existing ones (manual ordering)", async () => {
+ const res = await create({
+ name: "Appended",
+ action: "block",
+ targets: [NETWORK_TARGET],
+ });
+ const created = (await res.json()) as RuleBody;
+ expect(created.priority).toBe(3);
+ expect((await listRules()).map((r) => r.id)).toEqual([
+ "r-a",
+ "r-b",
+ created.id,
+ ]);
+ });
+});
+
+describe("authorization", () => {
+ it("403s every verb for a non-admin member session", async () => {
+ store.sessionUserId = MEMBER;
+ const asMember: RequestInit = {};
+ const responses = await Promise.all([
+ app.request("/v1/org/policy/rules", asMember),
+ app.request("/v1/org/policy/default", asMember),
+ create(
+ { name: "x", action: "block", targets: [NETWORK_TARGET] },
+ asMember,
+ ),
+ patch("r-a", { name: "y" }, asMember),
+ remove("r-a", asMember),
+ reorder(["r-a", "r-b"], asMember),
+ setDefault("allow", asMember),
+ publish(asMember),
+ ]);
+ expect(responses.map((r) => r.status)).toEqual([
+ 403, 403, 403, 403, 403, 403, 403, 403,
+ ]);
+ // Nothing changed behind the 403s.
+ expect(
+ orgRules()
+ .map((r) => r.id)
+ .sort(),
+ ).toEqual(["r-a", "r-b"]);
+ });
+
+ it("403s a PROJECT-scoped key even when its holder is an org admin", async () => {
+ // The Slice-3 invariant, and the security test that matters most here:
+ // `role` is scope-blind, so a leaked agent key belonging to an admin would
+ // otherwise rewrite the guardrails its own traffic is judged against.
+ const res = await app.request("/v1/org/policy/rules", asProjectKey);
+ expect(res.status).toBe(403);
+ const write = await create(
+ { name: "x", action: "block", targets: [NETWORK_TARGET] },
+ asProjectKey,
+ );
+ expect(write.status).toBe(403);
+ });
+
+ it("401s an unauthenticated caller (auth runs before the editing gate)", async () => {
+ const res = await app.request("/v1/org/policy/rules");
+ expect(res.status).toBe(401);
+ });
+});
+
+describe("cross-org isolation", () => {
+ it("hides org-1's rules from org-2's admin", async () => {
+ const body = await listRules(asOtherAdmin);
+ expect(body.map((r) => r.id)).toEqual(["r-foreign"]);
+ });
+
+ it("404s org-2's admin on org-1's rule for read, patch and delete", async () => {
+ const get = await app.request("/v1/org/policy/rules/r-a", asOtherAdmin);
+ expect(get.status).toBe(404);
+ expect(
+ (await patch("r-a", { name: "hijacked" }, asOtherAdmin)).status,
+ ).toBe(404);
+ expect((await remove("r-a", asOtherAdmin)).status).toBe(404);
+ expect(store.rules.find((r) => r.id === "r-a")?.name).toBe(
+ "Block internal admin API",
+ );
+ });
+
+ it("409s a reorder that names another org's rules", async () => {
+ const res = await reorder(["r-a", "r-b"], asOtherAdmin);
+ expect(res.status).toBe(409);
+ expect(store.rules.find((r) => r.id === "r-a")?.priority).toBe(1);
+ });
+
+ it("publishes only its own org's rules", async () => {
+ const res = await publish(asOtherAdmin);
+ expect(res.status).toBe(200);
+ const published = store.rules.filter((r) => r.status === "published");
+ expect(published.every((r) => r.organizationId === OTHER_ORG)).toBe(true);
+ });
+});
+
+describe("identity authoring (the picker's contract)", () => {
+ const withIdentities = (identities: unknown[]) => ({
+ name: "Directory rule",
+ action: "block",
+ targets: [NETWORK_TARGET],
+ identities,
+ });
+
+ it("accepts user and group identities, one row per principal", async () => {
+ const res = await create(
+ withIdentities([
+ { type: "user", id: MEMBER },
+ { type: "group", id: "g-1" },
+ { type: "group", id: "g-2" },
+ ]),
+ );
+ expect(res.status).toBe(201);
+ const created = (await res.json()) as RuleBody;
+ expect(created.identities).toEqual([
+ { type: "user", id: MEMBER },
+ { type: "group", id: "g-1" },
+ { type: "group", id: "g-2" },
+ ]);
+ // Exactly one principal column per row — the shape `assemble.rs` decodes.
+ for (const row of identitiesOf(created.id)) {
+ const named = [row.agentId, row.userId, row.groupId].filter(
+ (v) => v !== null,
+ );
+ expect(named).toHaveLength(1);
+ }
+ });
+
+ it("accepts an empty identity set (= all agents in the organization)", async () => {
+ const res = await create(withIdentities([]));
+ expect(res.status).toBe(201);
+ const created = (await res.json()) as RuleBody;
+ expect(created.identities).toEqual([]);
+ expect(identitiesOf(created.id)).toHaveLength(0);
+ });
+
+ it("422s a specific AGENT — the kind the picker must never offer", async () => {
+ const res = await create(
+ withIdentities([{ type: "agent", id: "agent-1" }]),
+ );
+ expect(res.status).toBe(422);
+ const body = (await res.json()) as { error: { message: string } };
+ expect(body.error.message).toMatch(/not a specific agent/i);
+ });
+
+ it("422s a principal from another organization", async () => {
+ for (const identity of [
+ { type: "group", id: "g-x" },
+ { type: "user", id: OUTSIDER },
+ ]) {
+ const res = await create(withIdentities([identity]));
+ expect(res.status).toBe(422);
+ }
+ });
+
+ it("422s a SUSPENDED member (the gateway drops them from the principal set)", async () => {
+ const res = await create(withIdentities([{ type: "user", id: SUSPENDED }]));
+ expect(res.status).toBe(422);
+ });
+
+ it("replaces the identity set on PATCH", async () => {
+ const res = await patch("r-a", {
+ identities: [{ type: "group", id: "g-1" }],
+ });
+ expect(res.status).toBe(200);
+ expect(identitiesOf("r-a").map((i) => i.groupId)).toEqual(["g-1"]);
+ expect(
+ identitiesOf("r-a").every((i) => i.agentId === null && i.userId === null),
+ ).toBe(true);
+ });
+});
+
+describe("target authoring at org scope", () => {
+ const withTargets = (targets: unknown[]) => ({
+ name: "Target rule",
+ action: "block",
+ targets,
+ });
+
+ it("accepts both level markers on an org rule", async () => {
+ for (const level of ["organization", "project"] as const) {
+ const app_ = await create(
+ withTargets([
+ { kind: "app", provider: "github", connectionScope: level },
+ ]),
+ );
+ expect(app_.status).toBe(201);
+ const secret = await create(
+ withTargets([{ kind: "secret", secretScope: level }]),
+ );
+ expect(secret.status).toBe(201);
+ }
+ });
+
+ it("422s an org rule naming a PROJECT-level connection or secret", async () => {
+ const conn = await create(
+ withTargets([{ kind: "connection", connectionId: "conn-proj" }]),
+ );
+ expect(conn.status).toBe(422);
+ const secret = await create(
+ withTargets([{ kind: "secret", secretId: "sec-proj" }]),
+ );
+ expect(secret.status).toBe(422);
+ });
+
+ it("accepts an org-level connection / secret by id", async () => {
+ const conn = await create(
+ withTargets([{ kind: "connection", connectionId: "conn-org" }]),
+ );
+ expect(conn.status).toBe(201);
+ const secret = await create(
+ withTargets([{ kind: "secret", secretId: "sec-org" }]),
+ );
+ expect(secret.status).toBe(201);
+ });
+
+ it("422s a rule with no targets (an empty target set matches nothing)", async () => {
+ const res = await create(withTargets([]));
+ expect(res.status).toBe(422);
+ });
+});
+
+describe("the Default Rule", () => {
+ it("GET returns a VIRTUAL default and writes nothing", async () => {
+ const res = await app.request("/v1/org/policy/default", asAdmin);
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as RuleBody;
+ // The virtual (unpersisted) default is `allow` in every scope on this base
+ // (`defaultAction`); `id: ""` marks it virtual.
+ expect(body).toMatchObject({ id: "", isDefault: true, action: "allow" });
+ // A read must never mint a persisted default row.
+ expect(store.rules.some((r) => r.isDefault)).toBe(false);
+ });
+
+ it("PATCH persists the admin's choice — an ALLOW default, not the minted Block", async () => {
+ const res = await setDefault("allow");
+ expect(res.status).toBe(200);
+ const defaults = store.rules.filter(
+ (r) => r.isDefault && r.organizationId === ORG && r.status === "draft",
+ );
+ expect(defaults).toHaveLength(1);
+ expect(defaults[0]).toMatchObject({ action: "allow", priority: 0 });
+ });
+
+ it("PATCH is idempotent — it never mints a second default", async () => {
+ await setDefault("allow");
+ await setDefault("block");
+ const defaults = store.rules.filter(
+ (r) => r.isDefault && r.organizationId === ORG && r.status === "draft",
+ );
+ expect(defaults).toHaveLength(1);
+ expect(defaults[0]?.action).toBe("block");
+ });
+
+ it("keeps the default out of the rules list", async () => {
+ await setDefault("allow");
+ expect((await listRules()).map((r) => r.id)).toEqual(["r-a", "r-b"]);
+ });
+});
+
+describe("publish + generations", () => {
+ it("snapshots the draft set plus the default into generation 1, then 2", async () => {
+ const first = await publish();
+ expect(first.status).toBe(200);
+ expect(await first.json()).toMatchObject({ generation: 1, ruleCount: 3 });
+
+ const published = store.rules.filter(
+ (r) => r.organizationId === ORG && r.status === "published",
+ );
+ expect(published).toHaveLength(3); // r-a, r-b + the minted default
+ expect(published.filter((r) => r.isDefault)).toHaveLength(1);
+
+ const second = await publish();
+ expect(await second.json()).toMatchObject({ generation: 2 });
+ });
+
+ it("copies identities and targets onto the published generation", async () => {
+ await publish();
+ const snapshot = store.rules.find(
+ (r) => r.status === "published" && r.name === "Block internal admin API",
+ );
+ expect(snapshot).toBeDefined();
+ expect(identitiesOf(snapshot?.id ?? "").map((i) => i.groupId)).toEqual([
+ "g-1",
+ ]);
+ expect(
+ store.targets
+ .filter((t) => t.ruleId === snapshot?.id)
+ .map((t) => t.hostPattern),
+ ).toEqual(["admin.internal"]);
+ });
+
+ it("returns only the ACTIVE published generation from ?status=published", async () => {
+ await publish();
+ await publish();
+ const body = await listRules(asAdmin, "?status=published");
+ expect(body.map((r) => r.name).sort()).toEqual([
+ "Block internal admin API",
+ "Block paste sites",
+ ]);
+ const generations = new Set(
+ body.map((r) => store.rules.find((row) => row.id === r.id)?.generation),
+ );
+ expect([...generations]).toEqual([2]);
+ });
+
+ it("prunes published generations beyond the retention window", async () => {
+ for (let i = 0; i < 11; i++) await publish();
+ const generations = store.rules
+ .filter((r) => r.organizationId === ORG && r.status === "published")
+ .map((r) => r.generation);
+ expect(Math.max(...generations)).toBe(11);
+ expect(Math.min(...generations)).toBe(2); // generation 1 pruned
+ });
+
+ it("410s a PROJECT publish — project-scope policy CRUD was retired", async () => {
+ // Project rules are compiled from agent grants now; `/v1/policy/*` answers
+ // 410 (`removedProjectPolicyRoutes`), so an org publish is the only writer.
+ const projectPublish = await app.request("/v1/policy/publish", {
+ ...asProjectKey,
+ method: "POST",
+ body: "{}",
+ headers: { ...asProjectKey.headers, "X-Project-Id": PROJECT },
+ });
+ expect(projectPublish.status).toBe(410);
+
+ const again = await publish();
+ expect(await again.json()).toMatchObject({ generation: 1 });
+ // No project generations were ever written.
+ expect(
+ store.rules.some(
+ (r) => r.projectId === PROJECT && r.status === "published",
+ ),
+ ).toBe(false);
+ });
+});
+
+describe("ordering", () => {
+ it("renumbers the draft densely and returns the new order", async () => {
+ const res = await reorder(["r-b", "r-a"]);
+ expect(res.status).toBe(200);
+ expect(((await res.json()) as RuleBody[]).map((r) => r.id)).toEqual([
+ "r-b",
+ "r-a",
+ ]);
+ expect(store.rules.find((r) => r.id === "r-b")?.priority).toBe(1);
+ expect(store.rules.find((r) => r.id === "r-a")?.priority).toBe(2);
+ });
+
+ it("409s a stale id set rather than writing a partial order", async () => {
+ const missing = await reorder(["r-a"]);
+ expect(missing.status).toBe(409);
+ const extra = await reorder(["r-a", "r-b", "r-project"]);
+ expect(extra.status).toBe(409);
+ const duplicated = await reorder(["r-a", "r-a"]);
+ expect(duplicated.status).toBe(409);
+ expect(store.rules.find((r) => r.id === "r-a")?.priority).toBe(1);
+ });
+});
+
+describe("audit + gateway cache flush", () => {
+ // `withAudit` keys `invalidateGatewayCacheForOrg` off `organizationId`, so an
+ // audit row missing it is also a MISSED FLUSH — the guardrail would keep
+ // enforcing its old shape for the cache window.
+ const expectOrgAudit = (action: string) => {
+ const row = store.audits.at(-1);
+ expect(row).toMatchObject({
+ organizationId: ORG,
+ userId: ADMIN,
+ action,
+ service: "policy",
+ source: "api",
+ });
+ expect(row?.projectId).toBeUndefined();
+ };
+
+ it("audits create, update, delete, reorder, default and publish", async () => {
+ const created = (await (
+ await create({
+ name: "Audited",
+ action: "block",
+ targets: [NETWORK_TARGET],
+ })
+ ).json()) as RuleBody;
+ expectOrgAudit("create");
+
+ await patch(created.id, { name: "Audited again" });
+ expectOrgAudit("update");
+
+ await reorder(["r-a", "r-b", created.id]);
+ expectOrgAudit("update");
+
+ await setDefault("allow");
+ expectOrgAudit("update");
+
+ await publish();
+ expectOrgAudit("publish");
+
+ await remove(created.id);
+ expectOrgAudit("delete");
+ });
+
+ it("carries the rule id in the metadata (never the whole rule)", async () => {
+ const created = (await (
+ await create({
+ name: "Metadata",
+ action: "block",
+ targets: [NETWORK_TARGET],
+ })
+ ).json()) as RuleBody;
+ expect(store.audits.at(-1)?.metadata).toEqual({
+ ruleId: created.id,
+ name: "Metadata",
+ });
+ });
+});
diff --git a/packages/api/src/routes/org/policy.ts b/packages/api/src/routes/org/policy.ts
new file mode 100644
index 00000000..4ccb4040
--- /dev/null
+++ b/packages/api/src/routes/org/policy.ts
@@ -0,0 +1,57 @@
+import { Hono } from "hono";
+import type { ApiEnv } from "../../types";
+import { auth } from "../../middleware/auth";
+import { ServiceError } from "../../services/errors";
+import { registerPolicyRoutes } from "../policy";
+
+/**
+ * `/v1/org/policy` — the ORGANIZATION policy scope (the guardrail level the
+ * gateway evaluates alongside each project's policy, taking the stricter
+ * verdict).
+ *
+ * Same guard stack as `/v1/org/groups`, for the same reasons:
+ *
+ * `requireProject: false`: these are ORG-scoped routes, so a caller with no
+ * project context (an org API key without `X-Project-Id`) must still get
+ * through. `role: "admin"` makes the whole router admin-only — a plain member
+ * gets a deterministic 403, which is what the web client renders (the org
+ * policy page's admin-only notice) rather than retries.
+ *
+ * `role` alone is SCOPE-BLIND, so it is not sufficient on its own: a
+ * project-scoped key (the credential an agent carries) resolves to its owning
+ * user, and if that user happens to be an org admin the role check passes. Org
+ * guardrails override every project's policy, so a leaked agent key would
+ * otherwise be able to rewrite the rules its own traffic is evaluated against.
+ * Org-wide authority requires an org-wide credential.
+ *
+ * Mounting a sub-app re-registers its `use("*")` guards under the mount path,
+ * so these two guards — and `registerPolicyRoutes`'s own editing-flag gate —
+ * cover every path beneath `/v1/org/policy` and only those. Registration order
+ * puts the auth guards first, so an unauthenticated caller gets 401 rather
+ * than "editing disabled".
+ */
+export const ossOrgPolicyRoutes = () => {
+ const app = new Hono();
+ app.use("*", auth({ requireProject: false, role: "admin" }));
+ app.use("*", async (c, next) => {
+ if (c.get("auth").scope === "project") {
+ throw new ServiceError(
+ "FORBIDDEN",
+ "Organization policy requires an organization-scoped credential.",
+ );
+ }
+ return next();
+ });
+
+ registerPolicyRoutes(app, {
+ // `organizationId` ONLY: `policyScope` requires exactly one key, and a
+ // scope carrying both would let project rows into this scope's reads.
+ resolveScope: (auth) => ({ organizationId: auth.organizationId }),
+ // Not decoration: `withAudit` keys `invalidateGatewayCacheForOrg` off
+ // `organizationId`, so this is the gateway cache-flush key. A missed flush
+ // is an org guardrail that keeps enforcing for up to the cache window
+ // after it was changed.
+ auditScope: (auth) => ({ organizationId: auth.organizationId }),
+ });
+ return app;
+};
From 741a45c53b8cbc64ec80b010aac821b122669ebd Mon Sep 17 00:00:00 2001
From: marcorivm
Date: Wed, 29 Jul 2026 23:11:05 -0600
Subject: [PATCH 08/10] feat: re-land spend budgets onto 1.44.0
Reconciliation Stage I (final). Per-secret monthly cost caps: the gateway
meters anthropic/openai token usage post-response (bounded stream tee, no
byte corruption, Accept-Encoding: identity so usage parses, prompt-cache
tokens priced), keeps a nano-dollar running total in the cache counter,
and enforces a pre-request 402 when over. Fail-OPEN on any metering or
read error (a cost control, not a security gate) while an over-budget org
is still blocked; the budget gate runs after the security decision so it
can never turn a Block into an allow. Org-scoped budget CRUD on the
eeRoutes seam + a Budgets tab. The reconciliation preserved every hook
site, so forward.rs needed no edits. +21 gateway / +12 api tests, no
agent-group, no migration.
---
apps/gateway/src/budget.rs | 533 ++++++++++++++++-
apps/gateway/src/db.rs | 75 +++
apps/gateway/src/gateway/hooks.rs | 262 +++++++-
apps/gateway/src/telemetry.rs | 56 +-
.../_components/budget-row-actions.tsx | 177 ++++++
.../budgets/_components/budget-usage-bar.tsx | 60 ++
.../budgets/_components/budgets-content.tsx | 57 ++
.../budgets/_components/budgets-list.tsx | 59 ++
.../_components/create-budget-dialog.tsx | 185 ++++++
.../connections/(tabs)/budgets/page.tsx | 10 +
.../_components/connections-tabs.tsx | 4 +
apps/web/src/hooks/use-budgets.ts | 81 +++
apps/web/src/lib/api/budgets.ts | 43 ++
apps/web/src/lib/api/index.ts | 8 +
apps/web/src/lib/api/keys.ts | 4 +
packages/api/src/routes/org/budgets.test.ts | 559 ++++++++++++++++++
packages/api/src/routes/org/budgets.ts | 146 +++++
packages/api/src/routes/org/index.ts | 2 +
packages/api/src/services/budget-service.ts | 234 ++++++++
19 files changed, 2520 insertions(+), 35 deletions(-)
create mode 100644 apps/web/src/app/(dashboard)/connections/(tabs)/budgets/_components/budget-row-actions.tsx
create mode 100644 apps/web/src/app/(dashboard)/connections/(tabs)/budgets/_components/budget-usage-bar.tsx
create mode 100644 apps/web/src/app/(dashboard)/connections/(tabs)/budgets/_components/budgets-content.tsx
create mode 100644 apps/web/src/app/(dashboard)/connections/(tabs)/budgets/_components/budgets-list.tsx
create mode 100644 apps/web/src/app/(dashboard)/connections/(tabs)/budgets/_components/create-budget-dialog.tsx
create mode 100644 apps/web/src/app/(dashboard)/connections/(tabs)/budgets/page.tsx
create mode 100644 apps/web/src/hooks/use-budgets.ts
create mode 100644 apps/web/src/lib/api/budgets.ts
create mode 100644 packages/api/src/routes/org/budgets.test.ts
create mode 100644 packages/api/src/routes/org/budgets.ts
create mode 100644 packages/api/src/services/budget-service.ts
diff --git a/apps/gateway/src/budget.rs b/apps/gateway/src/budget.rs
index a2785537..e6ec7281 100644
--- a/apps/gateway/src/budget.rs
+++ b/apps/gateway/src/budget.rs
@@ -1,18 +1,36 @@
-//! Budget layer — stub for the OSS build. All functions are no-ops; the cloud
-//! build swaps this module for `ee/budget.rs` via `#[path]` in `main.rs`.
+//! Budget layer — spend caps on org/project-owned LLM secrets (OSS).
//!
-//! The shared types (`BudgetBinding`, `BudgetPeriod`) and `resolve_bindings` are
-//! referenced by the shared `connect.rs`/`gateway/mitm.rs` threading, so they
-//! exist in both builds with the same surface — inert in OSS
-//! (`resolve_bindings` always returns an empty Vec, so the threaded field stays
-//! empty and the cloud-only enforcement/metering in `ee/hooks.rs` never runs).
+//! An admin sets a per-secret cost cap (`Budget` row). The gateway meters LLM
+//! spend against it by parsing the provider `usage` object out of the response
+//! (see `gateway/hooks.rs`), pricing it against the static [`price`] table, and
+//! recording it (see `telemetry.rs`). Enforcement is a PRE-request gate in
+//! `hooks::pre_forward`: once a prior request pushes the running total to/over
+//! the limit, the NEXT request is denied `402`. The in-flight request that
+//! crosses the line completes (cost is only known at stream end), so one
+//! request may overshoot — the cap blocks new requests once exceeded.
+//!
+//! Fail direction: the budget gate fails OPEN. A budget is a cost control, not
+//! a security control — a metering/read glitch lets the request through rather
+//! than causing a self-inflicted outage. Only the normal over-limit path bites.
+//!
+//! ⚠ KEEP THE SHARED TYPES (`BudgetBinding`, `BudgetPeriod`) IDENTICAL to
+//! `ee/budget.rs`. Only one of the two modules compiles per build (feature
+//! swap), so the shared threading in `connect.rs`/`gateway/mitm.rs` uses
+//! whichever copy is active. Treat the types as one.
+
+use std::collections::HashMap;
use serde::{Deserialize, Serialize};
+use time::OffsetDateTime;
+use tracing::warn;
+
+/// One cent = 1e7 nano-dollars (1e-9 USD).
+pub(crate) const CENT_TO_NANOS: i64 = 10_000_000;
-// ⚠ KEEP THE TYPES BELOW IDENTICAL to `ee/budget.rs`. Only one of the two
-// modules compiles per build (feature swap), so a field added to one and not the
-// other will NOT fail compilation — the shared threading in `connect.rs`/
-// `gateway/mitm.rs` just uses whichever copy is active. Treat them as one type.
+/// TTL for the hot spend counter. A monthly period rolls to a new counter key
+/// on the 1st (so a new month resets regardless of TTL); this bound just forces
+/// a periodic rehydrate from the durable `BudgetSpend` floor for `total` caps.
+pub(crate) const PERIOD_TTL: u64 = 60 * 60 * 24 * 40; // 40 days
/// How a budget's spend window resets.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -37,14 +55,491 @@ pub(crate) struct BudgetBinding {
pub period: BudgetPeriod,
}
-/// Resolve budget bindings for the effective partner secrets among a request's
-/// host-filtered secrets. OSS: always empty (no budgets enforced). Concrete on
-/// `db::SecretRow` — the cloud impl is generic over a `BudgetSecret` trait, but
-/// the stub only needs to accept what `connect.rs` passes (`&[SecretRow]`).
+/// Parsed token usage from a metered LLM response.
+#[derive(Debug, Clone, PartialEq)]
+pub(crate) struct Usage {
+ pub input: u64,
+ pub output: u64,
+ /// Anthropic `cache_creation_input_tokens` — cache-WRITE tokens, billed at
+ /// 1.25× base input and EXCLUDED from `input`. Always 0 for OpenAI (which
+ /// folds cached tokens into `prompt_tokens`).
+ pub cache_write: u64,
+ /// Anthropic `cache_read_input_tokens` — cache-READ tokens, billed at 0.1×
+ /// base input and EXCLUDED from `input`. Always 0 for OpenAI.
+ pub cache_read: u64,
+ /// Model served (from the response body) — selects the price row.
+ pub model: String,
+}
+
+/// Whether this secret type has a meter + price table entry, so a budget on it
+/// can actually be enforced. Everything else is DESCOPED (see the plan): a
+/// budget on an unmeterable secret is rejected at create time by the API.
+pub(crate) fn is_metered_type(secret_type: &str) -> bool {
+ matches!(secret_type, "anthropic" | "openai")
+}
+
+/// Resolve budget bindings for the org/project LLM secrets among a request's
+/// host-filtered secrets. Loads `Budget` rows for `(organization_id, secret_id ∈
+/// metered host-matched secrets)` and maps each to a [`BudgetBinding`]. Errors ⇒
+/// log + return `[]` (fail-open at resolution too — no binding ⇒ no cap).
pub(crate) async fn resolve_bindings(
- _pool: &sqlx::PgPool,
- _org_id: &str,
- _secrets: &[crate::db::SecretRow],
+ pool: &sqlx::PgPool,
+ org_id: &str,
+ secrets: &[crate::db::SecretRow],
) -> Vec {
- Vec::new()
+ // Only metered LLM types can be priced; others can't carry a spend cap.
+ let type_by_id: HashMap<&str, &str> = secrets
+ .iter()
+ .filter(|s| is_metered_type(&s.type_))
+ .map(|s| (s.id.as_str(), s.type_.as_str()))
+ .collect();
+ if type_by_id.is_empty() {
+ return Vec::new();
+ }
+
+ let ids: Vec = type_by_id.keys().map(|id| id.to_string()).collect();
+ let rows = match crate::db::find_budgets_for_secrets(pool, org_id, &ids).await {
+ Ok(rows) => rows,
+ Err(e) => {
+ warn!(error = %e, "budget: failed to load budgets; enforcing none (fail-open)");
+ return Vec::new();
+ }
+ };
+
+ rows.into_iter()
+ .filter_map(|row| {
+ let secret_type = type_by_id.get(row.secret_id.as_str())?;
+ Some(BudgetBinding {
+ secret_id: row.secret_id,
+ organization_id: org_id.to_string(),
+ secret_type: (*secret_type).to_string(),
+ limit_nanos: row.limit_cents as i64 * CENT_TO_NANOS,
+ period: period_from_str(&row.period),
+ })
+ })
+ .collect()
+}
+
+fn period_from_str(s: &str) -> BudgetPeriod {
+ match s {
+ "total" => BudgetPeriod::Total,
+ _ => BudgetPeriod::Monthly,
+ }
+}
+
+// ── Period + cache keys ──────────────────────────────────────────────────
+
+/// The spend-window key. Monthly → `m:YYYY-MM` (UTC), so a new month is a new
+/// key = automatic reset. Total → `total` (lifetime).
+pub(crate) fn period_key(period: BudgetPeriod, now: OffsetDateTime) -> String {
+ match period {
+ BudgetPeriod::Monthly => {
+ format!("m:{:04}-{:02}", now.year(), u8::from(now.month()))
+ }
+ BudgetPeriod::Total => "total".to_string(),
+ }
+}
+
+/// The hot-counter cache key holding accumulated nano-dollars for this window.
+pub(crate) fn counter_key(secret_id: &str, org_id: &str, period_key: &str) -> String {
+ format!("budget:spent:{secret_id}:{org_id}:{period_key}")
+}
+
+/// Enforcement predicate: a spend cap denies the NEXT request once the running
+/// total meets or exceeds the limit. `>=` — at exactly the limit, deny.
+pub(crate) fn is_over(spent: i64, limit: i64) -> bool {
+ spent >= limit
+}
+
+// ── Metering: parse + price ──────────────────────────────────────────────
+
+/// Bounded head/tail budget for the metering copy (per direction), enough for a
+/// non-stream JSON `usage` (trailing) or an SSE `message_start` (leading).
+pub(crate) const META_CAP: usize = 16 * 1024;
+
+/// Parse the provider `usage` from a bounded response sample. `head` is the
+/// first bytes (SSE `message_start` with input tokens + model), `tail` the last
+/// bytes (non-stream trailing `usage`, or the SSE final `message_delta` output).
+/// Best-effort substring scan — tolerant of truncation; when no usage is present
+/// (e.g. OpenAI SSE without `include_usage`) returns `None` ⇒ the caller charges
+/// 0 (fail-open). Never fabricates.
+pub(crate) fn parse_usage(secret_type: &str, head: &[u8], tail: &[u8]) -> Option {
+ let (in_key, out_key) = match secret_type {
+ "anthropic" => ("input_tokens", "output_tokens"),
+ "openai" => ("prompt_tokens", "completion_tokens"),
+ _ => return None,
+ };
+ let h = String::from_utf8_lossy(head);
+ let t = String::from_utf8_lossy(tail);
+
+ // Input tokens live in the leading usage (SSE message_start) or the trailing
+ // usage (non-stream) — first occurrence in either.
+ let input = find_uint(&h, in_key, false).or_else(|| find_uint(&t, in_key, false));
+ // Output tokens live in the trailing usage / final message_delta — last
+ // occurrence in the tail, then the head as a degraded fallback.
+ let output = find_uint(&t, out_key, true).or_else(|| find_uint(&h, out_key, true));
+ let model = find_str(&h, "model").or_else(|| find_str(&t, "model"))?;
+
+ // Anthropic reports prompt-cache tokens in fields EXCLUDED from `input_tokens`
+ // (`cache_creation_input_tokens`, `cache_read_input_tokens`). Agent workloads
+ // lean heavily on caching, so omitting these systematically under-meters
+ // input cost. They live in the leading `message_start` usage (SSE) or the
+ // trailing usage (non-stream) — first occurrence in either. OpenAI folds its
+ // cached tokens into `prompt_tokens`, so they stay 0 there.
+ let (cache_write, cache_read) = if secret_type == "anthropic" {
+ (
+ find_uint(&h, "cache_creation_input_tokens", false)
+ .or_else(|| find_uint(&t, "cache_creation_input_tokens", false))
+ .unwrap_or(0),
+ find_uint(&h, "cache_read_input_tokens", false)
+ .or_else(|| find_uint(&t, "cache_read_input_tokens", false))
+ .unwrap_or(0),
+ )
+ } else {
+ (0, 0)
+ };
+
+ match (input, output, cache_write, cache_read) {
+ (None, None, 0, 0) => None,
+ (i, o, _, _) => Some(Usage {
+ input: i.unwrap_or(0),
+ output: o.unwrap_or(0),
+ cache_write,
+ cache_read,
+ model,
+ }),
+ }
+}
+
+/// Price a usage into nano-dollars: `input × input_price + output × output_price`.
+/// Unknown model → 0 + `warn!` once (a fabricated price on a blocking control is
+/// worse than a documented under-meter).
+pub(crate) fn price(secret_type: &str, usage: &Usage) -> i64 {
+ match price_per_token(secret_type, &usage.model) {
+ // Cache-write is 1.25× (×5/4) and cache-read 0.1× (÷10) of base input;
+ // multiply before dividing to keep the integer rounding error sub-token.
+ Some((per_in, per_out)) => {
+ usage.input as i64 * per_in
+ + usage.output as i64 * per_out
+ + (usage.cache_write as i64 * per_in * 5) / 4
+ + (usage.cache_read as i64 * per_in) / 10
+ }
+ None => {
+ warn!(secret_type, model = %usage.model, "budget: no price for model; metering as 0");
+ 0
+ }
+ }
+}
+
+/// `(input_nanos_per_token, output_nanos_per_token)` for the given provider +
+/// model, by longest-prefix match. Prices are nano-dollars/token = USD-per-M ×
+/// 1000. Static curated table; new/unknown models meter as 0 until updated.
+fn price_per_token(secret_type: &str, model: &str) -> Option<(i64, i64)> {
+ // Ordered arbitrarily; longest matching prefix wins so specific rows
+ // (e.g. gpt-4o-mini) beat general ones (gpt-4o, gpt-4).
+ const ANTHROPIC: &[(&str, i64, i64)] = &[
+ ("claude-opus-5", 5_000, 25_000),
+ ("claude-opus-4", 5_000, 25_000),
+ ("claude-opus-3", 15_000, 75_000),
+ ("claude-3-opus", 15_000, 75_000),
+ ("claude-opus", 5_000, 25_000),
+ ("claude-fable-5", 10_000, 50_000),
+ ("claude-sonnet", 3_000, 15_000),
+ ("claude-3-5-sonnet", 3_000, 15_000),
+ ("claude-3-7-sonnet", 3_000, 15_000),
+ ("claude-3-sonnet", 3_000, 15_000),
+ ("claude-haiku-4", 1_000, 5_000),
+ ("claude-3-5-haiku", 800, 4_000),
+ ("claude-3-haiku", 250, 1_250),
+ ("claude-haiku", 1_000, 5_000),
+ ];
+ const OPENAI: &[(&str, i64, i64)] = &[
+ ("gpt-4o-mini", 150, 600),
+ ("gpt-4o", 2_500, 10_000),
+ ("gpt-4.1-mini", 400, 1_600),
+ ("gpt-4.1-nano", 100, 400),
+ ("gpt-4.1", 2_000, 8_000),
+ ("gpt-4-turbo", 10_000, 30_000),
+ ("gpt-4", 30_000, 60_000),
+ ("gpt-3.5-turbo", 500, 1_500),
+ ("o1-mini", 1_100, 4_400),
+ ("o3-mini", 1_100, 4_400),
+ ("o1", 15_000, 60_000),
+ ];
+
+ let table = match secret_type {
+ "anthropic" => ANTHROPIC,
+ "openai" => OPENAI,
+ _ => return None,
+ };
+ table
+ .iter()
+ .filter(|(prefix, _, _)| model.starts_with(prefix))
+ .max_by_key(|(prefix, _, _)| prefix.len())
+ .map(|(_, per_in, per_out)| (*per_in, *per_out))
+}
+
+/// Find the integer following `"key":` in `hay`. `last` picks the final match
+/// (streamed final `message_delta`), otherwise the first (leading usage).
+fn find_uint(hay: &str, key: &str, last: bool) -> Option