From 23d0d00a5ad18a14a6190c8d79d325724baebc8f Mon Sep 17 00:00:00 2001
From: marcorivm
Date: Wed, 29 Jul 2026 16:54:41 -0600
Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 8089e7732e11cb6bcf45b40f19cb33cc555d1551 Mon Sep 17 00:00:00 2001
From: marcorivm
Date: Thu, 30 Jul 2026 06:42:31 -0600
Subject: [PATCH 5/5] feat(gateway): mTLS client-certificate listener (Phase 1)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add a second listener that terminates mutual TLS, requiring a client
certificate signed by a dedicated client CA (distinct from the MITM CA).
After the handshake the stream is handed to the existing connection
handler unchanged; the peer certificate's identity (URI-SAN preferred,
else CN) is extracted and threaded into ProxyContext. Phase 1 logs the
identity only — no enforcement against the agent token yet.
- New src/client_ca.rs: ClientIdentity, identity extraction with
injection-safe sanitization, path-or-inline PEM loading, and
MtlsConfig with fail-closed from_parts/from_env.
- Fail-closed startup: mis-configured mTLS aborts main before DB/crypto
init; never falls back to plaintext.
- SECURITY: client-cert verifier uses the default deny anon policy (no
allow_unauthenticated); GATEWAY_CLIENT_CA is rejected if its SPKI
matches the host-resident MITM CA's public key.
- GATEWAY_PLAIN_BIND fails closed on an unparseable value; accept loop
retries transient errors so one listener can't cancel the other.
- mTLS is off when GATEWAY_MTLS_PORT is unset (backward compatible).
- 40 client_ca tests + mTLS e2e through handle_connection; OSS build,
clippy --all-targets -D warnings, and fmt all clean.
Claude-Session: https://claude.ai/code/session_01BgJuqEJqf7ZiUdqHWxi6bt
---
apps/gateway/Cargo.lock | 105 ++
apps/gateway/Cargo.toml | 3 +
apps/gateway/src/ca.rs | 16 +-
apps/gateway/src/client_ca.rs | 1138 +++++++++++++++++++++
apps/gateway/src/gateway.rs | 577 +++++++++--
apps/gateway/src/gateway/mitm.rs | 4 +-
apps/gateway/src/main.rs | 14 +-
apps/gateway/src/policy_engine/enforce.rs | 3 +-
docker/Dockerfile | 2 +-
docker/docker-compose.yml | 11 +
10 files changed, 1798 insertions(+), 75 deletions(-)
create mode 100644 apps/gateway/src/client_ca.rs
diff --git a/apps/gateway/Cargo.lock b/apps/gateway/Cargo.lock
index 6e62c693..94993dc4 100644
--- a/apps/gateway/Cargo.lock
+++ b/apps/gateway/Cargo.lock
@@ -207,6 +207,45 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "asn1-rs"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048"
+dependencies = [
+ "asn1-rs-derive",
+ "asn1-rs-impl",
+ "displaydoc",
+ "nom",
+ "num-traits",
+ "rusticata-macros",
+ "thiserror 1.0.69",
+ "time",
+]
+
+[[package]]
+name = "asn1-rs-derive"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "synstructure",
+]
+
+[[package]]
+name = "asn1-rs-impl"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "async-lock"
version = "3.4.2"
@@ -1174,6 +1213,20 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "der-parser"
+version = "9.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553"
+dependencies = [
+ "asn1-rs",
+ "displaydoc",
+ "nom",
+ "num-bigint",
+ "num-traits",
+ "rusticata-macros",
+]
+
[[package]]
name = "deranged"
version = "0.5.8"
@@ -2179,6 +2232,12 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+[[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
[[package]]
name = "mio"
version = "1.1.1"
@@ -2245,6 +2304,16 @@ dependencies = [
"tempfile",
]
+[[package]]
+name = "nom"
+version = "7.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
+dependencies = [
+ "memchr",
+ "minimal-lexical",
+]
+
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -2316,6 +2385,15 @@ dependencies = [
"libm",
]
+[[package]]
+name = "oid-registry"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9"
+dependencies = [
+ "asn1-rs",
+]
+
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -2376,6 +2454,7 @@ dependencies = [
"tracing-subscriber",
"uuid",
"webpki-roots 0.26.11",
+ "x509-parser",
]
[[package]]
@@ -2925,6 +3004,15 @@ dependencies = [
"semver",
]
+[[package]]
+name = "rusticata-macros"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
+dependencies = [
+ "nom",
+]
+
[[package]]
name = "rustix"
version = "1.1.4"
@@ -4613,6 +4701,23 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "x509-parser"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69"
+dependencies = [
+ "asn1-rs",
+ "data-encoding",
+ "der-parser",
+ "lazy_static",
+ "nom",
+ "oid-registry",
+ "rusticata-macros",
+ "thiserror 1.0.69",
+ "time",
+]
+
[[package]]
name = "xmlparser"
version = "0.13.6"
diff --git a/apps/gateway/Cargo.toml b/apps/gateway/Cargo.toml
index ba91eeb1..3d2c37c4 100644
--- a/apps/gateway/Cargo.toml
+++ b/apps/gateway/Cargo.toml
@@ -26,6 +26,9 @@ tokio-rustls = "0.26"
rustls = { version = "0.23", features = ["ring"] }
rustls-pemfile = "2"
+# X.509 parsing (mTLS client-certificate identity extraction — CN/URI SANs)
+x509-parser = "0.16"
+
# WebSocket upstream TLS root certificates
webpki-roots = "0.26"
diff --git a/apps/gateway/src/ca.rs b/apps/gateway/src/ca.rs
index 19872003..496a8e67 100644
--- a/apps/gateway/src/ca.rs
+++ b/apps/gateway/src/ca.rs
@@ -125,6 +125,14 @@ impl CertificateAuthority {
der_to_pem(self.ca_cert_der.as_ref())
}
+ /// Return the raw CA certificate DER.
+ /// Used by `client_ca::MtlsConfig::from_env` to reject a `GATEWAY_CLIENT_CA`
+ /// that is (accidentally or maliciously) this same CA — see the SECURITY
+ /// note in `client_ca.rs`.
+ pub(crate) fn ca_cert_der(&self) -> &CertificateDer<'static> {
+ &self.ca_cert_der
+ }
+
/// Load CA from PEM strings (key + certificate).
/// Used when CA is provided via environment variables (cloud mode).
fn load_from_pem(key_pem: &str, cert_pem: &str) -> Result {
@@ -333,9 +341,11 @@ mod tests {
fn ensure_crypto_provider() {
INIT_CRYPTO.call_once(|| {
- rustls::crypto::ring::default_provider()
- .install_default()
- .expect("install CryptoProvider");
+ // Ignore the error: it just means another test module (e.g.
+ // `client_ca`) already installed the process-wide default in this
+ // same test binary — a no-op for our purposes either way, since
+ // it's the same `ring` provider.
+ let _ = rustls::crypto::ring::default_provider().install_default();
});
}
diff --git a/apps/gateway/src/client_ca.rs b/apps/gateway/src/client_ca.rs
new file mode 100644
index 00000000..2800ae57
--- /dev/null
+++ b/apps/gateway/src/client_ca.rs
@@ -0,0 +1,1138 @@
+//! mTLS client-certificate support: config assembly, TLS server config
+//! construction, and identity extraction from the client certificate chain.
+//!
+//! Phase 1 (this module): the gateway can *require* a client certificate on a
+//! dedicated port and *extract* an identity from it (CN / URI SAN), but never
+//! compares that identity to anything — it's threaded onto [`crate::gateway::ProxyContext`]
+//! and logged, nothing more. Phase 2 wires the actual enforcement (comparing
+//! `client_identity` against `agent_token`) and CRL support (see the
+//! `with_crls` hook noted in `build_server_config`).
+//!
+//! mTLS is entirely opt-in: unset `GATEWAY_MTLS_PORT` and this module never
+//! touches a socket. That keeps the OSS build (and every existing deployment)
+//! byte-for-byte backward compatible.
+
+use std::net::{IpAddr, Ipv4Addr};
+use std::sync::Arc;
+
+use anyhow::{bail, Context, Result};
+use rustls::pki_types::{CertificateDer, PrivateKeyDer};
+use rustls::server::WebPkiClientVerifier;
+use rustls::{RootCertStore, ServerConfig};
+
+// ── Identity ─────────────────────────────────────────────────────────────
+
+/// Identity extracted from a client certificate that already passed the TLS
+/// verifier's chain-of-trust and expiry checks.
+///
+/// Phase 1 only threads and logs this value — see the module doc. `primary()`
+/// is the field Phase 2 will compare against the agent token.
+#[derive(Debug, Clone, PartialEq)]
+pub(crate) struct ClientIdentity {
+ pub(crate) cn: Option,
+ pub(crate) uri_sans: Vec,
+ pub(crate) serial_hex: String,
+ pub(crate) not_after_unix: i64,
+}
+
+impl ClientIdentity {
+ /// The identity used for logging (and, in Phase 2, matching): the first
+ /// URI SAN if present — agents are expected to mint `spiffe://`-style
+ /// URIs — else the Common Name.
+ pub(crate) fn primary(&self) -> Option<&str> {
+ self.uri_sans
+ .first()
+ .map(String::as_str)
+ .or(self.cn.as_deref())
+ }
+}
+
+impl std::fmt::Display for ClientIdentity {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str(self.primary().unwrap_or("unknown"))
+ }
+}
+
+/// Extract a [`ClientIdentity`] from the peer certificate chain presented
+/// during the TLS handshake. `certs[0]` is the end-entity leaf — rustls
+/// presents the chain leaf-first — intermediates are ignored.
+///
+/// Never panics: an empty slice, a leaf that fails to parse, or a hostile CN/
+/// SAN (see [`sanitize_identity_component`]) all just fall through to `None`
+/// pieces rather than a panic. The TLS verifier has already rejected chains
+/// that don't verify by the time this runs, so there's nothing to fail closed
+/// on here — Phase 1 doesn't enforce, it only reports what it saw.
+pub(crate) fn identity_from_peer_certs(certs: &[CertificateDer<'_>]) -> Option {
+ let leaf = certs.first()?;
+ let (_, cert) = x509_parser::parse_x509_certificate(leaf.as_ref()).ok()?;
+
+ let cn = cert
+ .subject()
+ .iter_common_name()
+ .next()
+ .and_then(|attr| attr.as_str().ok())
+ .and_then(sanitize_identity_component);
+
+ let uri_sans = cert
+ .subject_alternative_name()
+ .ok()
+ .flatten()
+ .map(|ext| {
+ ext.value
+ .general_names
+ .iter()
+ .filter_map(|name| match name {
+ x509_parser::extensions::GeneralName::URI(uri) => Some(*uri),
+ _ => None,
+ })
+ .filter_map(sanitize_identity_component)
+ .collect()
+ })
+ .unwrap_or_default();
+
+ let serial_hex = hex::encode(cert.raw_serial());
+ let not_after_unix = cert.validity().not_after.timestamp();
+
+ Some(ClientIdentity {
+ cn,
+ uri_sans,
+ serial_hex,
+ not_after_unix,
+ })
+}
+
+/// Validate a single identity component (a CN or a URI SAN) pulled from an
+/// otherwise-trusted certificate. The certificate chains to a trust anchor,
+/// but its *content* is still attacker-controlled (anyone who can get a cert
+/// signed by the configured client CA picks their own CN/SAN) — this becomes
+/// a log field and, in Phase 2, a lookup key, so control characters and
+/// oversized values are dropped rather than "cleaned up": a component that
+/// fails validation contributes nothing rather than a mangled value.
+fn sanitize_identity_component(s: &str) -> Option {
+ if s.is_empty() || s.len() > 253 {
+ return None;
+ }
+ // Printable ASCII only — this also excludes '\n'/'\r' (0x0A/0x0D), which
+ // fall outside 0x20..=0x7E; '"' and '\\' are inside that range and need
+ // an explicit check.
+ if !s.chars().all(|c| matches!(c, '\u{20}'..='\u{7E}')) {
+ return None;
+ }
+ if s.contains('"') || s.contains('\\') {
+ return None;
+ }
+ Some(s.to_string())
+}
+
+// ── PEM / root store loading ─────────────────────────────────────────────
+
+/// Resolve a PEM value from a raw string: a value starting with `-----BEGIN`
+/// is treated as inline PEM (cloud injects CA/cert/key material this way,
+/// from Secrets Manager); anything else is treated as a filesystem path (OSS
+/// mounts files). Empty or unset input is `Ok(None)` — the caller decides
+/// whether that's fatal.
+fn pem_from_value(var_name: &str, value: &str) -> Result> {
+ let trimmed = value.trim();
+ if trimmed.is_empty() {
+ return Ok(None);
+ }
+ if trimmed.starts_with("-----BEGIN") {
+ return Ok(Some(trimmed.to_string()));
+ }
+ std::fs::read_to_string(trimmed)
+ .map(Some)
+ .with_context(|| format!("reading {var_name} from path {trimmed}"))
+}
+
+/// Same as [`pem_from_value`], reading the raw value from the environment.
+#[cfg_attr(not(test), allow(dead_code))]
+fn pem_from_env(var_name: &str) -> Result > {
+ match std::env::var(var_name) {
+ Ok(value) => pem_from_value(var_name, &value),
+ Err(_) => Ok(None),
+ }
+}
+
+/// Parse every certificate out of a PEM bundle. Errors if the PEM is
+/// malformed or contains zero certificates — a client CA file that parses to
+/// nothing is a misconfiguration, not "no CAs trusted".
+fn pem_to_der_certs(pem: &str) -> Result>> {
+ let mut reader = pem.as_bytes();
+ let certs: Vec> = rustls_pemfile::certs(&mut reader)
+ .collect::>()
+ .context("parsing PEM certificate(s)")?;
+ if certs.is_empty() {
+ bail!("no certificates found in PEM");
+ }
+ Ok(certs)
+}
+
+/// Extract the DER-encoded SubjectPublicKeyInfo (SPKI) from a certificate —
+/// "is this the same key", not "is this byte-identical certificate". Used by
+/// the MITM-CA-reuse guard in `from_parts`: a certificate can be re-issued or
+/// re-encoded (different serial, validity window, or DN) while wrapping the
+/// exact same key pair, which a whole-certificate DER comparison would miss.
+fn spki_der(cert_der: &CertificateDer<'_>) -> Result> {
+ let (_, cert) = x509_parser::parse_x509_certificate(cert_der.as_ref())
+ .context("parsing certificate to extract its public key")?;
+ Ok(cert.public_key().raw.to_vec())
+}
+
+/// Build a [`RootCertStore`] from a PEM bundle of one or more CA certificates.
+/// Reused by later phases (e.g. reloading the client CA bundle on rotation).
+pub(crate) fn load_client_ca_roots(pem: &str) -> Result> {
+ let certs = pem_to_der_certs(pem)?;
+ let mut store = RootCertStore::empty();
+ for cert in certs {
+ store
+ .add(cert)
+ .context("adding client CA certificate to root store")?;
+ }
+ Ok(Arc::new(store))
+}
+
+// ── Server config ─────────────────────────────────────────────────────────
+
+/// Build the mTLS `ServerConfig`: the gateway's own cert/key for the TLS
+/// server side, plus `roots` as the trust anchor(s) for verifying client
+/// certificates.
+fn build_server_config(
+ cert_pem: &str,
+ key_pem: &str,
+ roots: Arc,
+) -> Result> {
+ let cert_chain = pem_to_der_certs(cert_pem).context("parsing GATEWAY_TLS_CERT")?;
+
+ let mut key_reader = key_pem.as_bytes();
+ let key: PrivateKeyDer<'static> = rustls_pemfile::private_key(&mut key_reader)
+ .context("parsing GATEWAY_TLS_KEY")?
+ .context("no private key found in GATEWAY_TLS_KEY")?;
+
+ // SECURITY: no `.allow_unauthenticated()` on this builder. The builder's
+ // default policy — reject any handshake that doesn't present a certificate
+ // verifiable against `roots` — IS the "no cert -> rejected" guarantee this
+ // whole module exists to provide. Do not add it, even for a "convenience"
+ // fallback: that would silently reopen the plaintext-equivalent hole this
+ // port is meant to close.
+ let verifier = WebPkiClientVerifier::builder(roots)
+ .build()
+ .context("building client certificate verifier")?;
+
+ let mut config = ServerConfig::builder()
+ .with_client_cert_verifier(verifier)
+ .with_single_cert(cert_chain, key)
+ .context("building mTLS ServerConfig")?;
+
+ // Force HTTP/1.1 — same rationale as the MITM leaf configs (ca.rs):
+ // prevent HTTP/2 negotiation via ALPN, since the gateway's connection
+ // handling assumes HTTP/1.1 semantics (CONNECT interception, upgrades).
+ config.alpn_protocols = vec![b"http/1.1".to_vec()];
+
+ // Phase 2 hook: revocation lists would be wired in here via
+ // `WebPkiClientVerifier::builder(roots).with_crls(...)`. Certificate
+ // expiry is already validated by the webpki verifier itself — no manual
+ // `not_after` check is needed (or added) on top of it.
+
+ Ok(Arc::new(config))
+}
+
+// ── Config ────────────────────────────────────────────────────────────────
+
+/// Resolved mTLS listener configuration. `None` (via [`MtlsConfig::from_env`])
+/// means mTLS is off — the gateway runs exactly as it did before this module
+/// existed.
+#[derive(Debug)]
+pub(crate) struct MtlsConfig {
+ pub(crate) port: u16,
+ pub(crate) bind: IpAddr,
+ pub(crate) server_config: Arc,
+}
+
+impl MtlsConfig {
+ /// Build the mTLS config from already-resolved parts — no environment
+ /// access, so this is the unit-testable core. `from_env` is a thin
+ /// wrapper that reads the four env vars and forwards here.
+ ///
+ /// `port`/`cert`/`key`/`ca` are the raw `GATEWAY_MTLS_PORT` /
+ /// `GATEWAY_TLS_CERT` / `GATEWAY_TLS_KEY` / `GATEWAY_CLIENT_CA` values
+ /// (or `None` if unset) — inline PEM or filesystem path, resolved here via
+ /// [`pem_from_value`]. `mitm_ca_der` is the gateway's own MITM CA
+ /// certificate (see `ca.rs`); `plain_port` is the plaintext listener port.
+ ///
+ /// `Ok(None)` means mTLS is off (port unset). Every other failure mode —
+ /// unparseable/zero/colliding port, missing material, unreadable/garbage
+ /// PEM, or a client CA that IS the MITM CA — is `Err`, and the caller
+ /// (`main`) must fail closed: never fall back to plaintext-only when mTLS
+ /// was requested but couldn't be built.
+ pub(crate) fn from_parts(
+ port: Option<&str>,
+ cert: Option<&str>,
+ key: Option<&str>,
+ ca: Option<&str>,
+ mitm_ca_der: &CertificateDer<'static>,
+ plain_port: u16,
+ ) -> Result> {
+ let Some(port_str) = port else {
+ // GATEWAY_MTLS_PORT unset: mTLS is off. Full backward compatibility.
+ return Ok(None);
+ };
+
+ let port: u16 = port_str.parse().with_context(|| {
+ format!("GATEWAY_MTLS_PORT {port_str:?} is not a valid port number")
+ })?;
+ if port == 0 {
+ bail!("GATEWAY_MTLS_PORT must not be 0");
+ }
+ if port == plain_port {
+ bail!(
+ "GATEWAY_MTLS_PORT ({port}) must differ from the plaintext gateway port ({plain_port})"
+ );
+ }
+
+ let cert_pem = cert
+ .context("GATEWAY_TLS_CERT is required when GATEWAY_MTLS_PORT is set")
+ .and_then(|v| pem_from_value("GATEWAY_TLS_CERT", v))?
+ .context("GATEWAY_TLS_CERT is required when GATEWAY_MTLS_PORT is set")?;
+ let key_pem = key
+ .context("GATEWAY_TLS_KEY is required when GATEWAY_MTLS_PORT is set")
+ .and_then(|v| pem_from_value("GATEWAY_TLS_KEY", v))?
+ .context("GATEWAY_TLS_KEY is required when GATEWAY_MTLS_PORT is set")?;
+ let ca_pem = ca
+ .context("GATEWAY_CLIENT_CA is required when GATEWAY_MTLS_PORT is set")
+ .and_then(|v| pem_from_value("GATEWAY_CLIENT_CA", v))?
+ .context("GATEWAY_CLIENT_CA is required when GATEWAY_MTLS_PORT is set")?;
+
+ // SECURITY: reject a client CA bundle that carries the same public
+ // key as the gateway's own MITM CA. Compared on the DER-encoded
+ // SubjectPublicKeyInfo (SPKI), not the whole-certificate DER: the real
+ // risk is the MITM CA's *private key* being host-resident (it signs a
+ // fresh leaf for every intercepted domain), and a re-issued or
+ // re-encoded certificate wrapping that SAME key would byte-differ
+ // from the original cert while remaining exactly as dangerous to
+ // trust — a whole-cert comparison would miss it. If ANY certificate
+ // carrying that key were trusted as a client-cert anchor, anyone able
+ // to mint a MITM leaf could just as easily mint a "valid" client cert
+ // and impersonate any agent.
+ let mitm_spki =
+ spki_der(mitm_ca_der).context("parsing the gateway's own MITM CA certificate")?;
+ let ca_certs = pem_to_der_certs(&ca_pem).context("parsing GATEWAY_CLIENT_CA")?;
+ for cert in &ca_certs {
+ let spki = spki_der(cert)
+ .context("GATEWAY_CLIENT_CA contains a certificate that failed to parse")?;
+ if spki == mitm_spki {
+ bail!(
+ "GATEWAY_CLIENT_CA must not include a certificate carrying the same public \
+ key as the gateway's own MITM CA (its private key lives on this host, so \
+ trusting that key as a client anchor would let anyone mint their own \
+ client certificate)"
+ );
+ }
+ }
+
+ let roots = load_client_ca_roots(&ca_pem).context("GATEWAY_CLIENT_CA")?;
+ let server_config = build_server_config(&cert_pem, &key_pem, roots)?;
+
+ Ok(Some(MtlsConfig {
+ port,
+ bind: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
+ server_config,
+ }))
+ }
+
+ /// Read `GATEWAY_MTLS_PORT` / `GATEWAY_TLS_CERT` / `GATEWAY_TLS_KEY` /
+ /// `GATEWAY_CLIENT_CA` from the environment and forward to [`Self::from_parts`].
+ pub(crate) fn from_env(
+ mitm_ca_der: &CertificateDer<'static>,
+ plain_port: u16,
+ ) -> Result > {
+ let port = std::env::var("GATEWAY_MTLS_PORT").ok();
+ let cert = std::env::var("GATEWAY_TLS_CERT").ok();
+ let key = std::env::var("GATEWAY_TLS_KEY").ok();
+ let ca = std::env::var("GATEWAY_CLIENT_CA").ok();
+ Self::from_parts(
+ port.as_deref(),
+ cert.as_deref(),
+ key.as_deref(),
+ ca.as_deref(),
+ mitm_ca_der,
+ plain_port,
+ )
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::io::Write;
+ use std::net::SocketAddr;
+ use std::sync::Once;
+ use std::time::{SystemTime, UNIX_EPOCH};
+
+ use rcgen::{
+ BasicConstraints, CertificateParams, DnType, IsCa, KeyPair, KeyUsagePurpose,
+ PKCS_ECDSA_P256_SHA256,
+ };
+ use rustls::pki_types::ServerName;
+ use time::OffsetDateTime;
+ use tokio::net::{TcpListener, TcpStream};
+ use tokio_rustls::{TlsAcceptor, TlsConnector};
+
+ static INIT_CRYPTO: Once = Once::new();
+
+ fn ensure_crypto_provider() {
+ INIT_CRYPTO.call_once(|| {
+ // Ignore the error: it just means another test module (e.g.
+ // `ca`) already installed the process-wide default in this same
+ // test binary — a no-op for our purposes either way, since it's
+ // the same `ring` provider.
+ let _ = rustls::crypto::ring::default_provider().install_default();
+ });
+ }
+
+ // ── sanitize_identity_component ─────────────────────────────────────
+
+ #[test]
+ fn sanitize_accepts_plain_values() {
+ assert_eq!(
+ sanitize_identity_component("agent-42"),
+ Some("agent-42".to_string())
+ );
+ assert_eq!(
+ sanitize_identity_component("spiffe://onecli/agent/42"),
+ Some("spiffe://onecli/agent/42".to_string())
+ );
+ }
+
+ #[test]
+ fn sanitize_drops_empty() {
+ assert_eq!(sanitize_identity_component(""), None);
+ }
+
+ #[test]
+ fn sanitize_drops_oversized() {
+ let long = "a".repeat(254);
+ assert_eq!(sanitize_identity_component(&long), None);
+ // 253 bytes is the boundary — still accepted.
+ let boundary = "a".repeat(253);
+ assert!(sanitize_identity_component(&boundary).is_some());
+ }
+
+ #[test]
+ fn sanitize_drops_newline_and_cr() {
+ assert_eq!(sanitize_identity_component("agent\n42"), None);
+ assert_eq!(sanitize_identity_component("agent\r42"), None);
+ }
+
+ #[test]
+ fn sanitize_drops_quote_and_backslash() {
+ assert_eq!(sanitize_identity_component("agent\"42"), None);
+ assert_eq!(sanitize_identity_component("agent\\42"), None);
+ }
+
+ #[test]
+ fn sanitize_drops_non_ascii() {
+ assert_eq!(sanitize_identity_component("agenté"), None);
+ }
+
+ // ── ClientIdentity::primary ──────────────────────────────────────────
+
+ #[test]
+ fn primary_prefers_uri_san_over_cn() {
+ let id = ClientIdentity {
+ cn: Some("fallback-cn".to_string()),
+ uri_sans: vec!["spiffe://onecli/agent/1".to_string()],
+ serial_hex: "ab".to_string(),
+ not_after_unix: 0,
+ };
+ assert_eq!(id.primary(), Some("spiffe://onecli/agent/1"));
+ }
+
+ #[test]
+ fn primary_falls_back_to_cn() {
+ let id = ClientIdentity {
+ cn: Some("cn-only".to_string()),
+ uri_sans: vec![],
+ serial_hex: "ab".to_string(),
+ not_after_unix: 0,
+ };
+ assert_eq!(id.primary(), Some("cn-only"));
+ }
+
+ #[test]
+ fn primary_none_when_both_missing() {
+ let id = ClientIdentity {
+ cn: None,
+ uri_sans: vec![],
+ serial_hex: "ab".to_string(),
+ not_after_unix: 0,
+ };
+ assert_eq!(id.primary(), None);
+ }
+
+ #[test]
+ fn display_uses_primary() {
+ let id = ClientIdentity {
+ cn: Some("cn-only".to_string()),
+ uri_sans: vec![],
+ serial_hex: "ab".to_string(),
+ not_after_unix: 0,
+ };
+ assert_eq!(id.to_string(), "cn-only");
+ }
+
+ // ── identity_from_peer_certs: empty/malformed input never panics ────
+
+ #[test]
+ fn identity_from_empty_slice_is_none() {
+ assert_eq!(identity_from_peer_certs(&[]), None);
+ }
+
+ #[test]
+ fn identity_from_garbage_der_is_none() {
+ let garbage = CertificateDer::from(vec![0u8, 1, 2, 3, 4]);
+ assert_eq!(
+ identity_from_peer_certs(std::slice::from_ref(&garbage)),
+ None
+ );
+ }
+
+ // ── test PKI helper ───────────────────────────────────────────────────
+
+ /// A minimal CA + leaf-signing helper built on rcgen, mirroring the
+ /// pattern in `ca.rs`'s own test module.
+ struct TestCa {
+ cert: rcgen::Certificate,
+ key: KeyPair,
+ der: CertificateDer<'static>,
+ }
+
+ fn new_test_ca(cn: &str) -> TestCa {
+ let key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).expect("CA key");
+ let mut params = CertificateParams::default();
+ params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
+ params.distinguished_name.push(DnType::CommonName, cn);
+ params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
+ params.not_before = OffsetDateTime::now_utc() - time::Duration::hours(1);
+ params.not_after = OffsetDateTime::now_utc() + time::Duration::days(3650);
+ let cert = params.self_signed(&key).expect("self-sign CA");
+ let der = cert.der().clone();
+ TestCa { cert, key, der }
+ }
+
+ /// Sign a client leaf under `ca`, valid `[not_before_h, not_after_h]` hours
+ /// from now, with the given CN and URI SANs. Returns (cert_pem, key_pem).
+ fn sign_client_leaf(
+ ca: &TestCa,
+ cn: Option<&str>,
+ uri_sans: &[&str],
+ not_before_h: i64,
+ not_after_h: i64,
+ ) -> (String, String) {
+ let leaf_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).expect("leaf key");
+ let mut params = CertificateParams::default();
+ // `CertificateParams::new(strings)` only ever infers IP or DNS SANs —
+ // a "spiffe://..."-shaped string comes out as a (nonsensical) DNS
+ // name, not a URI SAN. Push `SanType::URI` directly instead.
+ params.subject_alt_names = uri_sans
+ .iter()
+ .map(|s| {
+ rcgen::SanType::URI(
+ rcgen::Ia5String::try_from(s.to_string()).expect("valid IA5 URI"),
+ )
+ })
+ .collect();
+ if let Some(cn) = cn {
+ params.distinguished_name.push(DnType::CommonName, cn);
+ }
+ params.key_usages = vec![KeyUsagePurpose::DigitalSignature];
+ params.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::ClientAuth];
+ params.not_before = OffsetDateTime::now_utc() + time::Duration::hours(not_before_h);
+ params.not_after = OffsetDateTime::now_utc() + time::Duration::hours(not_after_h);
+ let leaf_cert = params
+ .signed_by(&leaf_key, &ca.cert, &ca.key)
+ .expect("sign leaf");
+ (leaf_cert.pem(), leaf_key.serialize_pem())
+ }
+
+ /// Self-signed "server" cert for `localhost`, used as the mTLS listener's
+ /// own identity in handshake tests. The test client trusts it directly
+ /// (it's its own root), sidestepping the need for a fake server verifier.
+ fn self_signed_server_cert() -> (String, String, CertificateDer<'static>) {
+ let key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).expect("server key");
+ let mut params = CertificateParams::new(vec!["localhost".to_string()]).expect("params");
+ params
+ .distinguished_name
+ .push(DnType::CommonName, "localhost");
+ params.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::ServerAuth];
+ params.not_before = OffsetDateTime::now_utc() - time::Duration::hours(1);
+ params.not_after = OffsetDateTime::now_utc() + time::Duration::days(1);
+ let cert = params.self_signed(&key).expect("self-sign server cert");
+ let der = cert.der().clone();
+ (cert.pem(), key.serialize_pem(), der)
+ }
+
+ /// Build a server config AND return the matching server cert DER — the
+ /// two must come from the same `self_signed_server_cert()` call, since
+ /// the test client trusts that DER directly as its only root.
+ fn test_server_setup(trusted_ca_pem: &str) -> (Arc, CertificateDer<'static>) {
+ let (server_cert_pem, server_key_pem, server_der) = self_signed_server_cert();
+ let roots = load_client_ca_roots(trusted_ca_pem).expect("roots");
+ let config =
+ build_server_config(&server_cert_pem, &server_key_pem, roots).expect("server config");
+ (config, server_der)
+ }
+
+ fn test_client_config(
+ server_der: &CertificateDer<'static>,
+ client_cert_pem: Option<&str>,
+ client_key_pem: Option<&str>,
+ ) -> Arc {
+ let mut roots = RootCertStore::empty();
+ roots.add(server_der.clone()).expect("trust server cert");
+
+ let builder = rustls::ClientConfig::builder().with_root_certificates(roots);
+ let config = match (client_cert_pem, client_key_pem) {
+ (Some(cert_pem), Some(key_pem)) => {
+ let chain = pem_to_der_certs(cert_pem).expect("client cert chain");
+ let mut key_reader = key_pem.as_bytes();
+ let key = rustls_pemfile::private_key(&mut key_reader)
+ .expect("parse client key")
+ .expect("client key present");
+ builder
+ .with_client_auth_cert(chain, key)
+ .expect("client auth cert")
+ }
+ _ => builder.with_no_client_auth(),
+ };
+ Arc::new(config)
+ }
+
+ /// Run one TLS handshake end to end over a real loopback socket (same
+ /// pattern as the plain-TCP tests in `gateway.rs`/`ca.rs`, just over TLS).
+ /// Returns the server-side accept result — the thing under test — and
+ /// discards the client-side result beyond confirming it also failed when
+ /// the server did (a rejected handshake fails both sides).
+ async fn attempt_handshake(
+ server_config: Arc,
+ client_config: Arc,
+ ) -> std::io::Result> {
+ let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
+ let addr: SocketAddr = listener.local_addr().expect("local addr");
+
+ let server = async move {
+ let (stream, _) = listener.accept().await?;
+ TlsAcceptor::from(server_config).accept(stream).await
+ };
+ let client = async move {
+ let stream = TcpStream::connect(addr).await?;
+ let name = ServerName::try_from("localhost").expect("server name");
+ TlsConnector::from(client_config)
+ .connect(name, stream)
+ .await
+ };
+
+ let (server_result, _client_result) = tokio::join!(server, client);
+ server_result
+ }
+
+ fn err_debug_contains(err: &std::io::Error, needle: &str) -> bool {
+ format!("{err:?}").contains(needle)
+ }
+
+ // ── Handshake behavior ────────────────────────────────────────────────
+
+ #[tokio::test]
+ async fn handshake_rejects_missing_client_cert() {
+ ensure_crypto_provider();
+ let ca = new_test_ca("Test Client CA");
+ let ca_pem = ca.cert.pem();
+ let (server_config, server_der) = test_server_setup(&ca_pem);
+ let client_config = test_client_config(&server_der, None, None);
+
+ let result = attempt_handshake(server_config, client_config).await;
+ let err = result.expect_err("handshake without a client cert must fail");
+ assert!(
+ err_debug_contains(&err, "NoCertificatesPresented")
+ || err_debug_contains(&err, "CertificateRequired"),
+ "unexpected error: {err:?}"
+ );
+ }
+
+ #[tokio::test]
+ async fn handshake_rejects_wrong_ca() {
+ ensure_crypto_provider();
+ let trusted_ca = new_test_ca("Trusted Client CA");
+ let other_ca = new_test_ca("Some Other CA");
+ let (cert_pem, key_pem) = sign_client_leaf(&other_ca, Some("agent-1"), &[], -1, 24);
+
+ let (server_config, server_der) = test_server_setup(&trusted_ca.cert.pem());
+ let client_config = test_client_config(&server_der, Some(&cert_pem), Some(&key_pem));
+
+ let result = attempt_handshake(server_config, client_config).await;
+ let err = result.expect_err("handshake signed by an untrusted CA must fail");
+ assert!(
+ err_debug_contains(&err, "UnknownIssuer"),
+ "unexpected error: {err:?}"
+ );
+ }
+
+ #[tokio::test]
+ async fn handshake_rejects_expired_cert() {
+ ensure_crypto_provider();
+ let ca = new_test_ca("Trusted Client CA");
+ // Valid window entirely in the past.
+ let (cert_pem, key_pem) = sign_client_leaf(&ca, Some("agent-1"), &[], -48, -24);
+
+ let (server_config, server_der) = test_server_setup(&ca.cert.pem());
+ let client_config = test_client_config(&server_der, Some(&cert_pem), Some(&key_pem));
+
+ let result = attempt_handshake(server_config, client_config).await;
+ let err = result.expect_err("handshake with an expired client cert must fail");
+ assert!(
+ err_debug_contains(&err, "Expired"),
+ "unexpected error: {err:?}"
+ );
+ }
+
+ #[tokio::test]
+ async fn handshake_accepts_valid_cert_with_uri_san() {
+ ensure_crypto_provider();
+ let ca = new_test_ca("Trusted Client CA");
+ let (cert_pem, key_pem) = sign_client_leaf(
+ &ca,
+ Some("fallback-cn"),
+ &["spiffe://onecli/agent/1"],
+ -1,
+ 24,
+ );
+
+ let (server_config, server_der) = test_server_setup(&ca.cert.pem());
+ let client_config = test_client_config(&server_der, Some(&cert_pem), Some(&key_pem));
+
+ let mut tls_stream = attempt_handshake(server_config, client_config)
+ .await
+ .expect("valid client cert must be accepted");
+
+ let identity = tls_stream
+ .get_ref()
+ .1
+ .peer_certificates()
+ .and_then(identity_from_peer_certs)
+ .expect("identity must be extracted");
+ assert_eq!(identity.primary(), Some("spiffe://onecli/agent/1"));
+ assert_eq!(identity.cn.as_deref(), Some("fallback-cn"));
+
+ // Drain so the client side's write half doesn't hang the test.
+ use tokio::io::AsyncWriteExt;
+ let _ = tls_stream.shutdown().await;
+ }
+
+ #[tokio::test]
+ async fn handshake_accepts_valid_cert_cn_only() {
+ ensure_crypto_provider();
+ let ca = new_test_ca("Trusted Client CA");
+ let (cert_pem, key_pem) = sign_client_leaf(&ca, Some("cn-only-agent"), &[], -1, 24);
+
+ let (server_config, server_der) = test_server_setup(&ca.cert.pem());
+ let client_config = test_client_config(&server_der, Some(&cert_pem), Some(&key_pem));
+
+ let mut tls_stream = attempt_handshake(server_config, client_config)
+ .await
+ .expect("valid client cert must be accepted");
+
+ let identity = tls_stream
+ .get_ref()
+ .1
+ .peer_certificates()
+ .and_then(identity_from_peer_certs)
+ .expect("identity must be extracted");
+ assert_eq!(identity.primary(), Some("cn-only-agent"));
+ assert!(identity.uri_sans.is_empty());
+
+ use tokio::io::AsyncWriteExt;
+ let _ = tls_stream.shutdown().await;
+ }
+
+ #[tokio::test]
+ async fn server_config_pins_http11_alpn() {
+ ensure_crypto_provider();
+ let ca = new_test_ca("Trusted Client CA");
+ let (config, _server_der) = test_server_setup(&ca.cert.pem());
+ assert_eq!(config.alpn_protocols, vec![b"http/1.1".to_vec()]);
+ }
+
+ // ── from_parts: no env access ────────────────────────────────────────
+
+ fn dummy_mitm_ca_der() -> CertificateDer<'static> {
+ new_test_ca("Dummy MITM CA").der
+ }
+
+ #[test]
+ fn from_parts_port_none_is_off() {
+ ensure_crypto_provider();
+ let mitm_der = dummy_mitm_ca_der();
+ let result = MtlsConfig::from_parts(None, None, None, None, &mitm_der, 10255).unwrap();
+ assert!(result.is_none());
+ }
+
+ /// Stands in for "this field is present" in `from_parts` tests that only
+ /// care about a *different* field. Must start with `-----BEGIN` so
+ /// `pem_from_value` takes the inline-PEM branch rather than trying (and
+ /// failing) to read it as a filesystem path — its content is never
+ /// actually parsed in these tests, since the function under test returns
+ /// before reaching that point.
+ const PRESENT_PLACEHOLDER_PEM: &str =
+ "-----BEGIN CERTIFICATE-----\nplaceholder\n-----END CERTIFICATE-----\n";
+
+ #[test]
+ fn from_parts_missing_cert_errs_naming_it() {
+ ensure_crypto_provider();
+ let mitm_der = dummy_mitm_ca_der();
+ let err = MtlsConfig::from_parts(
+ Some("10256"),
+ None,
+ Some("key"),
+ Some("ca"),
+ &mitm_der,
+ 10255,
+ )
+ .unwrap_err();
+ assert!(format!("{err:#}").contains("GATEWAY_TLS_CERT"));
+ }
+
+ #[test]
+ fn from_parts_missing_key_errs_naming_it() {
+ ensure_crypto_provider();
+ let mitm_der = dummy_mitm_ca_der();
+ let err = MtlsConfig::from_parts(
+ Some("10256"),
+ Some(PRESENT_PLACEHOLDER_PEM),
+ None,
+ Some(PRESENT_PLACEHOLDER_PEM),
+ &mitm_der,
+ 10255,
+ )
+ .unwrap_err();
+ assert!(format!("{err:#}").contains("GATEWAY_TLS_KEY"));
+ }
+
+ #[test]
+ fn from_parts_missing_ca_errs_naming_it() {
+ ensure_crypto_provider();
+ let mitm_der = dummy_mitm_ca_der();
+ let err = MtlsConfig::from_parts(
+ Some("10256"),
+ Some(PRESENT_PLACEHOLDER_PEM),
+ Some(PRESENT_PLACEHOLDER_PEM),
+ None,
+ &mitm_der,
+ 10255,
+ )
+ .unwrap_err();
+ assert!(format!("{err:#}").contains("GATEWAY_CLIENT_CA"));
+ }
+
+ #[test]
+ fn from_parts_zero_port_errs() {
+ ensure_crypto_provider();
+ let mitm_der = dummy_mitm_ca_der();
+ let err =
+ MtlsConfig::from_parts(Some("0"), Some("c"), Some("k"), Some("a"), &mitm_der, 10255)
+ .unwrap_err();
+ assert!(format!("{err:#}").contains("must not be 0"));
+ }
+
+ #[test]
+ fn from_parts_garbage_port_errs() {
+ ensure_crypto_provider();
+ let mitm_der = dummy_mitm_ca_der();
+ let err = MtlsConfig::from_parts(
+ Some("not-a-port"),
+ Some("c"),
+ Some("k"),
+ Some("a"),
+ &mitm_der,
+ 10255,
+ )
+ .unwrap_err();
+ assert!(format!("{err:#}").contains("not a valid port"));
+ }
+
+ #[test]
+ fn from_parts_port_equals_plain_port_errs() {
+ ensure_crypto_provider();
+ let mitm_der = dummy_mitm_ca_der();
+ let err = MtlsConfig::from_parts(
+ Some("10255"),
+ Some("c"),
+ Some("k"),
+ Some("a"),
+ &mitm_der,
+ 10255,
+ )
+ .unwrap_err();
+ assert!(format!("{err:#}").contains("must differ"));
+ }
+
+ #[test]
+ fn from_parts_loads_inline_pem_and_path_forms() {
+ ensure_crypto_provider();
+ let ca = new_test_ca("Trusted Client CA");
+ let (server_cert_pem, server_key_pem, _) = self_signed_server_cert();
+ let mitm_der = dummy_mitm_ca_der();
+ let ca_pem = ca.cert.pem();
+
+ // Inline PEM form for all three.
+ let result = MtlsConfig::from_parts(
+ Some("10256"),
+ Some(&server_cert_pem),
+ Some(&server_key_pem),
+ Some(&ca_pem),
+ &mitm_der,
+ 10255,
+ )
+ .expect("inline PEM must load");
+ assert!(result.is_some());
+
+ // Path form: write each to a tempfile and pass the path.
+ let dir = tempfile::tempdir().expect("tempdir");
+ let cert_path = dir.path().join("cert.pem");
+ let key_path = dir.path().join("key.pem");
+ let ca_path = dir.path().join("ca.pem");
+ std::fs::write(&cert_path, &server_cert_pem).expect("write cert");
+ std::fs::write(&key_path, &server_key_pem).expect("write key");
+ std::fs::write(&ca_path, &ca_pem).expect("write ca");
+
+ let result = MtlsConfig::from_parts(
+ Some("10256"),
+ Some(cert_path.to_str().unwrap()),
+ Some(key_path.to_str().unwrap()),
+ Some(ca_path.to_str().unwrap()),
+ &mitm_der,
+ 10255,
+ )
+ .expect("path form must load");
+ assert!(result.is_some());
+ }
+
+ #[test]
+ fn from_parts_bad_path_errs() {
+ ensure_crypto_provider();
+ let mitm_der = dummy_mitm_ca_der();
+ let err = MtlsConfig::from_parts(
+ Some("10256"),
+ Some("/nonexistent/path/cert.pem"),
+ Some("/nonexistent/path/key.pem"),
+ Some("/nonexistent/path/ca.pem"),
+ &mitm_der,
+ 10255,
+ )
+ .unwrap_err();
+ assert!(format!("{err:#}").contains("GATEWAY_TLS_CERT"));
+ }
+
+ #[test]
+ fn from_parts_garbage_pem_errs() {
+ ensure_crypto_provider();
+ let mitm_der = dummy_mitm_ca_der();
+ let (server_cert_pem, server_key_pem, _) = self_signed_server_cert();
+ let err = MtlsConfig::from_parts(
+ Some("10256"),
+ Some(&server_cert_pem),
+ Some(&server_key_pem),
+ Some("-----BEGIN CERTIFICATE-----\nbm90IGEgY2VydA==\n-----END CERTIFICATE-----\n"),
+ &mitm_der,
+ 10255,
+ )
+ .unwrap_err();
+ assert!(format!("{err:#}").contains("GATEWAY_CLIENT_CA"));
+ }
+
+ #[test]
+ fn from_parts_empty_client_ca_errs() {
+ ensure_crypto_provider();
+ let mitm_der = dummy_mitm_ca_der();
+ let (server_cert_pem, server_key_pem, _) = self_signed_server_cert();
+ // Empty value resolves to None via pem_from_value, which is then the
+ // "missing" case for a mandatory var.
+ let err = MtlsConfig::from_parts(
+ Some("10256"),
+ Some(&server_cert_pem),
+ Some(&server_key_pem),
+ Some(""),
+ &mitm_der,
+ 10255,
+ )
+ .unwrap_err();
+ assert!(format!("{err:#}").contains("GATEWAY_CLIENT_CA"));
+ }
+
+ #[test]
+ fn from_parts_rejects_client_ca_matching_mitm_ca() {
+ ensure_crypto_provider();
+ let mitm_ca = new_test_ca("Gateway MITM CA");
+ let (server_cert_pem, server_key_pem, _) = self_signed_server_cert();
+
+ // GATEWAY_CLIENT_CA is (accidentally) the same cert as the MITM CA.
+ let err = MtlsConfig::from_parts(
+ Some("10256"),
+ Some(&server_cert_pem),
+ Some(&server_key_pem),
+ Some(&mitm_ca.cert.pem()),
+ &mitm_ca.der,
+ 10255,
+ )
+ .unwrap_err();
+ assert!(format!("{err:#}").contains("MITM CA"));
+ }
+
+ /// FIX 3: the guard compares public keys (SPKI), not whole-certificate
+ /// DER — a certificate carrying the SAME key as the MITM CA must still be
+ /// rejected even though it's a byte-different certificate (different CN,
+ /// serial, and validity window — e.g. a re-issued or re-encoded cert).
+ #[test]
+ fn from_parts_rejects_client_ca_with_same_public_key_as_mitm_ca_even_if_cert_differs() {
+ ensure_crypto_provider();
+
+ let mitm_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).expect("mitm key");
+ let mut mitm_params = CertificateParams::default();
+ mitm_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
+ mitm_params
+ .distinguished_name
+ .push(DnType::CommonName, "Gateway MITM CA");
+ mitm_params.not_before = OffsetDateTime::now_utc() - time::Duration::hours(1);
+ mitm_params.not_after = OffsetDateTime::now_utc() + time::Duration::days(3650);
+ let mitm_cert = mitm_params.self_signed(&mitm_key).expect("self-sign mitm");
+ let mitm_der = mitm_cert.der().clone();
+
+ // A DIFFERENT certificate — different CN, serial, and validity window
+ // (as a re-issued cert would be) — but signed with the SAME key pair.
+ let mut reissued_params = CertificateParams::default();
+ reissued_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
+ reissued_params
+ .distinguished_name
+ .push(DnType::CommonName, "Totally Unrelated Client CA");
+ reissued_params.not_before = OffsetDateTime::now_utc() - time::Duration::hours(2);
+ reissued_params.not_after = OffsetDateTime::now_utc() + time::Duration::days(30);
+ let reissued_cert = reissued_params
+ .self_signed(&mitm_key)
+ .expect("self-sign reissued cert with the same key");
+
+ // Sanity check: the two certs must NOT be byte-identical — otherwise
+ // this test would exercise the same path as the whole-DER case above
+ // and prove nothing new.
+ assert_ne!(reissued_cert.der().as_ref(), mitm_der.as_ref());
+
+ let (server_cert_pem, server_key_pem, _) = self_signed_server_cert();
+ let err = MtlsConfig::from_parts(
+ Some("10256"),
+ Some(&server_cert_pem),
+ Some(&server_key_pem),
+ Some(&reissued_cert.pem()),
+ &mitm_der,
+ 10255,
+ )
+ .unwrap_err();
+ assert!(format!("{err:#}").contains("GATEWAY_CLIENT_CA"));
+ assert!(format!("{err:#}").contains("public"));
+ }
+
+ // ── pem_from_env / pem_from_value ────────────────────────────────────
+
+ #[test]
+ fn pem_from_value_empty_is_none() {
+ assert_eq!(pem_from_value("X", "").unwrap(), None);
+ assert_eq!(pem_from_value("X", " ").unwrap(), None);
+ }
+
+ #[test]
+ fn pem_from_value_inline_pem_passthrough() {
+ // Leading/trailing whitespace around the value is trimmed (matching
+ // ca.rs's `load_from_pem`), so assert against the trimmed form.
+ let pem = "-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----\n";
+ assert_eq!(
+ pem_from_value("X", pem).unwrap(),
+ Some(pem.trim().to_string())
+ );
+ }
+
+ #[test]
+ fn pem_from_value_reads_path() {
+ let mut file = tempfile::NamedTempFile::new().expect("tempfile");
+ write!(file, "file-contents").expect("write");
+ let path = file.path().to_str().unwrap();
+ assert_eq!(
+ pem_from_value("X", path).unwrap(),
+ Some("file-contents".to_string())
+ );
+ }
+
+ #[test]
+ fn pem_from_value_bad_path_errs() {
+ let err = pem_from_value("GATEWAY_TLS_CERT", "/no/such/file.pem").unwrap_err();
+ assert!(format!("{err:#}").contains("GATEWAY_TLS_CERT"));
+ assert!(format!("{err:#}").contains("/no/such/file.pem"));
+ }
+
+ #[test]
+ fn pem_from_env_unset_is_none() {
+ // A var name essentially guaranteed not to be set.
+ assert_eq!(
+ pem_from_env("GATEWAY_CA_TEST_DOES_NOT_EXIST_XYZ").unwrap(),
+ None
+ );
+ }
+
+ // ── load_client_ca_roots ──────────────────────────────────────────────
+
+ #[test]
+ fn load_client_ca_roots_empty_pem_errs() {
+ assert!(load_client_ca_roots("").is_err());
+ }
+
+ #[test]
+ fn load_client_ca_roots_garbage_errs() {
+ assert!(load_client_ca_roots("not pem at all").is_err());
+ }
+
+ #[test]
+ fn load_client_ca_roots_valid_pem_ok() {
+ let ca = new_test_ca("Trusted Client CA");
+ assert!(load_client_ca_roots(&ca.cert.pem()).is_ok());
+ }
+
+ // Sanity: not_after_unix reflects the certificate's actual expiry, so a
+ // "certificate expires in ~1 day" leaf really does report a timestamp
+ // roughly a day in the future (the verifier — not this field — is what
+ // rejects expired certs; this just confirms the value is meaningful for
+ // Phase 2 to eventually build on).
+ #[tokio::test]
+ async fn identity_not_after_matches_leaf_validity() {
+ ensure_crypto_provider();
+ let ca = new_test_ca("Trusted Client CA");
+ let (cert_pem, key_pem) = sign_client_leaf(&ca, Some("agent-1"), &[], -1, 24);
+ let (server_config, server_der) = test_server_setup(&ca.cert.pem());
+ let client_config = test_client_config(&server_der, Some(&cert_pem), Some(&key_pem));
+
+ let mut tls_stream = attempt_handshake(server_config, client_config)
+ .await
+ .expect("valid cert accepted");
+ let identity = tls_stream
+ .get_ref()
+ .1
+ .peer_certificates()
+ .and_then(identity_from_peer_certs)
+ .expect("identity extracted");
+
+ let now = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap()
+ .as_secs() as i64;
+ // Leaf expires ~24h from now; allow generous slack for test runtime.
+ assert!(identity.not_after_unix > now);
+ assert!(identity.not_after_unix < now + 25 * 3600);
+
+ use tokio::io::AsyncWriteExt;
+ let _ = tls_stream.shutdown().await;
+ }
+}
diff --git a/apps/gateway/src/gateway.rs b/apps/gateway/src/gateway.rs
index d4d83fd3..e5bbf76e 100644
--- a/apps/gateway/src/gateway.rs
+++ b/apps/gateway/src/gateway.rs
@@ -36,8 +36,9 @@ mod transforms;
mod tunnel;
mod websocket;
-use std::net::SocketAddr;
+use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::sync::Arc;
+use std::time::Duration;
use anyhow::{Context, Result};
use axum::extract::State;
@@ -47,8 +48,10 @@ use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper::{Method, Request, Response, StatusCode};
use hyper_util::rt::TokioIo;
-use tokio::net::{TcpListener, TcpStream};
-use tokio_rustls::TlsConnector;
+use tokio::io::{AsyncRead, AsyncWrite};
+use tokio::net::TcpListener;
+use tokio::time::timeout;
+use tokio_rustls::{TlsAcceptor, TlsConnector};
use tower::ServiceExt;
use tower_http::cors::CorsLayer;
use tracing::{debug, info, info_span, warn, Instrument};
@@ -57,20 +60,29 @@ use crate::approval::{ApprovalDecision, ApprovalStore, APPROVAL_TIMEOUT_SECS};
use crate::auth::AuthUser;
use crate::ca::CertificateAuthority;
use crate::cache::CacheStore;
+use crate::client_ca::{self, ClientIdentity, MtlsConfig};
use crate::connect::{self, AppConnectionResult, ConnectError, PolicyEngine};
use crate::db;
use crate::inject;
use crate::vault;
-/// Pause before retrying a failed `accept`, so a persistent error (a truly
-/// exhausted fd table) cannot spin the loop at full tilt.
-const ACCEPT_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(100);
+/// Cap the client TLS handshake on the mTLS listener so a stalled or hostile
+/// ClientHello can't hold a connection task (and, in effect, the socket) open
+/// indefinitely.
+const TLS_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
+
+/// Backoff after a recoverable `accept()` error (e.g. EMFILE under fd
+/// pressure) before retrying. Both listeners run under the same
+/// `tokio::try_join!` in `run()`, so an unhandled error from one accept loop
+/// would cancel the other — this keeps a transient error confined to its own
+/// listener instead.
+const ACCEPT_RETRY_DELAY: Duration = Duration::from_millis(100);
// ── GatewayState ───────────────────────────────────────────────────────
/// Context for a proxied request, resolved at CONNECT time.
/// Wrapped in `Arc` and shared across all requests within a MITM session.
-#[derive(Debug)]
+#[derive(Debug, Default)]
pub(crate) struct ProxyContext {
pub project_id: Option,
pub organization_id: Option,
@@ -78,6 +90,13 @@ pub(crate) struct ProxyContext {
pub agent_name: Option,
pub agent_identifier: Option,
pub agent_token: Option,
+ /// Identity extracted from the client's mTLS certificate, when the
+ /// connection came in on the mTLS listener. Phase 1 only threads and logs
+ /// this (the log statements read the identity before it's moved in here)
+ /// — it is never compared against `agent_token`; Phase 2 is the first
+ /// reader of the field itself, hence the lint allowance below.
+ #[allow(dead_code)]
+ pub client_identity: Option>,
}
/// Shared state for the gateway, passed to all request handlers.
@@ -113,6 +132,13 @@ pub(crate) struct GatewayState {
pub struct GatewayServer {
state: GatewayState,
port: u16,
+ /// `None` when `GATEWAY_MTLS_PORT` is unset — the gateway then runs
+ /// exactly as it did before mTLS support existed.
+ mtls: Option,
+ /// Bind address for the plaintext listener. Defaults to `0.0.0.0`
+ /// (`GATEWAY_PLAIN_BIND`) — see the warning in `new()` about what
+ /// narrowing it costs when mTLS is also enabled.
+ plain_bind: IpAddr,
}
/// Build the HTTP client used for upstream requests.
@@ -247,6 +273,35 @@ fn parse_skip_verify_hosts() -> Vec {
.collect()
}
+/// Parse an already-read `GATEWAY_PLAIN_BIND` value (`None` when the var is
+/// unset) into a bind address, defaulting to `0.0.0.0` (unrestricted —
+/// today's behavior, unchanged unless the operator opts into narrowing it).
+///
+/// Fails closed: unset/empty stays the default, but a SET-and-unparseable
+/// value (a typo like `127.0.0.q`, or `localhost`, which isn't an IP literal)
+/// is an `Err`, not a silent fallback to the wide-open default. This is the
+/// one operator knob for restricting the always-open plaintext listener, so
+/// silently widening it on a typo would defeat the whole point of the knob.
+///
+/// No env access — that's `parse_plain_bind`'s job — so this is directly
+/// unit-testable, mirroring the `from_parts`/`from_env` split in `client_ca.rs`.
+fn parse_plain_bind_value(value: Option<&str>) -> Result {
+ match value {
+ None => Ok(IpAddr::V4(Ipv4Addr::UNSPECIFIED)),
+ Some(s) if s.trim().is_empty() => Ok(IpAddr::V4(Ipv4Addr::UNSPECIFIED)),
+ Some(s) => s
+ .trim()
+ .parse()
+ .with_context(|| format!("GATEWAY_PLAIN_BIND {s:?} is not a valid IP address")),
+ }
+}
+
+/// Read `GATEWAY_PLAIN_BIND` from the environment and parse it via
+/// [`parse_plain_bind_value`].
+fn parse_plain_bind() -> Result {
+ parse_plain_bind_value(std::env::var("GATEWAY_PLAIN_BIND").ok().as_deref())
+}
+
/// Returns true if `host` matches any pattern in `patterns`.
///
/// - `*.example.com` matches `sub.example.com` but NOT `example.com` itself.
@@ -266,6 +321,7 @@ fn host_matches_skip_verify(host: &str, patterns: &[String]) -> bool {
}
impl GatewayServer {
+ #[allow(clippy::too_many_arguments)]
pub fn new(
ca: CertificateAuthority,
port: u16,
@@ -273,7 +329,8 @@ impl GatewayServer {
vault_service: Arc,
cache: Arc,
approval_store: Arc,
- ) -> Self {
+ mtls: Option,
+ ) -> Result {
let global_skip = std::env::var("GATEWAY_DANGER_ACCEPT_INVALID_CERTS").is_ok();
let skip_verify_hosts = Arc::new(parse_skip_verify_hosts());
@@ -283,6 +340,17 @@ impl GatewayServer {
info!(hosts = ?skip_verify_hosts.as_ref(), "TLS verification disabled for matched hosts (GATEWAY_SKIP_VERIFY_HOSTS)");
}
+ let plain_bind = parse_plain_bind()?;
+ if mtls.is_some() && plain_bind.is_unspecified() {
+ warn!(
+ "GATEWAY_MTLS_PORT is set but the plaintext listener is still bound to \
+ 0.0.0.0 — anyone who can reach that port bypasses certificate \
+ authentication entirely. Set GATEWAY_PLAIN_BIND=127.0.0.1 to restrict it, \
+ but note that loopback also breaks Docker-published browser -> gateway \
+ vault/approval/cache calls, which arrive on the plaintext listener."
+ );
+ }
+
let state = GatewayState {
ca: Arc::new(ca),
http_client: build_http_client(global_skip),
@@ -296,23 +364,18 @@ impl GatewayServer {
approval_store,
};
- Self { state, port }
+ Ok(Self {
+ state,
+ port,
+ mtls,
+ plain_bind,
+ })
}
- /// Start the gateway TCP listener. Runs forever.
- pub async fn run(&self) -> Result<()> {
- let addr = SocketAddr::from(([0, 0, 0, 0], self.port));
- let listener = TcpListener::bind(addr)
- .await
- .context("binding TCP listener")?;
-
- // Report what we actually bound rather than what we asked for: with
- // `--port 0` the OS assigns the port, and the requested address would
- // report `:0` — leaving no way to discover where the gateway is listening.
- let bound_addr = listener.local_addr().context("reading bound address")?;
-
- info!(addr = %bound_addr, "listening for connections");
-
+ /// Build the Axum router for non-CONNECT routes (healthz, vault API,
+ /// approvals, org routes, ...). Shared by both listeners — the plaintext
+ /// one and, when configured, the mTLS one.
+ fn build_router(&self) -> Router {
// CORS configuration for browser → gateway requests.
// credentials: true requires explicit headers/methods (not wildcard *).
let cors_layer = CorsLayer::new()
@@ -420,53 +483,169 @@ impl GatewayServer {
// Org-scoped routes are mounted via an edition-swapped seam
// (`ee/org_routes.rs` for cloud + onprem, an identity stub for OSS — see
// `main.rs`), so the org handler never reaches the OSS build.
- let axum_router = crate::org_routes::mount(axum_router)
+ crate::org_routes::mount(axum_router)
.layer(cors_layer)
.fallback(fallback)
- .with_state(self.state.clone());
-
- let mut shutdown_signal = crate::shutdown::subscribe();
-
- loop {
- let (stream, peer_addr) = tokio::select! {
- accepted = listener.accept() => match accepted {
- Ok(conn) => conn,
- Err(e) => {
- // Accept failures are almost always transient and
- // self-healing (EMFILE clears as connections close,
- // ECONNABORTED is a client that gave up mid-handshake).
- // Propagating one would tear down every healthy
- // connection this proxy is carrying.
- warn!(error = %e, "accept failed; retrying");
- tokio::time::sleep(ACCEPT_RETRY_DELAY).await;
- continue;
- }
- },
- _ = shutdown_signal.wait() => break,
- };
+ .with_state(self.state.clone())
+ }
- let state = self.state.clone();
- let router = axum_router.clone();
- let guard = crate::shutdown::task_guard();
+ /// Start the gateway's TCP listener(s). Runs forever.
+ ///
+ /// Always binds the plaintext listener. When mTLS is configured, ALSO
+ /// binds the mTLS listener before either accept loop starts — so a bind
+ /// failure on either port aborts startup instead of leaving one listener
+ /// silently running without the other — then drives both accept loops
+ /// concurrently for the life of the process.
+ pub async fn run(&self) -> Result<()> {
+ let plain_addr = SocketAddr::new(self.plain_bind, self.port);
+ let plain_listener = TcpListener::bind(plain_addr)
+ .await
+ .context("binding plaintext TCP listener")?;
+ // Report what we actually bound rather than what we asked for: with
+ // `--port 0` the OS assigns the port, and the requested address would
+ // report `:0` — leaving no way to discover where the gateway is listening.
+ let plain_bound = plain_listener
+ .local_addr()
+ .context("reading bound plaintext address")?;
+ info!(addr = %plain_bound, "listening for plaintext connections");
+
+ let mtls_listener = match &self.mtls {
+ Some(mtls) => {
+ let mtls_addr = SocketAddr::new(mtls.bind, mtls.port);
+ let listener = TcpListener::bind(mtls_addr)
+ .await
+ .context("binding mTLS TCP listener")?;
+ let mtls_bound = listener
+ .local_addr()
+ .context("reading bound mTLS address")?;
+ info!(addr = %mtls_bound, "listening for mTLS connections");
+ Some((listener, TlsAcceptor::from(Arc::clone(&mtls.server_config))))
+ }
+ None => None,
+ };
- tokio::spawn(async move {
- let _guard = guard;
- if let Err(e) = handle_connection(stream, peer_addr, state, router).await {
- warn!(peer = %peer_addr, error = ?e, "connection error");
- }
- });
+ let router = self.build_router();
+ let plain_loop = accept_loop(
+ plain_listener,
+ "plaintext",
+ router.clone(),
+ self.state.clone(),
+ None,
+ );
+
+ match mtls_listener {
+ Some((listener, acceptor)) => {
+ let mtls_loop =
+ accept_loop(listener, "mTLS", router, self.state.clone(), Some(acceptor));
+ tokio::try_join!(plain_loop, mtls_loop)?;
+ }
+ None => plain_loop.await?,
}
- // Closing the port is what stops new work: anything that connects from
- // here on is refused rather than accepted into a process on its way
- // out. Dropped explicitly rather than at the end of the scope so the
- // port is provably shut before the line below claims it is.
- drop(listener);
- info!("listener closed — draining connections");
Ok(())
}
}
+/// Accept connections from `listener` forever, spawning a task per connection.
+/// `name` labels this listener in logs (`"plaintext"` or `"mTLS"`) so the two
+/// concurrent accept loops are distinguishable.
+///
+/// Stops accepting NEW connections once a shutdown signal arrives (breaking
+/// out of the loop below) — in-flight connections are tracked via a shutdown
+/// task guard and drained by `main`'s shutdown sequence, not by this loop.
+///
+/// When `tls` is set, the TLS handshake happens *inside* the spawned task —
+/// never in this loop — so one slow or hostile `ClientHello` can only stall
+/// its own connection, not every other pending accept.
+async fn accept_loop(
+ listener: TcpListener,
+ name: &'static str,
+ router: Router,
+ state: GatewayState,
+ tls: Option,
+) -> Result<()> {
+ let mut shutdown_signal = crate::shutdown::subscribe();
+
+ loop {
+ let (stream, peer_addr) = tokio::select! {
+ accepted = listener.accept() => match accepted {
+ Ok(pair) => pair,
+ Err(e) => {
+ // Log-and-continue: a recoverable accept() error (EMFILE
+ // under fd pressure, ECONNABORTED from a client that gave
+ // up mid-handshake) must not propagate. Both listeners'
+ // loops are driven by the same `tokio::try_join!` in
+ // `run()`, so an `Err` here would cancel the OTHER loop
+ // too, taking down a perfectly healthy listener over a
+ // transient blip on this one.
+ warn!(listener = name, error = %e, "accept() failed, retrying");
+ tokio::time::sleep(ACCEPT_RETRY_DELAY).await;
+ continue;
+ }
+ },
+ _ = shutdown_signal.wait() => break,
+ };
+
+ let state = state.clone();
+ let router = router.clone();
+ let tls = tls.clone();
+ let guard = crate::shutdown::task_guard();
+
+ tokio::spawn(async move {
+ let _guard = guard;
+ match tls {
+ Some(acceptor) => {
+ let handshake: Result<_, anyhow::Error> = async {
+ let tls_stream =
+ timeout(TLS_HANDSHAKE_TIMEOUT, acceptor.accept(stream)).await??;
+ Ok(tls_stream)
+ }
+ .await;
+
+ let tls_stream = match handshake {
+ Ok(s) => s,
+ Err(e) => {
+ warn!(peer = %peer_addr, error = ?e, "mTLS handshake rejected");
+ return;
+ }
+ };
+
+ // Read the peer's certificate chain before moving the
+ // stream into `TokioIo` — `peer_certificates()` is only
+ // reachable through the raw rustls connection.
+ let client_identity = tls_stream
+ .get_ref()
+ .1
+ .peer_certificates()
+ .and_then(client_ca::identity_from_peer_certs)
+ .map(Arc::new);
+
+ if let Err(e) =
+ handle_connection(tls_stream, peer_addr, state, router, client_identity)
+ .await
+ {
+ warn!(peer = %peer_addr, error = ?e, "connection error");
+ }
+ }
+ None => {
+ if let Err(e) = handle_connection(stream, peer_addr, state, router, None).await
+ {
+ warn!(peer = %peer_addr, error = ?e, "connection error");
+ }
+ }
+ }
+ });
+ }
+
+ // Closing the port is what stops new work: anything that connects from
+ // here on is refused rather than accepted into a process on its way out.
+ // Dropped explicitly rather than at the end of the scope so the port is
+ // provably shut before the line below claims it is.
+ drop(listener);
+ info!(listener = name, "listener closed — draining connections");
+ Ok(())
+}
+
// ── Axum route handlers ─────────────────────────────────────────────────
async fn healthz() -> axum::Json {
@@ -715,12 +894,16 @@ fn is_http_proxy_request(req: &Request) -> bool {
/// Uses a `service_fn` wrapper that intercepts CONNECT requests before they reach
/// the Axum router (CONNECT URIs like `host:port` don't match Axum's path-based routing).
/// All other HTTP routes (vault API, healthz, etc.) go through the Axum router.
-async fn handle_connection(
- stream: TcpStream,
+async fn handle_connection(
+ stream: S,
peer_addr: SocketAddr,
state: GatewayState,
router: Router,
-) -> Result<()> {
+ client_identity: Option>,
+) -> Result<()>
+where
+ S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
+{
let io = TokioIo::new(stream);
let conn = http1::Builder::new()
@@ -731,11 +914,12 @@ async fn handle_connection(
service_fn(move |req: Request| {
let state = state.clone();
let router = router.clone();
+ let client_identity = client_identity.clone();
async move {
if req.method() == Method::CONNECT {
- handle_connect(req, peer_addr, state).await
+ handle_connect(req, peer_addr, state, client_identity).await
} else if is_http_proxy_request(&req) {
- handle_http_proxy(req, peer_addr, state).await
+ handle_http_proxy(req, peer_addr, state, client_identity).await
} else {
// Axum handles all non-proxy routes (healthz, vault API, fallback)
let resp: Response = router
@@ -773,6 +957,7 @@ async fn handle_connect(
req: Request,
peer_addr: SocketAddr,
state: GatewayState,
+ client_identity: Option>,
) -> Result, anyhow::Error> {
let host = req
.uri()
@@ -848,6 +1033,7 @@ async fn handle_connect(
org_id = organization_id.as_deref().unwrap_or("-"),
agent = agent_name.as_deref().unwrap_or("-"),
agent_id = agent_id.as_deref().unwrap_or("-"),
+ client_identity = client_identity.as_deref().and_then(ClientIdentity::primary).unwrap_or("-"),
);
info!(
@@ -880,6 +1066,7 @@ async fn handle_connect(
agent_name,
agent_identifier,
agent_token: agent_token.clone(),
+ client_identity,
});
// Taken here, before the spawn, so the session is tracked from the moment
@@ -943,6 +1130,7 @@ async fn handle_http_proxy(
req: Request,
peer_addr: SocketAddr,
state: GatewayState,
+ client_identity: Option>,
) -> Result, anyhow::Error> {
let authority = req
.uri()
@@ -1074,6 +1262,7 @@ async fn handle_http_proxy(
org_id = resolved.organization_id.as_deref().unwrap_or("-"),
agent = resolved.agent_name.as_deref().unwrap_or("-"),
agent_id = resolved.agent_id.as_deref().unwrap_or("-"),
+ client_identity = client_identity.as_deref().and_then(ClientIdentity::primary).unwrap_or("-"),
);
info!(
@@ -1090,6 +1279,7 @@ async fn handle_http_proxy(
agent_name: resolved.agent_name,
agent_identifier: resolved.agent_identifier,
agent_token,
+ client_identity,
};
let rules = mitm::ResolvedRules {
@@ -1268,6 +1458,215 @@ mod tests {
);
}
+ /// Build a `GatewayState` cheap enough for tests: a lazily-connected pool
+ /// (`connect_lazy` never actually dials — nothing exercised by these
+ /// tests touches the DB), an in-memory cache/approval store, and a local
+ /// `CryptoService` key. None of this is real credential material; it
+ /// exists only so the type checker is satisfied and `/healthz` (which
+ /// touches none of these fields) can be routed to through the real
+ /// `handle_connection` path.
+ async fn test_gateway_state() -> GatewayState {
+ use base64::Engine;
+
+ let pool = sqlx::postgres::PgPoolOptions::new()
+ .connect_lazy("postgres://test:test@127.0.0.1/test")
+ .expect("lazy pool");
+ let crypto = Arc::new(
+ crate::crypto::CryptoService::from_base64_key(
+ &base64::engine::general_purpose::STANDARD.encode([0u8; 32]),
+ )
+ .expect("crypto"),
+ );
+ let onepassword = Arc::new(crate::vault::onepassword::OnePasswordVaultProvider::new(
+ pool.clone(),
+ Arc::clone(&crypto),
+ ));
+ let policy_engine = Arc::new(PolicyEngine {
+ pool: pool.clone(),
+ crypto: Arc::clone(&crypto),
+ onepassword: Arc::clone(&onepassword),
+ });
+ let bitwarden = crate::vault::bitwarden::BitwardenVaultProvider::new(
+ crate::vault::bitwarden::BitwardenConfig {
+ proxy_url: "wss://example.invalid".to_string(),
+ },
+ pool.clone(),
+ Arc::clone(&crypto),
+ );
+ let providers: Vec> = vec![Arc::new(bitwarden), onepassword];
+ let vault_service = Arc::new(vault::VaultService::new(providers, pool.clone()));
+ let cache = crate::cache::create_store().await.expect("cache store");
+ let approval_store = crate::approval::create_store()
+ .await
+ .expect("approval store");
+
+ let tmp = tempfile::tempdir().expect("tempdir");
+ let ca = crate::ca::CertificateAuthority::load_or_generate(tmp.path())
+ .await
+ .expect("test ca");
+
+ GatewayState {
+ ca: Arc::new(ca),
+ http_client: build_http_client(false),
+ http_client_no_verify: build_http_client(true),
+ skip_verify_hosts: Arc::new(vec![]),
+ ws_connector: TlsConnector::from(build_ws_tls_config(false)),
+ ws_connector_no_verify: TlsConnector::from(build_ws_tls_config(true)),
+ policy_engine,
+ cache,
+ vault_service,
+ approval_store,
+ }
+ }
+
+ /// End-to-end proof that Phase 1 mTLS *threads* identity but does not
+ /// *enforce* on it: a client cert that verifies successfully is extracted
+ /// into a `ClientIdentity` (asserted below), but a plain `GET /healthz`
+ /// over that same connection still gets a normal 200 through the real
+ /// `handle_connection` path — nothing gates on the identity yet.
+ #[tokio::test]
+ async fn mtls_valid_cert_reaches_healthz_with_no_enforcement() {
+ static INIT: std::sync::Once = std::sync::Once::new();
+ INIT.call_once(|| {
+ let _ = rustls::crypto::ring::default_provider().install_default();
+ });
+
+ // Client CA + a leaf it signs.
+ let ca_key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).expect("ca key");
+ let mut ca_params = rcgen::CertificateParams::default();
+ ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
+ ca_params
+ .distinguished_name
+ .push(rcgen::DnType::CommonName, "Test Client CA");
+ ca_params.not_before = time::OffsetDateTime::now_utc() - time::Duration::hours(1);
+ ca_params.not_after = time::OffsetDateTime::now_utc() + time::Duration::days(1);
+ let ca_cert = ca_params.self_signed(&ca_key).expect("self-sign ca");
+ let ca_pem = ca_cert.pem();
+
+ let leaf_key =
+ rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).expect("leaf key");
+ let mut leaf_params = rcgen::CertificateParams::default();
+ leaf_params
+ .distinguished_name
+ .push(rcgen::DnType::CommonName, "agent-1");
+ leaf_params.not_before = time::OffsetDateTime::now_utc() - time::Duration::hours(1);
+ leaf_params.not_after = time::OffsetDateTime::now_utc() + time::Duration::hours(24);
+ let leaf_cert = leaf_params
+ .signed_by(&leaf_key, &ca_cert, &ca_key)
+ .expect("sign leaf");
+ let leaf_pem = leaf_cert.pem();
+ let leaf_key_pem = leaf_key.serialize_pem();
+
+ // The gateway's own mTLS listener cert (self-signed, "localhost").
+ let server_key =
+ rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).expect("server key");
+ let mut server_params =
+ rcgen::CertificateParams::new(vec!["localhost".to_string()]).expect("params");
+ server_params.not_before = time::OffsetDateTime::now_utc() - time::Duration::hours(1);
+ server_params.not_after = time::OffsetDateTime::now_utc() + time::Duration::days(1);
+ let server_cert = server_params
+ .self_signed(&server_key)
+ .expect("self-sign server");
+ let server_pem = server_cert.pem();
+ let server_key_pem = server_key.serialize_pem();
+ let server_der = server_cert.der().clone();
+
+ // A distinct CA standing in for "the gateway's MITM CA" — only used
+ // to satisfy `from_parts`'s signature; unrelated to the client CA above.
+ let mitm_key =
+ rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).expect("mitm key");
+ let mitm_cert = rcgen::CertificateParams::default()
+ .self_signed(&mitm_key)
+ .expect("self-sign mitm");
+ let mitm_der = mitm_cert.der().clone();
+
+ // Build the real ServerConfig through the same path main.rs uses.
+ let mtls = client_ca::MtlsConfig::from_parts(
+ Some("10256"),
+ Some(&server_pem),
+ Some(&server_key_pem),
+ Some(&ca_pem),
+ &mitm_der,
+ 10255,
+ )
+ .expect("from_parts")
+ .expect("mtls configured");
+
+ // Client trusts the server's self-signed cert directly and presents
+ // the CA-signed leaf.
+ let mut roots = rustls::RootCertStore::empty();
+ roots.add(server_der).expect("trust server cert");
+ let mut key_reader = leaf_key_pem.as_bytes();
+ let client_key = rustls_pemfile::private_key(&mut key_reader)
+ .expect("parse client key")
+ .expect("client key present");
+ let mut cert_reader = leaf_pem.as_bytes();
+ let client_chain: Vec<_> = rustls_pemfile::certs(&mut cert_reader)
+ .collect::>()
+ .expect("client chain");
+ let client_config = Arc::new(
+ rustls::ClientConfig::builder()
+ .with_root_certificates(roots)
+ .with_client_auth_cert(client_chain, client_key)
+ .expect("client auth cert"),
+ );
+
+ let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
+ .await
+ .expect("bind");
+ let addr = listener.local_addr().expect("addr");
+
+ let router = Router::new()
+ .route("/healthz", axum::routing::get(healthz))
+ .fallback(fallback);
+ let state = test_gateway_state().await;
+
+ let server_task = tokio::spawn(async move {
+ let (stream, peer_addr) = listener.accept().await.expect("accept");
+ let acceptor = TlsAcceptor::from(Arc::clone(&mtls.server_config));
+ let tls_stream = acceptor.accept(stream).await.expect("server handshake");
+
+ let client_identity = tls_stream
+ .get_ref()
+ .1
+ .peer_certificates()
+ .and_then(client_ca::identity_from_peer_certs)
+ .map(Arc::new);
+ assert!(client_identity.is_some(), "identity must be extracted");
+
+ handle_connection(tls_stream, peer_addr, state, router, client_identity).await
+ });
+
+ let client_stream = tokio::net::TcpStream::connect(addr).await.expect("connect");
+ let server_name = rustls::pki_types::ServerName::try_from("localhost").expect("name");
+ let mut tls_client = tokio_rustls::TlsConnector::from(client_config)
+ .connect(server_name, client_stream)
+ .await
+ .expect("client handshake");
+
+ use tokio::io::{AsyncReadExt, AsyncWriteExt};
+ tls_client
+ .write_all(b"GET /healthz HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
+ .await
+ .expect("write request");
+
+ let mut response = Vec::new();
+ tls_client
+ .read_to_end(&mut response)
+ .await
+ .expect("read response");
+ let response_str = String::from_utf8_lossy(&response);
+ assert!(
+ response_str.starts_with("HTTP/1.1 200"),
+ "expected 200 OK (no enforcement in Phase 1), got: {response_str}"
+ );
+
+ server_task
+ .await
+ .expect("server task panicked")
+ .expect("connection handled");
+ }
+
// ── strip_port ──────────────────────────────────────────────────────
#[test]
@@ -1352,6 +1751,54 @@ mod tests {
assert!(parse_patterns("").is_empty());
}
+ // ── parse_plain_bind_value ───────────────────────────────────────────
+
+ #[test]
+ fn plain_bind_defaults_to_unspecified_when_unset() {
+ assert_eq!(
+ parse_plain_bind_value(None).unwrap(),
+ IpAddr::V4(Ipv4Addr::UNSPECIFIED)
+ );
+ }
+
+ #[test]
+ fn plain_bind_defaults_to_unspecified_when_empty() {
+ assert_eq!(
+ parse_plain_bind_value(Some("")).unwrap(),
+ IpAddr::V4(Ipv4Addr::UNSPECIFIED)
+ );
+ assert_eq!(
+ parse_plain_bind_value(Some(" ")).unwrap(),
+ IpAddr::V4(Ipv4Addr::UNSPECIFIED)
+ );
+ }
+
+ #[test]
+ fn plain_bind_parses_valid_ip() {
+ assert_eq!(
+ parse_plain_bind_value(Some("127.0.0.1")).unwrap(),
+ IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))
+ );
+ }
+
+ /// FIX 1: a set-but-unparseable value must fail closed (`Err`), not
+ /// silently fall back to the wide-open `0.0.0.0` default — that default
+ /// is exactly what this knob exists to let an operator narrow.
+ #[test]
+ fn plain_bind_unparseable_value_errs_naming_var_and_value() {
+ let err = parse_plain_bind_value(Some("127.0.0.q")).unwrap_err();
+ let msg = format!("{err:#}");
+ assert!(msg.contains("GATEWAY_PLAIN_BIND"), "message: {msg}");
+ assert!(msg.contains("127.0.0.q"), "message: {msg}");
+ }
+
+ #[test]
+ fn plain_bind_hostname_is_not_an_ip_literal_errs() {
+ // "localhost" is a valid hostname but not an IP literal — parsing it
+ // as an IpAddr must fail rather than silently resolve or default.
+ assert!(parse_plain_bind_value(Some("localhost")).is_err());
+ }
+
// ── is_http_proxy_request ──────────────────────────────────────────
#[test]
diff --git a/apps/gateway/src/gateway/mitm.rs b/apps/gateway/src/gateway/mitm.rs
index 47507143..0e0dea6f 100644
--- a/apps/gateway/src/gateway/mitm.rs
+++ b/apps/gateway/src/gateway/mitm.rs
@@ -479,10 +479,8 @@ mod tests {
ProxyContext {
project_id: Some("p1".to_string()),
organization_id: Some("o1".to_string()),
- agent_id: None,
- agent_name: None,
- agent_identifier: None,
agent_token: Some("tok".to_string()),
+ ..Default::default()
}
}
diff --git a/apps/gateway/src/main.rs b/apps/gateway/src/main.rs
index d66b9f19..edca7c79 100644
--- a/apps/gateway/src/main.rs
+++ b/apps/gateway/src/main.rs
@@ -10,6 +10,7 @@ mod auth;
mod auth;
mod ca;
+mod client_ca;
#[cfg(not(edition_cloud))]
mod cache;
@@ -239,6 +240,16 @@ async fn main() -> Result<()> {
let ca = CertificateAuthority::load_or_generate(&data_dir).await?;
info!("CA certificate loaded");
+ // mTLS is opt-in: unset GATEWAY_MTLS_PORT and this is a no-op (full
+ // backward compatibility). When it IS requested, any load failure here
+ // must abort startup — the gateway must never silently fall back to
+ // plaintext-only when mTLS was asked for.
+ let mtls = client_ca::MtlsConfig::from_env(ca.ca_cert_der(), cli.port)?;
+ match &mtls {
+ Some(m) => info!(port = m.port, "mTLS client-certificate listener configured"),
+ None => info!("mTLS disabled (GATEWAY_MTLS_PORT not set)"),
+ }
+
// Connect to PostgreSQL
// Support both DATABASE_URL (OSS) and individual DB_* vars (cloud ECS from Secrets Manager)
let database_url = match std::env::var("DATABASE_URL") {
@@ -317,7 +328,8 @@ async fn main() -> Result<()> {
vault_service,
cache,
approval_store,
- );
+ mtls,
+ )?;
let result = server.run().await;
// The drain, in the one order that does not lose data: connections first
diff --git a/apps/gateway/src/policy_engine/enforce.rs b/apps/gateway/src/policy_engine/enforce.rs
index 0af4c190..8ff8cf8d 100644
--- a/apps/gateway/src/policy_engine/enforce.rs
+++ b/apps/gateway/src/policy_engine/enforce.rs
@@ -242,9 +242,8 @@ mod tests {
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()),
+ ..Default::default()
}
}
diff --git a/docker/Dockerfile b/docker/Dockerfile
index f86fed37..6c706fd9 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -117,7 +117,7 @@ VOLUME ["/app/data"]
USER node
-EXPOSE 10254 10255
+EXPOSE 10254 10255 10256
HEALTHCHECK --interval=10s --timeout=5s --start-period=60s --retries=3 \
CMD wget -qO- http://127.0.0.1:10254/v1/health && wget -qO- http://127.0.0.1:10255/healthz || exit 1
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index f7365003..220e6c39 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -31,6 +31,9 @@ services:
ports:
- "${ONECLI_BIND_HOST:-127.0.0.1}:${ONECLI_APP_PORT:-10254}:10254"
- "${ONECLI_BIND_HOST:-127.0.0.1}:${ONECLI_GATEWAY_PORT:-10255}:10255"
+ # mTLS client-certificate listener — off by default (GATEWAY_MTLS_PORT
+ # unset). Uncomment to publish it, and set the env block below.
+ # - "${ONECLI_BIND_HOST:-127.0.0.1}:${ONECLI_GATEWAY_MTLS_PORT:-10256}:10256"
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-onecli}:${POSTGRES_PASSWORD:-onecli}@postgres:5432/${POSTGRES_DB:-onecli}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-}
@@ -53,6 +56,14 @@ services:
# sending internal vault calls out over the internet, whatever version the
# `image:` above pins.
INTERNAL_API_URL: http://localhost:10254
+ # mTLS is OFF unless GATEWAY_MTLS_PORT is set — uncomment all four to
+ # enable it. GATEWAY_TLS_CERT/KEY/CLIENT_CA accept either an inline PEM
+ # value (starts with "-----BEGIN") or a filesystem path mounted into the
+ # container. GATEWAY_CLIENT_CA must NOT be the gateway's own MITM CA.
+ # GATEWAY_MTLS_PORT: "10256"
+ # GATEWAY_TLS_CERT: /run/secrets/gateway-tls-cert.pem
+ # GATEWAY_TLS_KEY: /run/secrets/gateway-tls-key.pem
+ # GATEWAY_CLIENT_CA: /run/secrets/gateway-client-ca.pem
volumes:
- app-data:/app/data
env_file: