From 23d0d00a5ad18a14a6190c8d79d325724baebc8f Mon Sep 17 00:00:00 2001 From: marcorivm Date: Wed, 29 Jul 2026 16:54:41 -0600 Subject: [PATCH 01/10] feat(edition): re-land Tier 1 ungating onto 1.44.0 Reconciliation Stage A. getCurrentPlan() reports enterprise; the OSS policy validator wiring and now-empty policy-flags/policy-oss-locks modules are removed (the coherence bridge was already dropped upstream); lock affordances are edition-neutral (UnavailableBadge, informational ProAppDialog); the OneCLI Cloud promo blocks and cloud_only error string are removed. No agent-group code, no migration. --- .../app-connect/_components/connect-flow.tsx | 23 ------ .../_components/get-started-dialog.tsx | 24 ++---- .../_components/app-config-form.tsx | 18 ----- .../connections/_components/apps-tab.tsx | 47 ++---------- .../configure-credentials-dialog.tsx | 16 ---- .../src/lib/components/condition-builder.tsx | 12 +-- .../web/src/lib/components/pro-app-dialog.tsx | 72 +++--------------- apps/web/src/lib/components/team-badge.tsx | 38 ---------- .../src/lib/components/unavailable-badge.tsx | 11 +++ apps/web/src/lib/init/api.ts | 10 +-- .../policy-editor/_components/app-select.tsx | 6 +- .../_components/app-target-fields.tsx | 21 ++---- .../src/lib/policy-editor/identity-picker.tsx | 2 +- .../src/lib/policy-editor/resource-scope.tsx | 12 +-- apps/web/src/lib/user-plan.tsx | 9 ++- .../api/src/apps/connect-credentials.test.ts | 2 +- packages/api/src/apps/connect-credentials.ts | 2 +- packages/api/src/lib/policy-flags.test.ts | 37 ---------- packages/api/src/lib/policy-flags.ts | 14 ---- .../src/providers/hooks/policy-validator.ts | 3 +- .../api/src/services/policy-oss-locks.test.ts | 73 ------------------- packages/api/src/services/policy-oss-locks.ts | 49 ------------- packages/api/src/services/policy-service.ts | 14 ++-- 23 files changed, 73 insertions(+), 442 deletions(-) delete mode 100644 apps/web/src/lib/components/team-badge.tsx create mode 100644 apps/web/src/lib/components/unavailable-badge.tsx delete mode 100644 packages/api/src/lib/policy-flags.test.ts delete mode 100644 packages/api/src/lib/policy-flags.ts delete mode 100644 packages/api/src/services/policy-oss-locks.test.ts delete mode 100644 packages/api/src/services/policy-oss-locks.ts diff --git a/apps/web/src/app/(connect)/app-connect/_components/connect-flow.tsx b/apps/web/src/app/(connect)/app-connect/_components/connect-flow.tsx index 6871d9ea..80af71c5 100644 --- a/apps/web/src/app/(connect)/app-connect/_components/connect-flow.tsx +++ b/apps/web/src/app/(connect)/app-connect/_components/connect-flow.tsx @@ -4,7 +4,6 @@ import type { ReactNode } from "react"; import { useCallback, useEffect, useRef, useState } from "react"; import { Loader2 } from "lucide-react"; import { Button } from "@onecli/ui/components/button"; -import { IS_CLOUD } from "@/lib/env"; import { API_ORIGIN, getAuthToken, getProjectId } from "@/lib/api-fetch"; import { ConnectLayout } from "./connect-layout"; import { ConnectSuccess } from "./connect-success"; @@ -280,28 +279,6 @@ export const ConnectFlow = ({ Use an API key instead )} - {!IS_CLOUD && ( - <> -
-
- - or - -
-
-

- 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.

- -
- - -
diff --git a/apps/web/src/lib/components/team-badge.tsx b/apps/web/src/lib/components/team-badge.tsx deleted file mode 100644 index f9d9e4df..00000000 --- a/apps/web/src/lib/components/team-badge.tsx +++ /dev/null @@ -1,38 +0,0 @@ -/** - * The house "Team" pill marking an app that needs a paid OneCLI plan — the - * same badge the Connections list (`apps-tab.tsx` AppRow) and `ProAppDialog` - * render inline. Extracted for the policy editor's cloud-only-app surfaces; - * the two existing inline copies are untouched (future cleanup). - */ -export const TeamBadge = () => ( - - - - - - - Team - - -); diff --git a/apps/web/src/lib/components/unavailable-badge.tsx b/apps/web/src/lib/components/unavailable-badge.tsx new file mode 100644 index 00000000..ca229ff7 --- /dev/null +++ b/apps/web/src/lib/components/unavailable-badge.tsx @@ -0,0 +1,11 @@ +/** + * The house pill marking an integration or capability this build does not + * implement (`available: false` registry entries, and the locked policy-editor + * surfaces). Rendered by the Connections list, the policy editor's app + * pickers, and `ProAppDialog`. + */ +export const UnavailableBadge = () => ( + + Unavailable + +); diff --git a/apps/web/src/lib/init/api.ts b/apps/web/src/lib/init/api.ts index 2512b8cc..0c9b01e8 100644 --- a/apps/web/src/lib/init/api.ts +++ b/apps/web/src/lib/init/api.ts @@ -1,6 +1,5 @@ import type { CreateApiAppOptions } from "@onecli/api"; import { ossNewProjectPolicySeeder } from "@onecli/api/services/policy-oss-cutover"; -import { ossPolicyValidator } from "@onecli/api/services/policy-oss-locks"; /** * The OSS edition's API wiring. Every EE edition ALIASES THIS FILE AWAY @@ -8,11 +7,12 @@ import { ossPolicyValidator } from "@onecli/api/services/policy-oss-locks"; * here is OSS-only by construction: * * - the new-project seeder gives fresh projects their published Default Rule — - * the per-project enforce signal — pinned to ALLOW since step 6; - * - the policy validator LOCKS granular resource scoping (a OneCLI Cloud - * capability the OSS gateway does not enforce) with a loud 422. + * the per-project enforce signal — pinned to ALLOW since step 6. + * + * No `policyValidator` is wired: the provider-hook default is permissive, so + * granular resource scoping and cloud-only app targets are accepted at the API + * layer. The gateway does not yet ENFORCE resource scoping — see Tier 3. */ export const eeOverrides: CreateApiAppOptions | undefined = { newOrgPolicySeeder: ossNewProjectPolicySeeder, - policyValidator: ossPolicyValidator, }; diff --git a/apps/web/src/lib/policy-editor/_components/app-select.tsx b/apps/web/src/lib/policy-editor/_components/app-select.tsx index 1dd255b2..a3e831b1 100644 --- a/apps/web/src/lib/policy-editor/_components/app-select.tsx +++ b/apps/web/src/lib/policy-editor/_components/app-select.tsx @@ -11,7 +11,7 @@ import { } from "@onecli/ui/components/popover"; import { getApp, getApps } from "@onecli/api/apps/registry"; import { AppIcon } from "@/app/(dashboard)/connections/_components/app-icon"; -import { TeamBadge } from "@/lib/components/team-badge"; +import { UnavailableBadge } from "@/lib/components/unavailable-badge"; /** * True when the registry knows the app but this edition can't connect it — @@ -96,7 +96,7 @@ export const AppSelect = ({ value, onChange, id, invalid }: AppSelectProps) => { size={18} /> {selectedApp.name} - {!selectedApp.available && } + {!selectedApp.available && } ) : ( Select an app… @@ -141,7 +141,7 @@ export const AppSelect = ({ value, onChange, id, invalid }: AppSelectProps) => { {a.name} - {!a.available && } + {!a.available && } {a.id === value && ( )} diff --git a/apps/web/src/lib/policy-editor/_components/app-target-fields.tsx b/apps/web/src/lib/policy-editor/_components/app-target-fields.tsx index 813b50de..94c1ffba 100644 --- a/apps/web/src/lib/policy-editor/_components/app-target-fields.tsx +++ b/apps/web/src/lib/policy-editor/_components/app-target-fields.tsx @@ -13,10 +13,11 @@ import { cn } from "@onecli/ui/lib/utils"; import { getApp } from "@onecli/api/apps/registry"; import { AppSelect } from "./app-select"; import { AppToolsPicker } from "./app-tools-picker"; -import { TeamBadge } from "@/lib/components/team-badge"; +import { UnavailableBadge } from "@/lib/components/unavailable-badge"; // Edition seam: EE aliases to the real granular resource editor; the OSS -// module is a locked "available on OneCLI Cloud" hint. Alias key on purpose — -// a relative import would bypass turbopack resolveAlias in EE builds. +// module is a locked "not available in this build" hint. Alias key on +// purpose — a relative import would bypass turbopack resolveAlias in EE +// builds. import { ResourceScopeFields } from "@/lib/policy-editor/resource-scope"; import type { Connection } from "@/lib/api"; @@ -144,18 +145,10 @@ export const AppTargetFields = ({ role="status" className="flex items-center gap-2.5 rounded-md border border-dashed px-3 py-2.5" > - +

- {providerName(value.provider)} connections are available on{" "} - - OneCLI Cloud - - . + {providerName(value.provider)} connections are not yet available in + this build.

) : ( diff --git a/apps/web/src/lib/policy-editor/identity-picker.tsx b/apps/web/src/lib/policy-editor/identity-picker.tsx index 85f7b932..2979506b 100644 --- a/apps/web/src/lib/policy-editor/identity-picker.tsx +++ b/apps/web/src/lib/policy-editor/identity-picker.tsx @@ -4,7 +4,7 @@ import type { ProjectionIdentity } from "@/lib/api"; /** * The OSS identity-picker seam (step 9.5). Directory identities (users, - * user-groups) are a OneCLI Cloud capability, and since attach-model step 6 + * user-groups) are not implemented in this build, and since attach-model step 6 * the only policy console left is the ORG one — which OSS does not mount at * all. So this stub can never render; it exists to keep the shared rule form * compiling in an OSS build. The EE editions alias this file to diff --git a/apps/web/src/lib/policy-editor/resource-scope.tsx b/apps/web/src/lib/policy-editor/resource-scope.tsx index e01916ca..d905e195 100644 --- a/apps/web/src/lib/policy-editor/resource-scope.tsx +++ b/apps/web/src/lib/policy-editor/resource-scope.tsx @@ -5,11 +5,11 @@ import type { Connection } from "@/lib/api"; /** * The OSS resource-scope seam (step 9.5): granular per-resource scoping * (GitHub repositories / Dropbox folders on a connection's injected - * credential) is a OneCLI Cloud capability — the OSS gateway has no guard to - * enforce it and the API locks it with a 422. Rendered only where the real - * editor would appear (a single specific connection on an Allow), as a locked - * capability hint. The EE editions alias this file to - * `@/ee/policy-editor/resource-scope` (the real fields). + * credential) is not implemented in this build — the gateway has no guard to + * enforce it (Tier 3). Rendered only where the real editor would appear (a + * single specific connection on an Allow), as a locked capability hint. The + * EE editions alias this file to `@/ee/policy-editor/resource-scope` (the + * real fields). */ export interface ResourceScopeFieldsProps { @@ -23,6 +23,6 @@ export const ResourceScopeFields: ( ) => React.JSX.Element = () => (

Resource scoping (limit this connection to specific repositories or folders) - is available on OneCLI Cloud. + is not yet available in this build.

); diff --git a/apps/web/src/lib/user-plan.tsx b/apps/web/src/lib/user-plan.tsx index 172ea050..c6997ec0 100644 --- a/apps/web/src/lib/user-plan.tsx +++ b/apps/web/src/lib/user-plan.tsx @@ -3,5 +3,10 @@ /** OSS default: no redirect needed. The EE editions override this via turbopack alias. */ export const checkDashboardRedirect = async (): Promise => null; -/** OSS default: no plan. The EE editions override this via turbopack alias. */ -export const getCurrentPlan = async (): Promise => null; +/** + * This build is fully entitled — mirrors what on-prem reports via + * ONPREM_ENTITLEMENT_ALIASES (`next.config.js`), so plan-gated apps and + * features are never shown as locked. The EE editions override this via + * turbopack alias. + */ +export const getCurrentPlan = async (): Promise => "enterprise"; diff --git a/packages/api/src/apps/connect-credentials.test.ts b/packages/api/src/apps/connect-credentials.test.ts index 3882bad4..830c7ffa 100644 --- a/packages/api/src/apps/connect-credentials.test.ts +++ b/packages/api/src/apps/connect-credentials.test.ts @@ -112,7 +112,7 @@ describe("resolveConnectCredentials", () => { }); expect(result).toEqual({ ok: false, - error: 'Provider "cloudy" is only available in OneCLI Cloud', + error: 'Provider "cloudy" is not yet available in this build', }); }); diff --git a/packages/api/src/apps/connect-credentials.ts b/packages/api/src/apps/connect-credentials.ts index 754698a2..a8b72926 100644 --- a/packages/api/src/apps/connect-credentials.ts +++ b/packages/api/src/apps/connect-credentials.ts @@ -70,7 +70,7 @@ export const resolveConnectCredentials = async ( if (activeMethod.type === "cloud_only") { return { ok: false, - error: `Provider "${provider}" is only available in OneCLI Cloud`, + error: `Provider "${provider}" is not yet available in this build`, }; } diff --git a/packages/api/src/lib/policy-flags.test.ts b/packages/api/src/lib/policy-flags.test.ts deleted file mode 100644 index 6ad9cb89..00000000 --- a/packages/api/src/lib/policy-flags.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { afterEach, describe, expect, it } from "vitest"; -import { isOssEdition } from "./policy-flags"; - -// The OSS edition drives how the shared policy service phrases capability -// rejections (a OneCLI Cloud pointer there, byte-identical everywhere else), so -// the edition resolution itself is pinned: EDITION first, NEXT_PUBLIC_EDITION as -// the fallback, and an unset/unknown value parsing as OSS. -describe("isOssEdition", () => { - const originalEdition = process.env.EDITION; - const originalPublicEdition = process.env.NEXT_PUBLIC_EDITION; - - afterEach(() => { - if (originalEdition === undefined) delete process.env.EDITION; - else process.env.EDITION = originalEdition; - if (originalPublicEdition === undefined) - delete process.env.NEXT_PUBLIC_EDITION; - else process.env.NEXT_PUBLIC_EDITION = originalPublicEdition; - }); - - it.each([ - ["oss", true], - ["onprem-slim", false], - ["onprem-full", false], - ["cloud", false], - ["", true], // unset edition parses as oss - ])("edition %s → %s", (edition, expected) => { - delete process.env.NEXT_PUBLIC_EDITION; - process.env.EDITION = edition; - expect(isOssEdition()).toBe(expected); - }); - - it("falls back to NEXT_PUBLIC_EDITION when EDITION is unset", () => { - delete process.env.EDITION; - process.env.NEXT_PUBLIC_EDITION = "cloud"; - expect(isOssEdition()).toBe(false); - }); -}); diff --git a/packages/api/src/lib/policy-flags.ts b/packages/api/src/lib/policy-flags.ts deleted file mode 100644 index 138be7c0..00000000 --- a/packages/api/src/lib/policy-flags.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Policy runtime edition helpers. Pure and dependency-free (reads only - * `process.env` plus the pure edition parser), so it is safe to import from - * routes, middleware, or a standalone startup entry. - */ -import { parseEdition } from "./edition"; - -const runtimeEdition = () => - parseEdition(process.env.EDITION ?? process.env.NEXT_PUBLIC_EDITION).edition; - -/** Whether this runtime is the OSS edition — used by the shared policy - * service to phrase capability rejections as OneCLI Cloud pointers there - * (byte-identical messages everywhere else). */ -export const isOssEdition = (): boolean => runtimeEdition() === "oss"; diff --git a/packages/api/src/providers/hooks/policy-validator.ts b/packages/api/src/providers/hooks/policy-validator.ts index 7f3eb03b..b1e9731b 100644 --- a/packages/api/src/providers/hooks/policy-validator.ts +++ b/packages/api/src/providers/hooks/policy-validator.ts @@ -10,8 +10,7 @@ export interface PolicyValidator { /** * Edition gate over a rule's targets, run on create/update (never publish — * a pre-existing row must not brick a whole-scope publish). Absent = - * permissive (the default); the OSS edition wires an implementation that - * rejects app targets for cloud-only providers its gateway can't enforce. + * permissive (the default); no edition in this repo wires one. */ validateTargets?(targets: PolicyTargetInput[]): Promise; } diff --git a/packages/api/src/services/policy-oss-locks.test.ts b/packages/api/src/services/policy-oss-locks.test.ts deleted file mode 100644 index 71df400c..00000000 --- a/packages/api/src/services/policy-oss-locks.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { ossPolicyValidator } from "./policy-oss-locks"; -import { ServiceError } from "./errors"; -import type { PolicyTargetInput } from "../validations/policy"; - -// The OSS edition's policy locks. These run against the DEFAULT registries — -// exactly what an OSS process sees (no initEeApps): base apps available, the -// shared EE-stub list (aws-role, datadog, …) present with `available: false`. - -describe("ossPolicyValidator.validate (granular session policy)", () => { - it("rejects unconditionally with the cloud-only message", async () => { - await expect( - ossPolicyValidator.validate("org-1", "github", null, { - repositories: ["a/b"], - }), - ).rejects.toMatchObject({ - code: "UNPROCESSABLE", - message: - "Granular resource scoping (repositories/folders) is available on OneCLI Cloud.", - }); - }); -}); - -describe("ossPolicyValidator.validateTargets (cloud-only apps)", () => { - const run = (targets: PolicyTargetInput[]) => - ossPolicyValidator.validateTargets!(targets); - - it("rejects an app target for a cloud-only (EE-stub) provider, naming the app", async () => { - const err = await run([{ kind: "app", provider: "aws-role" }]).catch( - (e: unknown) => e, - ); - expect(err).toBeInstanceOf(ServiceError); - expect((err as ServiceError).code).toBe("UNPROCESSABLE"); - expect((err as ServiceError).message).toBe( - "AWS Role connections are available on OneCLI Cloud.", - ); - }); - - it("rejects when the cloud-only target is mixed among valid ones", async () => { - await expect( - run([ - { kind: "network", hostPattern: "api.example.com" }, - { kind: "app", provider: "datadog" }, - ]), - ).rejects.toMatchObject({ code: "UNPROCESSABLE" }); - }); - - it("accepts a base (connectable) app", async () => { - await expect( - run([{ kind: "app", provider: "github" }]), - ).resolves.toBeUndefined(); - }); - - it("accepts an UNKNOWN provider string (typos, and onprem-style excluded apps, stay non-fatal)", async () => { - await expect( - run([{ kind: "app", provider: "not-a-real-app" }]), - ).resolves.toBeUndefined(); - }); - - it("ignores non-app target kinds", async () => { - await expect( - run([ - { kind: "network", hostPattern: "*.x.com" }, - { kind: "secret", secretScope: "project" }, - { kind: "connection", connectionId: "conn-1" }, - ]), - ).resolves.toBeUndefined(); - }); - - it("accepts an empty target list", async () => { - await expect(run([])).resolves.toBeUndefined(); - }); -}); diff --git a/packages/api/src/services/policy-oss-locks.ts b/packages/api/src/services/policy-oss-locks.ts deleted file mode 100644 index c2fcceaf..00000000 --- a/packages/api/src/services/policy-oss-locks.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * The OSS edition's policy locks (step 9.5): shared implementations wired ONLY - * through the OSS init seam (`apps/web/src/lib/init/api.ts`, aliased away by - * every EE edition). The provider-hook DEFAULTS stay permissive — cloud's - * in-process web app relies on them before its init warms — so the locks are - * wired, not defaulted. - */ -import { ServiceError } from "./errors"; -import type { PolicyValidator } from "../providers"; -import { getApp } from "../apps/registry"; - -/** - * OSS rejects granular resource scoping outright. One seam covers both storage - * paths: `assertSessionPolicyValid` (policy-rule create/update/publish) and - * the legacy equipment `sessionPolicy` write both call - * `getPolicyValidator().validate(...)`. Without this lock OSS would - * accept-and-store `{repositories}`/`{folders}` that its gateway never - * enforces — false security, worse than absence. - * - * `validateTargets` (create/update only) rejects app targets naming a - * cloud-only provider — the registry's EE stubs (`available: false`), which - * the OSS gateway's base catalog can't resolve, so the rule would be dead. - * The editor locks the same key visually; this is the belt for the CLI/API - * path. App targets only: `assertTargetsValid` proves a connection target's - * OWNERSHIP, not connectability — but no OSS flow can mint an EE-provider - * connection in the first place (connect rejects `cloud_only` providers), so - * connection targets need no provider check. Unknown provider strings stay - * accepted (today's behavior). - */ -export const ossPolicyValidator: PolicyValidator = { - validate: async () => { - throw new ServiceError( - "UNPROCESSABLE", - "Granular resource scoping (repositories/folders) is available on OneCLI Cloud.", - ); - }, - validateTargets: async (targets) => { - for (const t of targets) { - if (t.kind !== "app") continue; - const app = getApp(t.provider); - if (app?.available === false) { - throw new ServiceError( - "UNPROCESSABLE", - `${app.name} connections are available on OneCLI Cloud.`, - ); - } - } - }, -}; diff --git a/packages/api/src/services/policy-service.ts b/packages/api/src/services/policy-service.ts index 5c1b2a07..a36c5aa8 100644 --- a/packages/api/src/services/policy-service.ts +++ b/packages/api/src/services/policy-service.ts @@ -1,6 +1,5 @@ import { db, Prisma } from "@onecli/db"; import { ServiceError } from "./errors"; -import { isOssEdition } from "../lib/policy-flags"; import { type ResourceScope } from "./resource-scope"; import { getPolicyValidator, getRuleActionGate } from "../providers"; import type { @@ -363,15 +362,12 @@ export const assertIdentitiesValid = async ( const userIds = idsOf("user"); const groupIds = idsOf("group"); - // Level restriction. The OSS edition phrases it as the capability lock it - // is there (directory identities are a OneCLI Cloud capability); the EE - // editions keep the scope-shaped message byte-identical. + // Level restriction — the same scope-shaped rule in every edition: a project + // rule targets agents, an org rule targets directory identities. if (base.scope === "project" && (userIds.length || groupIds.length)) { throw new ServiceError( "UNPROCESSABLE", - isOssEdition() - ? "Group and user identities are available on OneCLI Cloud." - : "A project rule can target a specific agent or all agents.", + "A project rule can target a specific agent or all agents.", ); } if (base.scope === "organization" && agentIds.length) { @@ -561,8 +557,8 @@ export const assertTargetsValid = async ( * with a connection target — then runs the wired policy validator per * connection target. EE deep-checks the shape against the provider (repos * exist on the installation, absolute Dropbox paths) and gates the team+ - * entitlement; OSS wires a validator that REJECTS session policies outright - * (granular scoping is a OneCLI Cloud capability — step 9.5). A no-op for + * entitlement; OSS wires no validator — the permissive default accepts + * session policies the OSS gateway does not yet enforce (Tier 3). A no-op for * behavioral / absent conditions. Same org fence as `assertTargetsValid`. * * Callers pass the MERGED (post-update) action/targets/conditions, so no PATCH From 45f516b3c35f366e475057f9788fcc9e17f20e35 Mon Sep 17 00:00:00 2001 From: marcorivm Date: Wed, 29 Jul 2026 17:47:57 -0600 Subject: [PATCH 02/10] feat(api): re-land user groups onto 1.44.0 (agent groups dropped) Reconciliation Stage C. /v1/org/groups CRUD + replace-set membership (org-membership validated on the global-FK userId), /groups as a single user-groups view. Agent groups and the org-agent directory are dropped (orphaned once agent groups are gone). No orphan-neutralization pass: upstream's grants engine treats an FK-orphaned rule identity as inert, so a group delete cannot widen a rule. +59 tests, no agent-group code, no migration. --- .../groups/_components/admin-only-notice.tsx | 20 + .../_components/create-group-dialog.tsx | 96 ++ .../_components/group-members-dialog.tsx | 277 ++++ .../groups/_components/group-row-actions.tsx | 203 +++ .../groups/_components/groups-content.tsx | 47 + .../groups/_components/groups-table.tsx | 91 ++ .../groups/_components/local-mode-notice.tsx | 21 + .../src/app/(dashboard)/groups/loading.tsx | 27 + apps/web/src/app/(dashboard)/groups/page.tsx | 30 + apps/web/src/lib/nav-config.ts | 5 + packages/api/src/routes/org/groups.test.ts | 1388 +++++++++++++++++ packages/api/src/routes/org/groups.ts | 219 +++ packages/api/src/routes/org/index.ts | 2 + packages/api/src/services/audit-service.ts | 5 +- .../api/src/services/org-group-service.ts | 497 ++++++ packages/api/src/validations/org.ts | 36 + 16 files changed, 2963 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/app/(dashboard)/groups/_components/admin-only-notice.tsx create mode 100644 apps/web/src/app/(dashboard)/groups/_components/create-group-dialog.tsx create mode 100644 apps/web/src/app/(dashboard)/groups/_components/group-members-dialog.tsx create mode 100644 apps/web/src/app/(dashboard)/groups/_components/group-row-actions.tsx create mode 100644 apps/web/src/app/(dashboard)/groups/_components/groups-content.tsx create mode 100644 apps/web/src/app/(dashboard)/groups/_components/groups-table.tsx create mode 100644 apps/web/src/app/(dashboard)/groups/_components/local-mode-notice.tsx create mode 100644 apps/web/src/app/(dashboard)/groups/loading.tsx create mode 100644 apps/web/src/app/(dashboard)/groups/page.tsx create mode 100644 packages/api/src/routes/org/groups.test.ts create mode 100644 packages/api/src/routes/org/groups.ts create mode 100644 packages/api/src/services/org-group-service.ts diff --git a/apps/web/src/app/(dashboard)/groups/_components/admin-only-notice.tsx b/apps/web/src/app/(dashboard)/groups/_components/admin-only-notice.tsx new file mode 100644 index 00000000..cda9a616 --- /dev/null +++ b/apps/web/src/app/(dashboard)/groups/_components/admin-only-notice.tsx @@ -0,0 +1,20 @@ +import { Lock } from "lucide-react"; +import { Card } from "@onecli/ui/components/card"; + +/** + * Rendered when the groups query 403s — the API is the authority on who is + * an admin (the /team D-K pattern). A plain card: no retry, no toast (the + * 403 is deterministic). + */ +export const AdminOnlyNotice = () => ( + +
+ +
+

Admins only

+

+ Managing groups requires an organization admin. Ask an admin if you need a + group created or changed. +

+
+); diff --git a/apps/web/src/app/(dashboard)/groups/_components/create-group-dialog.tsx b/apps/web/src/app/(dashboard)/groups/_components/create-group-dialog.tsx new file mode 100644 index 00000000..d8e2f311 --- /dev/null +++ b/apps/web/src/app/(dashboard)/groups/_components/create-group-dialog.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@onecli/ui/components/dialog"; +import { Button } from "@onecli/ui/components/button"; +import { Input } from "@onecli/ui/components/input"; +import { Label } from "@onecli/ui/components/label"; +import { cn } from "@onecli/ui/lib/utils"; +import { useCreateGroup } from "@/hooks/use-groups"; + +export interface CreateGroupDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export const CreateGroupDialog = ({ + open, + onOpenChange, +}: CreateGroupDialogProps) => { + const [name, setName] = useState(""); + const [touched, setTouched] = useState(false); + const createGroup = useCreateGroup(); + + const trimmed = name.trim(); + const nameError = + trimmed.length === 0 + ? "Name is required." + : trimmed.length > 100 + ? "Name must be 100 characters or fewer." + : null; + const showNameError = touched && nameError !== null; + + const handleCreate = () => { + setTouched(true); + if (nameError || createGroup.isPending) return; + createGroup.mutate(trimmed, { onSuccess: () => handleClose(false) }); + }; + + const handleClose = (value: boolean) => { + if (!value) { + setName(""); + setTouched(false); + } + onOpenChange(value); + }; + + return ( + + + + Create group + + Groups organize members for project access and policy rules. + + +
+ + setName(e.target.value)} + onBlur={() => setTouched(true)} + onKeyDown={(e) => { + if (e.key === "Enter") handleCreate(); + }} + autoFocus + className={cn(showNameError && "border-destructive")} + /> + {showNameError && ( +

{nameError}

+ )} +
+ + + + +
+
+ ); +}; 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 */} +
+
+ + {/* Toolbar: count + bulk actions */} +
+

+ + {selected.size} + {" "} + of {candidates.length} selected +

+
+ + / + +
+
+ + {/* 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) => ( + + ))} + + {filteredCandidates.length === 0 && ( +

+ No members match “{search}” +

+ )} +
+
+
+ )} +
+ + + + + +
+
+ ); +}; 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 ( + <> + + + + + + {!isManual && ( + + Managed by your identity provider + + )} + handleRenameOpen(true)} + > + Rename + + setMembersOpen(true)} + > + Manage members + + + setDeleteOpen(true)} + > + Delete + + + + + + + + Rename {group.name} + +
+ + setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleRename(); + }} + autoFocus + /> + {nameError && name !== group.name && ( +

{nameError}

+ )} +
+ + + + +
+
+ + + + + + + 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 ( +
+
+ +
+ {groups.length === 0 ? ( + +
+ +
+

No groups yet

+

+ Create a group to organize members for project access and policy. +

+
+ ) : ( + + + + + Name + Members + Created + + + + + {groups.map((row) => ( + + + {row.name} + {row.source === "scim" && ( + + IdP-managed + + )} + + + {row.memberCount} + + + {new Date(row.createdAt).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })} + + + + + + ))} + +
+
+ )} + +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/groups/_components/local-mode-notice.tsx b/apps/web/src/app/(dashboard)/groups/_components/local-mode-notice.tsx new file mode 100644 index 00000000..2dbdd06f --- /dev/null +++ b/apps/web/src/app/(dashboard)/groups/_components/local-mode-notice.tsx @@ -0,0 +1,21 @@ +import { UsersRound } from "lucide-react"; +import { Card } from "@onecli/ui/components/card"; + +/** + * Local auth mode has exactly one identity, so groups are inert — there is + * nobody to group. + */ +export const LocalModeNotice = () => ( + +
+ +
+

Groups are unavailable in local mode

+

+ This instance runs in local auth mode, which has exactly one built-in + identity (admin@localhost) — there is nobody to group. To invite teammates + and group them, configure Google OAuth (NEXTAUTH_SECRET + + GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET) and restart. +

+
+); diff --git a/apps/web/src/app/(dashboard)/groups/loading.tsx b/apps/web/src/app/(dashboard)/groups/loading.tsx new file mode 100644 index 00000000..447a4c2a --- /dev/null +++ b/apps/web/src/app/(dashboard)/groups/loading.tsx @@ -0,0 +1,27 @@ +import { Card } from "@onecli/ui/components/card"; +import { Skeleton } from "@onecli/ui/components/skeleton"; +import { PageHeader } from "@dashboard/page-header"; + +export default function GroupsLoading() { + return ( +
+ +
+ {[1, 2].map((i) => ( + +
+
+ + +
+ +
+
+ ))} +
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/groups/page.tsx b/apps/web/src/app/(dashboard)/groups/page.tsx new file mode 100644 index 00000000..d463f257 --- /dev/null +++ b/apps/web/src/app/(dashboard)/groups/page.tsx @@ -0,0 +1,30 @@ +import { Suspense } from "react"; +import type { Metadata } from "next"; +import { PageHeader } from "@dashboard/page-header"; +import { getAuthMode } from "@/lib/auth/auth-mode"; +import { GroupsContent } from "./_components/groups-content"; + +export const metadata: Metadata = { + title: "Groups", +}; + +export default function GroupsPage() { + // Auth mode is server-only (fs-backed runtime config), so it is resolved + // here and threaded down as a prop (the TeamContent precedent). Local mode + // gates groups entirely — one built-in identity means nobody to group. No + // server-side auth/role resolution at page level — no dashboard page does + // it, and the API's 403 is the authority on who is an admin. + const groupsEnabled = getAuthMode() !== "local"; + + return ( +
+ + + + +
+ ); +} diff --git a/apps/web/src/lib/nav-config.ts b/apps/web/src/lib/nav-config.ts index a34a1a38..0cb6bb93 100644 --- a/apps/web/src/lib/nav-config.ts +++ b/apps/web/src/lib/nav-config.ts @@ -6,6 +6,7 @@ import { Activity, User, Users, + UsersRound, KeyRound, ShieldCheck, Globe, @@ -31,6 +32,10 @@ export const navItems: NavItem[] = [ // Always visible (D-J): the page itself degrades for non-admins and in // local auth mode — hiding the item would require a session role field. { title: "Team", url: "/team", icon: Users }, + // Always visible (D-J): the page itself degrades for non-admins and gates + // groups in local auth mode — hiding the item would require a session role + // field. + { title: "Groups", url: "/groups", icon: UsersRound }, { title: "Settings", url: "/settings", icon: Settings }, ]; diff --git a/packages/api/src/routes/org/groups.test.ts b/packages/api/src/routes/org/groups.test.ts new file mode 100644 index 00000000..d901f7a9 --- /dev/null +++ b/packages/api/src/routes/org/groups.test.ts @@ -0,0 +1,1388 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Hono } from "hono"; +import type { ApiEnv } from "../../types"; + +// `/v1/org/groups` end-to-end through the real app: the OSS org routes +// mounted on the `eeRoutes` seam, the OSS role resolver wired as the +// RoleResolver, and `CAPS.rbac` on. Admin callers arrive with an org API key +// (whose key path re-checks admin through the resolver); the non-admin cases +// use a session, since a non-admin's org key fails key authentication +// outright. (Same harness as invitations.test.ts / members.test.ts — cloned, +// not shared.) +// +// Reconciliation Stage C ships USER GROUPS ONLY. Role automation +// (group→org-role mappings) and policy-rule orphan neutralization are separate +// later stages and are NOT exercised here — the OSS grants engine already +// treats a rule identity orphaned by an FK cascade as inert, so a group delete +// needs no explicit neutralization pass. + +const ORG = "org-1"; +const OTHER_ORG = "org-2"; +const OWNER = "user-owner"; +const ADMIN = "user-admin"; +const MEMBER = "user-member"; +const OUTSIDER = "user-outsider"; +const ADMIN_KEY = "oc_org_admin-key"; +const PROJECT_KEY = "oc_project-key-of-owner"; + +vi.hoisted(() => { + process.env.NEXT_PUBLIC_EDITION = "oss"; + process.env.SECRET_ENCRYPTION_KEY = "test-secret"; + process.env.OAUTH_STATE_SECRET = "test-secret"; +}); + +interface MemberRow { + organizationId: string; + userId: string; + role: string; + status: string; + ssoExempt: boolean; + suspendedAt: Date | null; + createdAt: Date; +} + +interface UserRow { + id: string; + externalAuthId: string; + email: string; + name: string | null; +} + +interface GroupRow { + id: string; + organizationId: string; + name: string; + source: string; + externalId: string | null; + createdAt: Date; + updatedAt: Date; +} + +interface GroupMemberRow { + groupId: string; + userId: string; + createdByUserId: string | null; + createdAt: Date; +} + +interface ProjectAccessRow { + id: string; + projectId: string; + groupId: string; +} + +interface AuditRow { + organizationId?: string; + userId: string; + action: string; + service: string; + source: string; + metadata: Record; +} + +const store = vi.hoisted(() => ({ + members: [] as MemberRow[], + users: [] as UserRow[], + groups: [] as GroupRow[], + groupMembers: [] as GroupMemberRow[], + projectAccess: [] as ProjectAccessRow[], + audits: [] as AuditRow[], + seq: 0, + txCount: 0, + /** Simulate a create-create race: the name pre-check misses, create P2002s. */ + race: false, + /** Which user the session provider resolves to (null = no session). */ + sessionUserId: null as string | null, +})); + +vi.mock("@onecli/db", () => { + class PrismaClientKnownRequestError extends Error { + code: string; + constructor(message: string, code: string) { + super(message); + this.code = code; + } + } + + // The subset of the Prisma `where` shapes these routes actually build. + interface KeysetClause { + createdAt?: Date | { gt?: Date }; + id?: { gt: string }; + userId?: { gt: string }; + } + interface GroupWhere { + id?: string | { not: string }; + organizationId?: string; + source?: string; + name?: string | { contains: string }; + /** The keyset predicate — the service nests it under AND, never top-level. */ + AND?: { OR: KeysetClause[] }[]; + } + interface GroupSelect { + id?: boolean; + name?: boolean; + source?: boolean; + externalId?: boolean; + createdAt?: boolean; + updatedAt?: boolean; + _count?: { select: { members?: boolean; projectAccess?: boolean } }; + } + interface GroupMemberWhere { + groupId?: string | { in: string[] }; + userId?: string | { in: string[] }; + user?: { + OR: { email?: { contains: string }; name?: { contains: string } }[]; + }; + AND?: { OR: KeysetClause[] }[]; + } + interface OrgMemberWhere { + organizationId?: string; + userId?: string | { in: string[] }; + role?: string | { not?: string }; + status?: string | { not?: string }; + } + + const matchesKeyset = ( + row: { createdAt: Date; id?: string; userId?: string }, + filter: { OR: KeysetClause[] }[] | undefined, + ) => { + if (!filter) return true; + return filter.every((conjunct) => + conjunct.OR.some((clause) => { + if (clause.createdAt instanceof Date) { + if (row.createdAt.getTime() !== clause.createdAt.getTime()) + return false; + if (clause.id !== undefined && row.id !== undefined) + return row.id > clause.id.gt; + if (clause.userId !== undefined && row.userId !== undefined) + return row.userId > clause.userId.gt; + return false; + } + const gt = clause.createdAt?.gt; + return gt !== undefined && row.createdAt.getTime() > gt.getTime(); + }), + ); + }; + + const filterGroups = (where: GroupWhere) => + store.groups.filter((row) => { + if (typeof where.id === "string" && row.id !== where.id) return false; + if ( + typeof where.id === "object" && + where.id !== null && + row.id === where.id.not + ) + return false; + if ( + where.organizationId !== undefined && + row.organizationId !== where.organizationId + ) + return false; + if (where.source !== undefined && row.source !== where.source) + return false; + if (typeof where.name === "string" && row.name !== where.name) + return false; + if ( + typeof where.name === "object" && + where.name !== null && + !row.name.toLowerCase().includes(where.name.contains.toLowerCase()) + ) + return false; + return matchesKeyset(row, where.AND); + }); + + // Mirror Prisma's `select` (incl. `_count`) so a route can't accidentally + // leak a column the service didn't ask for. + const pickGroup = (row: GroupRow, select?: GroupSelect) => { + if (!select) return { ...row }; + const picked: Record = {}; + for (const key of [ + "id", + "name", + "source", + "externalId", + "createdAt", + "updatedAt", + ] as const) { + if (select[key]) picked[key] = row[key]; + } + if (select._count) { + const count: Record = {}; + if (select._count.select.members) { + count.members = store.groupMembers.filter( + (m) => m.groupId === row.id, + ).length; + } + if (select._count.select.projectAccess) { + count.projectAccess = store.projectAccess.filter( + (pa) => pa.groupId === row.id, + ).length; + } + picked._count = count; + } + return picked; + }; + + const filterGroupMembers = (where: GroupMemberWhere) => + store.groupMembers.filter((row) => { + if (typeof where.groupId === "string" && row.groupId !== where.groupId) + return false; + if ( + typeof where.groupId === "object" && + where.groupId !== null && + !where.groupId.in.includes(row.groupId) + ) + return false; + if (typeof where.userId === "string" && row.userId !== where.userId) + return false; + if ( + typeof where.userId === "object" && + where.userId !== null && + !where.userId.in.includes(row.userId) + ) + return false; + if (where.user) { + const user = store.users.find((u) => u.id === row.userId); + if (!user) return false; + const hit = where.user.OR.some((clause) => { + if (clause.email) + return user.email + .toLowerCase() + .includes(clause.email.contains.toLowerCase()); + if (clause.name) + return (user.name ?? "") + .toLowerCase() + .includes(clause.name.contains.toLowerCase()); + return false; + }); + if (!hit) return false; + } + return matchesKeyset(row, where.AND); + }); + + const findMember = (organizationId: string, userId: string) => + store.members.find( + (row) => row.organizationId === organizationId && row.userId === userId, + ); + + const filterOrgMembers = (where: OrgMemberWhere) => + store.members.filter((row) => { + if ( + where.organizationId !== undefined && + row.organizationId !== where.organizationId + ) + return false; + if (typeof where.userId === "string" && row.userId !== where.userId) + return false; + if ( + typeof where.userId === "object" && + where.userId !== null && + !where.userId.in.includes(row.userId) + ) + return false; + if (where.status !== undefined) { + const ok = + typeof where.status === "string" + ? row.status === where.status + : where.status.not === undefined || row.status !== where.status.not; + if (!ok) return false; + } + if (where.role !== undefined) { + const ok = + typeof where.role === "string" + ? row.role === where.role + : where.role.not === undefined || row.role !== where.role.not; + if (!ok) return false; + } + return true; + }); + + const dbGroup = { + findFirst: async ({ + where, + select, + }: { + where: GroupWhere; + select?: GroupSelect; + }) => { + // Race simulation: the create pre-check (a name-keyed findFirst) + // misses, so the create itself must surface the P2002. + if (store.race && where.name !== undefined) return null; + const row = filterGroups(where)[0]; + return row ? pickGroup(row, select) : null; + }, + findMany: async ({ + where, + select, + take, + }: { + where: GroupWhere; + select?: GroupSelect; + take?: number; + }) => { + const rows = filterGroups(where) + .slice() + .sort( + (a, b) => + a.createdAt.getTime() - b.createdAt.getTime() || + a.id.localeCompare(b.id), + ); + const limited = take === undefined ? rows : rows.slice(0, take); + return limited.map((row) => pickGroup(row, select)); + }, + create: async ({ + data, + select, + }: { + data: { + organizationId: string; + name: string; + source: string; + externalId?: string | null; + }; + select?: GroupSelect; + }) => { + const dupe = store.groups.some( + (g) => g.organizationId === data.organizationId && g.name === data.name, + ); + if (dupe) { + throw new PrismaClientKnownRequestError( + "Unique constraint failed", + "P2002", + ); + } + const row: GroupRow = { + id: `g-${++store.seq}`, + organizationId: data.organizationId, + name: data.name, + source: data.source, + externalId: data.externalId ?? null, + createdAt: new Date(), + updatedAt: new Date(), + }; + store.groups.push(row); + return pickGroup(row, select); + }, + // Org-scoped conditional write (the rename path): unique violations + // surface as P2002, a filter miss as count 0. + updateMany: async ({ + where, + data, + }: { + where: GroupWhere; + data: { name: string }; + }) => { + const rows = filterGroups(where); + for (const row of rows) { + const dupe = store.groups.some( + (g) => + g.organizationId === row.organizationId && + g.name === data.name && + g.id !== row.id, + ); + if (dupe) { + throw new PrismaClientKnownRequestError( + "Unique constraint failed", + "P2002", + ); + } + row.name = data.name; + row.updatedAt = new Date(); + } + return { count: rows.length }; + }, + // Delete applies the DB cascades the shipped migration declares: + // GroupMember and ProjectAccess group bindings go with the row (as do + // GroupRoleMapping and PolicyRuleIdentity in stages that populate them). + deleteMany: async ({ where }: { where: GroupWhere }) => { + const rows = filterGroups(where); + for (const row of rows) { + store.groupMembers = store.groupMembers.filter( + (m) => m.groupId !== row.id, + ); + store.projectAccess = store.projectAccess.filter( + (pa) => pa.groupId !== row.id, + ); + } + const ids = new Set(rows.map((r) => r.id)); + store.groups = store.groups.filter((g) => !ids.has(g.id)); + return { count: rows.length }; + }, + }; + + return { + Prisma: { JsonNull: null, PrismaClientKnownRequestError }, + db: { + apiKey: { + findUnique: async ({ where }: { where: { key?: string } }) => { + if (where.key === "oc_org_admin-key") + return { + userId: "user-admin", + organizationId: "org-1", + scope: "organization", + }; + // A PROJECT-scoped key owned by the org's OWNER: it authenticates + // fine, which is exactly why the router needs its own scope guard. + if (where.key === "oc_project-key-of-owner") + return { userId: "user-owner", projectId: "proj-1" }; + return null; + }, + findFirst: async () => null, + findMany: async () => [], + }, + user: { + findUnique: async ({ + where, + select, + }: { + where: { id?: string; externalAuthId?: string; email?: string }; + select?: Record; + }) => { + if (select?.organizationMemberships) { + return { + organizationMemberships: store.members + .filter((m) => m.userId === where.id) + .map((m) => ({ organizationId: m.organizationId })), + }; + } + return ( + store.users.find( + (u) => + (where.id !== undefined && u.id === where.id) || + (where.externalAuthId !== undefined && + u.externalAuthId === where.externalAuthId) || + (where.email !== undefined && u.email === where.email), + ) ?? null + ); + }, + }, + organizationMember: { + findUnique: async ({ + where, + }: { + where: { + organizationId_userId: { organizationId: string; userId: string }; + }; + }) => { + const { organizationId, userId } = where.organizationId_userId; + return findMember(organizationId, userId) ?? null; + }, + // The session auth path resolves membership through these — a stub + // returning null would read every session caller as org-less (401). + findFirst: async ({ + where, + }: { + where: { + organizationId?: string; + userId?: string; + status?: string | { not?: string }; + }; + }) => + store.members.find( + (row) => + (where.organizationId === undefined || + row.organizationId === where.organizationId) && + (where.userId === undefined || row.userId === where.userId) && + (where.status === undefined || + (typeof where.status === "string" + ? row.status === where.status + : where.status.not === undefined || + row.status !== where.status.not)), + ) ?? null, + // THE membership-validation query: { organizationId, userId: { in } }. + findMany: async ({ + where, + select, + }: { + where: OrgMemberWhere; + select?: { userId?: boolean; role?: boolean }; + }) => + filterOrgMembers(where).map((row) => { + if (!select) return { ...row }; + const picked: Record = {}; + if (select.userId) picked.userId = row.userId; + if (select.role) picked.role = row.role; + return picked; + }), + count: async () => 0, + }, + group: dbGroup, + groupMember: { + findUnique: async ({ + where, + }: { + where: { groupId_userId: { groupId: string; userId: string } }; + }) => { + const { groupId, userId } = where.groupId_userId; + const row = store.groupMembers.find( + (m) => m.groupId === groupId && m.userId === userId, + ); + return row ? { userId: row.userId } : null; + }, + findMany: async ({ + where, + select, + take, + }: { + where: GroupMemberWhere; + select?: { + groupId?: boolean; + userId?: boolean; + createdAt?: boolean; + user?: { select: { email?: boolean; name?: boolean } }; + }; + take?: number; + }) => { + const rows = filterGroupMembers(where) + .slice() + .sort( + (a, b) => + a.createdAt.getTime() - b.createdAt.getTime() || + a.userId.localeCompare(b.userId), + ); + const limited = take === undefined ? rows : rows.slice(0, take); + return limited.map((row) => { + if (!select) return { ...row }; + const picked: Record = {}; + if (select.groupId) picked.groupId = row.groupId; + if (select.userId) picked.userId = row.userId; + if (select.createdAt) picked.createdAt = row.createdAt; + if (select.user) { + const user = store.users.find((u) => u.id === row.userId); + picked.user = { + email: user?.email ?? "missing@example.com", + name: user?.name ?? null, + }; + } + return picked; + }); + }, + upsert: async ({ + where, + create, + }: { + where: { groupId_userId: { groupId: string; userId: string } }; + create: GroupMemberRow; + }) => { + const { groupId, userId } = where.groupId_userId; + const existing = store.groupMembers.find( + (m) => m.groupId === groupId && m.userId === userId, + ); + if (existing) return existing; + const row: GroupMemberRow = { ...create, createdAt: new Date() }; + store.groupMembers.push(row); + return row; + }, + createMany: async ({ + data, + }: { + data: { groupId: string; userId: string; createdByUserId: string }[]; + skipDuplicates?: boolean; + }) => { + let count = 0; + for (const d of data) { + const exists = store.groupMembers.some( + (m) => m.groupId === d.groupId && m.userId === d.userId, + ); + if (exists) continue; // skipDuplicates + store.groupMembers.push({ ...d, createdAt: new Date() }); + count++; + } + return { count }; + }, + deleteMany: async ({ where }: { where: GroupMemberWhere }) => { + const rows = filterGroupMembers(where); + const keys = new Set(rows.map((r) => `${r.groupId}:${r.userId}`)); + const before = store.groupMembers.length; + store.groupMembers = store.groupMembers.filter( + (m) => !keys.has(`${m.groupId}:${m.userId}`), + ); + return { count: before - store.groupMembers.length }; + }, + }, + project: { + findFirst: async () => ({ id: "proj-1", organizationId: "org-1" }), + findUnique: async () => ({ id: "proj-1", organizationId: "org-1" }), + }, + projectAccess: { findFirst: async () => null }, + auditLog: { + create: async ({ data }: { data: AuditRow }) => { + store.audits.push(data); + return data; + }, + }, + // The replace-set writer runs its delete+create under ONE array-form + // transaction; the delete path is a plain conditional deleteMany. + $transaction: async (arg: unknown) => { + store.txCount++; + if (typeof arg === "function") { + return (arg as (tx: unknown) => Promise)({ group: dbGroup }); + } + return Promise.all(arg as Promise[]); + }, + }, + }; +}); + +import { createApiApp } from "../../app"; +import { registerOssOrgRoutes } from "./index"; +import { ossRoleResolver } from "../../services/org-role-resolver"; + +const sessionProvider = { + getSession: async () => { + const user = store.users.find((u) => u.id === store.sessionUserId); + return user ? { id: user.externalAuthId, email: user.email } : null; + }, +}; + +const app: Hono = createApiApp(sessionProvider, { + eeRoutes: registerOssOrgRoutes, + roleResolver: ossRoleResolver, +}); + +const at = (minutes: number) => new Date(Date.UTC(2026, 0, 1, 0, minutes)); + +const member = ( + userId: string, + role: string, + createdAt: Date, + organizationId = ORG, +): MemberRow => ({ + organizationId, + userId, + role, + status: "active", + ssoExempt: false, + suspendedAt: null, + createdAt, +}); + +const group = ( + id: string, + name: string, + overrides: Partial = {}, +): GroupRow => ({ + id, + organizationId: ORG, + name, + source: "manual", + externalId: null, + createdAt: at(10), + updatedAt: at(10), + ...overrides, +}); + +beforeEach(() => { + store.users = [ + { + id: OWNER, + externalAuthId: "ext-owner", + email: "owner@example.com", + name: "Olive Owner", + }, + { + id: ADMIN, + externalAuthId: "ext-admin", + email: "admin@example.com", + name: "Adam Admin", + }, + { + id: MEMBER, + externalAuthId: "ext-member", + email: "member@elsewhere.test", + name: null, + }, + { + id: OUTSIDER, + externalAuthId: "ext-outsider", + email: "outsider@other.test", + name: "Odette Outsider", + }, + ]; + store.members = [ + member(OWNER, "owner", at(0)), + member(ADMIN, "admin", at(1)), + member(MEMBER, "member", at(2)), + member(OUTSIDER, "admin", at(3), OTHER_ORG), + ]; + store.groups = [ + group("g-a", "Engineering", { createdAt: at(10), updatedAt: at(10) }), + group("g-b", "Design", { createdAt: at(11), updatedAt: at(11) }), + group("g-scim", "Provisioned", { + source: "scim", + externalId: "idp-77", + createdAt: at(12), + updatedAt: at(12), + }), + // A group in a DIFFERENT org — never visible through this org's routes. + group("g-x", "Foreign", { organizationId: OTHER_ORG, createdAt: at(13) }), + ]; + store.groupMembers = [ + { + groupId: "g-a", + userId: OWNER, + createdByUserId: ADMIN, + createdAt: at(20), + }, + { + groupId: "g-a", + userId: ADMIN, + createdByUserId: ADMIN, + createdAt: at(21), + }, + { + groupId: "g-scim", + userId: MEMBER, + createdByUserId: null, + createdAt: at(22), + }, + // Membership of the foreign group, for cross-org isolation checks. + { + groupId: "g-x", + userId: OUTSIDER, + createdByUserId: null, + createdAt: at(23), + }, + ]; + store.projectAccess = [{ id: "pa-1", projectId: "proj-1", groupId: "g-a" }]; + store.audits = []; + store.seq = 100; + store.txCount = 0; + store.race = false; + store.sessionUserId = null; +}); + +const groupRow = (id: string) => store.groups.find((g) => g.id === id); +const membersOf = (groupId: string) => + store.groupMembers + .filter((m) => m.groupId === groupId) + .map((m) => m.userId) + .sort(); + +const asAdmin = { headers: { Authorization: `Bearer ${ADMIN_KEY}` } }; +const asProjectKey = { headers: { Authorization: `Bearer ${PROJECT_KEY}` } }; + +interface GroupListBody { + data: { + id: string; + name: string; + source: string; + externalId: string | null; + memberCount: number; + createdAt: string; + updatedAt: string; + }[]; + nextCursor: string | null; +} + +interface MemberListBody { + data: { + userId: string; + email: string; + name: string | null; + addedAt: string; + }[]; + nextCursor: string | null; +} + +const list = async (query = ""): Promise => { + const res = await app.request(`/v1/org/groups${query}`, asAdmin); + expect(res.status).toBe(200); + return (await res.json()) as GroupListBody; +}; + +const create = (body: unknown, init: RequestInit = asAdmin) => + app.request("/v1/org/groups", { + ...init, + method: "POST", + body: JSON.stringify(body), + }); + +const rename = (id: string, body: unknown, init: RequestInit = asAdmin) => + app.request(`/v1/org/groups/${id}`, { + ...init, + method: "PATCH", + body: JSON.stringify(body), + }); + +const remove = (id: string, init: RequestInit = asAdmin) => + app.request(`/v1/org/groups/${id}`, { ...init, method: "DELETE" }); + +const putMembers = (id: string, body: unknown, init: RequestInit = asAdmin) => + app.request(`/v1/org/groups/${id}/members`, { + ...init, + method: "PUT", + body: JSON.stringify(body), + }); + +const putMember = (id: string, userId: string, init: RequestInit = asAdmin) => + app.request(`/v1/org/groups/${id}/members/${userId}`, { + ...init, + method: "PUT", + body: JSON.stringify({}), + }); + +const deleteMember = ( + id: string, + userId: string, + init: RequestInit = asAdmin, +) => + app.request(`/v1/org/groups/${id}/members/${userId}`, { + ...init, + method: "DELETE", + }); + +describe("GET /v1/org/groups", () => { + it("returns the org's groups in the page envelope with member counts", async () => { + const body = await list(); + expect(body.nextCursor).toBeNull(); + expect(body.data.map((row) => row.id)).toEqual(["g-a", "g-b", "g-scim"]); + expect(body.data[0]).toEqual({ + id: "g-a", + name: "Engineering", + source: "manual", + externalId: null, + memberCount: 2, + createdAt: at(10).toISOString(), + updatedAt: at(10).toISOString(), + }); + expect(body.data[2]).toMatchObject({ + source: "scim", + externalId: "idp-77", + memberCount: 1, + }); + }); + + it("never leaks groups of another organization", async () => { + const body = await list(); + expect(body.data.some((row) => row.id === "g-x")).toBe(false); + }); + + it("filters by source", async () => { + const body = await list("?source=scim"); + expect(body.data.map((r) => r.id)).toEqual(["g-scim"]); + const manual = await list("?source=manual"); + expect(manual.data.map((r) => r.id)).toEqual(["g-a", "g-b"]); + }); + + it("rejects an unknown source with 422", async () => { + const res = await app.request("/v1/org/groups?source=github", asAdmin); + expect(res.status).toBe(422); + }); + + it("filters by free-text q over name, case-insensitively", async () => { + const body = await list("?q=ENGINEER"); + expect(body.data.map((r) => r.id)).toEqual(["g-a"]); + }); + + it("pages with an opaque cursor and ends with nextCursor null", async () => { + const first = await list("?limit=2"); + expect(first.data.map((r) => r.id)).toEqual(["g-a", "g-b"]); + expect(first.nextCursor).toBeTruthy(); + + const second = await list( + `?limit=2&cursor=${encodeURIComponent(first.nextCursor ?? "")}`, + ); + expect(second.data.map((r) => r.id)).toEqual(["g-scim"]); + expect(second.nextCursor).toBeNull(); + }); + + it("walks every page exactly once when createdAt ties", async () => { + // Same millisecond for all: only the id half of the cursor can separate + // them, so a one-at-a-time walk is the tiebreak's real test. + for (const row of store.groups) row.createdAt = at(7); + + const seen: string[] = []; + let cursor: string | null = null; + for (let page = 0; page < 10; page++) { + const body: GroupListBody = await list( + `?limit=1${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`, + ); + seen.push(...body.data.map((r) => r.id)); + cursor = body.nextCursor; + if (!cursor) break; + } + + expect(cursor).toBeNull(); + expect(seen.slice().sort()).toEqual(["g-a", "g-b", "g-scim"]); + expect(new Set(seen).size).toBe(seen.length); + }); + + it("treats a malformed cursor as the first page instead of failing", async () => { + const body = await list("?cursor=not-a-real-cursor"); + expect(body.data).toHaveLength(3); + }); + + it("rejects out-of-range limits with 422", async () => { + for (const limit of ["5000", "0", "abc"]) { + const res = await app.request(`/v1/org/groups?limit=${limit}`, asAdmin); + expect(res.status).toBe(422); + } + }); + + it("403s a project-scoped key even when its user is an org owner", async () => { + const res = await app.request("/v1/org/groups", asProjectKey); + expect(res.status).toBe(403); + }); + + it("403s a non-admin member (deterministic, not a 401)", async () => { + store.sessionUserId = MEMBER; + const res = await app.request("/v1/org/groups"); + expect(res.status).toBe(403); + }); + + it("rejects a suspended admin's org key (suspended reads as no role)", async () => { + const row = store.members.find((m) => m.userId === ADMIN); + if (row) row.status = "suspended"; + const res = await app.request("/v1/org/groups", asAdmin); + expect(res.status).toBe(401); + }); + + it("401s an unauthenticated caller", async () => { + const res = await app.request("/v1/org/groups"); + expect(res.status).toBe(401); + }); +}); + +describe("POST /v1/org/groups", () => { + it("creates a manual group and audits it", async () => { + const res = await create({ name: "Platform" }); + expect(res.status).toBe(200); + const body = (await res.json()) as GroupListBody["data"][number]; + expect(body).toMatchObject({ + name: "Platform", + source: "manual", + externalId: null, + memberCount: 0, + }); + expect(store.audits).toHaveLength(1); + expect(store.audits[0]).toMatchObject({ + organizationId: ORG, + userId: ADMIN, + action: "create", + service: "group", + source: "api", + metadata: { groupId: body.id, name: "Platform" }, + }); + }); + + it("ignores body source/externalId: creates are always manual", async () => { + const res = await create({ + name: "Sneaky", + source: "scim", + externalId: "idp-evil", + }); + expect(res.status).toBe(200); + const body = (await res.json()) as GroupListBody["data"][number]; + expect(body.source).toBe("manual"); + expect(body.externalId).toBeNull(); + expect(groupRow(body.id)?.source).toBe("manual"); + }); + + it("trims the name before storing", async () => { + const res = await create({ name: " Padded " }); + expect(res.status).toBe(200); + const body = (await res.json()) as GroupListBody["data"][number]; + expect(body.name).toBe("Padded"); + }); + + it("422s an empty / whitespace-only / overlong / missing name", async () => { + for (const body of [ + { name: "" }, + { name: " " }, + { name: "x".repeat(101) }, + {}, + ]) { + const res = await create(body); + expect(res.status).toBe(422); + } + expect(store.audits).toHaveLength(0); + }); + + it("422s a missing/unparseable body", async () => { + const res = await app.request("/v1/org/groups", { + ...asAdmin, + method: "POST", + }); + expect(res.status).toBe(422); + }); + + it("409s a duplicate name and audits nothing", async () => { + const res = await create({ name: "Engineering" }); + expect(res.status).toBe(409); + expect(store.audits).toHaveLength(0); + expect(store.groups.filter((g) => g.name === "Engineering")).toHaveLength( + 1, + ); + }); + + it("409s a create-create race surfaced as P2002", async () => { + store.race = true; + const res = await create({ name: "Engineering" }); + expect(res.status).toBe(409); + expect(store.audits).toHaveLength(0); + }); + + it("403s a project-scoped key and audits/creates nothing", async () => { + const res = await create({ name: "Platform" }, asProjectKey); + expect(res.status).toBe(403); + expect(store.audits).toHaveLength(0); + expect(store.groups.some((g) => g.name === "Platform")).toBe(false); + }); + + it("403s a non-admin member and audits nothing", async () => { + store.sessionUserId = MEMBER; + const res = await create({ name: "Platform" }, {}); + expect(res.status).toBe(403); + expect(store.audits).toHaveLength(0); + }); +}); + +describe("PATCH /v1/org/groups/:groupId", () => { + it("renames a group and audits the change discriminator", async () => { + const res = await rename("g-a", { name: "Core Engineering" }); + expect(res.status).toBe(200); + const body = (await res.json()) as GroupListBody["data"][number]; + expect(body).toMatchObject({ + id: "g-a", + name: "Core Engineering", + memberCount: 2, + }); + expect(groupRow("g-a")?.name).toBe("Core Engineering"); + expect(store.audits).toHaveLength(1); + expect(store.audits[0]).toMatchObject({ + action: "update", + service: "group", + metadata: { groupId: "g-a", change: "name", name: "Core Engineering" }, + }); + }); + + it("permits a rename-to-self as a no-op 200", async () => { + const res = await rename("g-a", { name: "Engineering" }); + expect(res.status).toBe(200); + expect(groupRow("g-a")?.name).toBe("Engineering"); + }); + + it("409s a rename onto another group's name", async () => { + const res = await rename("g-a", { name: "Design" }); + expect(res.status).toBe(409); + expect(groupRow("g-a")?.name).toBe("Engineering"); + expect(store.audits).toHaveLength(0); + }); + + it("404s an unknown group", async () => { + const res = await rename("g-nope", { name: "Anything" }); + expect(res.status).toBe(404); + }); + + it("404s a group of another organization (cross-org isolation)", async () => { + const res = await rename("g-x", { name: "Captured" }); + expect(res.status).toBe(404); + expect(groupRow("g-x")?.name).toBe("Foreign"); + }); + + it("409s a scim-provisioned group (IdP-owned)", async () => { + const res = await rename("g-scim", { name: "Mine now" }); + expect(res.status).toBe(409); + expect(groupRow("g-scim")?.name).toBe("Provisioned"); + expect(store.audits).toHaveLength(0); + }); + + it("422s an invalid name", async () => { + const res = await rename("g-a", { name: " " }); + expect(res.status).toBe(422); + }); +}); + +describe("DELETE /v1/org/groups/:groupId", () => { + it("deletes, reports the impact read BEFORE the delete, and cascades", async () => { + const res = await remove("g-a"); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + id: "g-a", + name: "Engineering", + removedMembers: 2, + removedProjectBindings: 1, + }); + // Cascades applied: membership and project bindings went with the group. + expect(groupRow("g-a")).toBeUndefined(); + expect(membersOf("g-a")).toEqual([]); + expect(store.projectAccess.some((pa) => pa.groupId === "g-a")).toBe(false); + }); + + it("audits counts only — never id arrays", async () => { + const res = await remove("g-a"); + expect(res.status).toBe(200); + expect(store.audits).toHaveLength(1); + expect(store.audits[0]).toMatchObject({ + action: "delete", + service: "group", + metadata: { + groupId: "g-a", + name: "Engineering", + removedMembers: 2, + removedProjectBindings: 1, + }, + }); + for (const value of Object.values(store.audits[0]?.metadata ?? {})) { + expect(Array.isArray(value)).toBe(false); + } + }); + + it("404s an unknown group and audits nothing", async () => { + const res = await remove("g-nope"); + expect(res.status).toBe(404); + expect(store.audits).toHaveLength(0); + }); + + it("404s a group of another organization (cross-org isolation)", async () => { + const res = await remove("g-x"); + expect(res.status).toBe(404); + expect(groupRow("g-x")).toBeTruthy(); + }); + + it("409s a scim-provisioned group", async () => { + const res = await remove("g-scim"); + expect(res.status).toBe(409); + expect(groupRow("g-scim")).toBeTruthy(); + }); + + it("403s a project-scoped key and deletes nothing", async () => { + const res = await remove("g-a", asProjectKey); + expect(res.status).toBe(403); + expect(groupRow("g-a")).toBeTruthy(); + expect(store.audits).toHaveLength(0); + }); +}); + +describe("GET /v1/org/groups/:groupId/members", () => { + const listMembers = async ( + groupId: string, + query = "", + ): Promise => { + const res = await app.request( + `/v1/org/groups/${groupId}/members${query}`, + asAdmin, + ); + expect(res.status).toBe(200); + return (await res.json()) as MemberListBody; + }; + + it("returns the group's members with user identity joined in", async () => { + const body = await listMembers("g-a"); + expect(body.nextCursor).toBeNull(); + expect(body.data).toEqual([ + { + userId: OWNER, + email: "owner@example.com", + name: "Olive Owner", + addedAt: at(20).toISOString(), + }, + { + userId: ADMIN, + email: "admin@example.com", + name: "Adam Admin", + addedAt: at(21).toISOString(), + }, + ]); + }); + + it("filters by q over email and name, case-insensitively", async () => { + const byEmail = await listMembers("g-a", "?q=OWNER@example"); + expect(byEmail.data.map((r) => r.userId)).toEqual([OWNER]); + const byName = await listMembers("g-a", "?q=adam"); + expect(byName.data.map((r) => r.userId)).toEqual([ADMIN]); + }); + + it("pages the member list with the two-part cursor", async () => { + const first = await listMembers("g-a", "?limit=1"); + expect(first.data.map((r) => r.userId)).toEqual([OWNER]); + expect(first.nextCursor).toBeTruthy(); + const second = await listMembers( + "g-a", + `?limit=1&cursor=${encodeURIComponent(first.nextCursor ?? "")}`, + ); + expect(second.data.map((r) => r.userId)).toEqual([ADMIN]); + expect(second.nextCursor).toBeNull(); + }); + + it("404s a group of another organization (no membership oracle)", async () => { + const res = await app.request("/v1/org/groups/g-x/members", asAdmin); + expect(res.status).toBe(404); + }); + + it("lists a scim group's members (reads are always allowed)", async () => { + const body = await listMembers("g-scim"); + expect(body.data.map((r) => r.userId)).toEqual([MEMBER]); + }); +}); + +describe("PUT /v1/org/groups/:groupId/members (replace-set)", () => { + it("applies the exact set: adds, removes, keeps, and returns the delta", async () => { + // g-a currently {OWNER, ADMIN}; target {ADMIN, MEMBER}. + const res = await putMembers("g-a", { userIds: [ADMIN, MEMBER] }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ added: 1, removed: 1 }); + expect(membersOf("g-a")).toEqual([ADMIN, MEMBER].sort()); + expect(store.txCount).toBe(1); + expect(store.audits).toHaveLength(1); + expect(store.audits[0]).toMatchObject({ + action: "update", + service: "group", + source: "api", + metadata: { groupId: "g-a", change: "members", added: 1, removed: 1 }, + }); + // Counts only, never id arrays. + for (const value of Object.values(store.audits[0]?.metadata ?? {})) { + expect(Array.isArray(value)).toBe(false); + } + }); + + it("an empty set clears the group", async () => { + const res = await putMembers("g-a", { userIds: [] }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ added: 0, removed: 2 }); + expect(membersOf("g-a")).toEqual([]); + }); + + it("a no-op set returns {0,0} without opening a transaction", async () => { + const res = await putMembers("g-a", { userIds: [OWNER, ADMIN] }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ added: 0, removed: 0 }); + expect(store.txCount).toBe(0); + expect(membersOf("g-a")).toEqual([ADMIN, OWNER].sort()); + }); + + it("deduplicates repeated ids in the payload", async () => { + const res = await putMembers("g-b", { userIds: [MEMBER, MEMBER] }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ added: 1, removed: 0 }); + expect(membersOf("g-b")).toEqual([MEMBER]); + }); + + it("422s a set beyond the cap", async () => { + const userIds = Array.from({ length: 1001 }, (_, i) => `u-${i}`); + const res = await putMembers("g-a", { userIds }); + expect(res.status).toBe(422); + }); + + it("400s when ANY id is not a member of this org — the security core", async () => { + const res = await putMembers("g-a", { userIds: [ADMIN, OUTSIDER] }); + expect(res.status).toBe(400); + // Nothing written, nothing audited: the whole write is rejected. + expect(membersOf("g-a")).toEqual([ADMIN, OWNER].sort()); + expect(store.audits).toHaveLength(0); + expect(store.txCount).toBe(0); + }); + + it("allows suspended members (suspension is an auth-time gate)", async () => { + const row = store.members.find((m) => m.userId === MEMBER); + if (row) row.status = "suspended"; + const res = await putMembers("g-b", { userIds: [MEMBER] }); + expect(res.status).toBe(200); + expect(membersOf("g-b")).toEqual([MEMBER]); + }); + + it("404s a cross-org group before validating membership", async () => { + const res = await putMembers("g-x", { userIds: [OUTSIDER] }); + expect(res.status).toBe(404); + expect(membersOf("g-x")).toEqual([OUTSIDER]); + }); + + it("409s a scim group (membership is IdP-owned)", async () => { + const res = await putMembers("g-scim", { userIds: [ADMIN] }); + expect(res.status).toBe(409); + expect(membersOf("g-scim")).toEqual([MEMBER]); + }); + + it("422s a malformed body", async () => { + for (const body of [{}, { userIds: "ADMIN" }, { userIds: [""] }, null]) { + const res = await putMembers("g-a", body); + expect(res.status).toBe(422); + } + }); + + it("403s a non-admin and writes/audits nothing", async () => { + store.sessionUserId = MEMBER; + const res = await putMembers("g-a", { userIds: [] }, {}); + expect(res.status).toBe(403); + expect(membersOf("g-a")).toEqual([ADMIN, OWNER].sort()); + expect(store.audits).toHaveLength(0); + }); +}); + +describe("PUT /v1/org/groups/:groupId/members/:userId (single add)", () => { + it("adds a member, returns a JSON body, and audits", async () => { + const res = await putMember("g-b", MEMBER); + expect(res.status).toBe(200); + // apiPut ALWAYS parses the response — a 204 here would break the client. + expect(res.headers.get("content-type")).toContain("application/json"); + expect(await res.json()).toEqual({ added: true }); + expect(membersOf("g-b")).toEqual([MEMBER]); + expect(store.audits).toHaveLength(1); + expect(store.audits[0]).toMatchObject({ + metadata: { + groupId: "g-b", + change: "members", + userId: MEMBER, + added: true, + }, + }); + }); + + it("is idempotent: a second add reports added: false", async () => { + expect((await putMember("g-b", MEMBER)).status).toBe(200); + const res = await putMember("g-b", MEMBER); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ added: false }); + expect(membersOf("g-b")).toEqual([MEMBER]); + }); + + it("400s a user from another organization", async () => { + const res = await putMember("g-b", OUTSIDER); + expect(res.status).toBe(400); + expect(membersOf("g-b")).toEqual([]); + expect(store.audits).toHaveLength(0); + }); + + it("404s a cross-org group / 409s a scim group", async () => { + expect((await putMember("g-x", MEMBER)).status).toBe(404); + expect((await putMember("g-scim", ADMIN)).status).toBe(409); + }); +}); + +describe("DELETE /v1/org/groups/:groupId/members/:userId (single remove)", () => { + it("removes a member and audits", async () => { + const res = await deleteMember("g-a", OWNER); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ removed: true }); + expect(membersOf("g-a")).toEqual([ADMIN]); + expect(store.audits).toHaveLength(1); + expect(store.audits[0]).toMatchObject({ + metadata: { + groupId: "g-a", + change: "members", + userId: OWNER, + removed: true, + }, + }); + }); + + it("is idempotent: a missing membership is removed:false, not 404", async () => { + const res = await deleteMember("g-b", MEMBER); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ removed: false }); + }); + + it("404s only for a missing/cross-org GROUP", async () => { + expect((await deleteMember("g-nope", MEMBER)).status).toBe(404); + expect((await deleteMember("g-x", OUTSIDER)).status).toBe(404); + expect(membersOf("g-x")).toEqual([OUTSIDER]); + }); + + it("409s a scim group", async () => { + const res = await deleteMember("g-scim", MEMBER); + expect(res.status).toBe(409); + expect(membersOf("g-scim")).toEqual([MEMBER]); + }); +}); diff --git a/packages/api/src/routes/org/groups.ts b/packages/api/src/routes/org/groups.ts new file mode 100644 index 00000000..243a348f --- /dev/null +++ b/packages/api/src/routes/org/groups.ts @@ -0,0 +1,219 @@ +import { Hono } from "hono"; +import type { Context } from "hono"; +import type { ApiEnv } from "../../types"; +import { auth } from "../../middleware/auth"; +import { ServiceError } from "../../services/errors"; +import { parse } from "./parse"; +import { + addOrgGroupMember, + createOrgGroup, + deleteOrgGroup, + listOrgGroupMembers, + listOrgGroups, + removeOrgGroupMember, + renameOrgGroup, + setOrgGroupMembers, +} from "../../services/org-group-service"; +import { + createGroupSchema, + directoryListQuerySchema, + groupListQuerySchema, + renameGroupSchema, + setGroupMembersSchema, +} from "../../validations/org"; +import { + withAudit, + AUDIT_ACTIONS, + AUDIT_SERVICES, + AUDIT_SOURCE, +} from "../../services/audit-service"; + +/** + * `/v1/org/groups` — the organization's human groups. + * + * Same guard stack as `/v1/org/members`, for the same reasons: + * + * `requireProject: false`: these are ORG-scoped routes, so a caller with no + * project context (an org API key without `X-Project-Id`) must still get + * through. `role: "admin"` makes the whole router admin-only — a plain member + * gets a deterministic 403, which is exactly what the web client expects + * (directory queries are not retried on 403). Both only work because the OSS + * edition now registers a `RoleResolver`. + * + * `role` alone is SCOPE-BLIND, so it is not sufficient on its own: a + * project-scoped key (the credential an agent carries) resolves to its owning + * user, and if that user happens to be an org admin the role check passes. A + * leaked agent key would then be able to rewrite group membership — the very + * substrate project access and policy identities hang off. Org-wide authority + * requires an org-wide credential, so project-scoped callers are rejected + * outright. + */ +export const orgGroupRoutes = () => { + const app = new Hono(); + app.use("*", auth({ requireProject: false, role: "admin" })); + app.use("*", async (c, next) => { + if (c.get("auth").scope === "project") { + throw new ServiceError( + "FORBIDDEN", + "Organization management requires an organization-scoped credential.", + ); + } + return next(); + }); + + // `organizationId` in every audit params below is deliberate: besides + // scoping the audit row it flushes the gateway's org cache + // (invalidateGatewayCacheForOrg). Group membership is exactly what the + // gateway's principal resolution reads — a missed flush becomes a stale + // authorization decision, so EVERY membership write must go through withAudit. + const auditBase = (c: Context) => ({ + organizationId: c.get("auth").organizationId, + userId: c.get("auth").userId, + userEmail: c.get("auth").userEmail, + service: AUDIT_SERVICES.GROUP, + source: AUDIT_SOURCE.API, + }); + + // GET /org/groups — cursor-paged, optionally filtered by source / free text. + app.get("/", async (c) => { + const auth = c.get("auth"); + const query = parse(groupListQuerySchema, c.req.query()); + return c.json(await listOrgGroups(auth.organizationId, query)); + }); + + // POST /org/groups — create a manual group. + app.post("/", async (c) => { + const auth = c.get("auth"); + const body = await c.req.json().catch(() => null); + const input = parse(createGroupSchema, body); + + const group = await withAudit( + () => createOrgGroup(auth.organizationId, input.name), + (created) => ({ + ...auditBase(c), + action: AUDIT_ACTIONS.CREATE, + metadata: { groupId: created.id, name: created.name }, + }), + ); + return c.json(group); + }); + + // PATCH /org/groups/:groupId — rename. + app.patch("/:groupId", async (c) => { + const auth = c.get("auth"); + const groupId = c.req.param("groupId"); + const body = await c.req.json().catch(() => null); + const input = parse(renameGroupSchema, body); + + const group = await withAudit( + () => renameOrgGroup(auth.organizationId, groupId, input.name), + (renamed) => ({ + ...auditBase(c), + action: AUDIT_ACTIONS.UPDATE, + metadata: { groupId: renamed.id, change: "name", name: renamed.name }, + }), + ); + return c.json(group); + }); + + // DELETE /org/groups/:groupId — the response carries the cascade impact + // (read before the delete) so the UI can report what went with the group. + app.delete("/:groupId", async (c) => { + const auth = c.get("auth"); + const groupId = c.req.param("groupId"); + + const result = await withAudit( + () => deleteOrgGroup(auth.organizationId, groupId), + (deleted) => ({ + ...auditBase(c), + action: AUDIT_ACTIONS.DELETE, + // Counts only, never id arrays — audit metadata must stay bounded. + metadata: { + groupId: deleted.id, + name: deleted.name, + removedMembers: deleted.removedMembers, + removedProjectBindings: deleted.removedProjectBindings, + }, + }), + ); + return c.json(result); + }); + + // GET /org/groups/:groupId/members — cursor-paged member list. + app.get("/:groupId/members", async (c) => { + const auth = c.get("auth"); + const groupId = c.req.param("groupId"); + const query = parse(directoryListQuerySchema, c.req.query()); + return c.json( + await listOrgGroupMembers(auth.organizationId, groupId, query), + ); + }); + + // PUT /org/groups/:groupId/members — bulk replace-set (the dialog's save). + // Every PUT returns a JSON body: the client's apiPut ALWAYS parses, so a + // 204 here would throw in the browser. + app.put("/:groupId/members", async (c) => { + const auth = c.get("auth"); + const groupId = c.req.param("groupId"); + const body = await c.req.json().catch(() => null); + const input = parse(setGroupMembersSchema, body); + + const result = await withAudit( + () => + setOrgGroupMembers( + auth.organizationId, + auth.userId, + groupId, + input.userIds, + ), + (delta) => ({ + ...auditBase(c), + action: AUDIT_ACTIONS.UPDATE, + metadata: { + groupId, + change: "members", + added: delta.added, + removed: delta.removed, + }, + }), + ); + return c.json(result); + }); + + // PUT /org/groups/:groupId/members/:userId — idempotent single add. + app.put("/:groupId/members/:userId", async (c) => { + const auth = c.get("auth"); + const groupId = c.req.param("groupId"); + const userId = c.req.param("userId"); + + const result = await withAudit( + () => + addOrgGroupMember(auth.organizationId, auth.userId, groupId, userId), + (r) => ({ + ...auditBase(c), + action: AUDIT_ACTIONS.UPDATE, + metadata: { groupId, change: "members", userId, added: r.added }, + }), + ); + return c.json(result); + }); + + // DELETE /org/groups/:groupId/members/:userId — idempotent single remove. + app.delete("/:groupId/members/:userId", async (c) => { + const auth = c.get("auth"); + const groupId = c.req.param("groupId"); + const userId = c.req.param("userId"); + + const result = await withAudit( + () => removeOrgGroupMember(auth.organizationId, groupId, userId), + (r) => ({ + ...auditBase(c), + action: AUDIT_ACTIONS.UPDATE, + metadata: { groupId, change: "members", userId, removed: r.removed }, + }), + ); + return c.json(result); + }); + + return app; +}; diff --git a/packages/api/src/routes/org/index.ts b/packages/api/src/routes/org/index.ts index 1cbcbff7..57c3ccfc 100644 --- a/packages/api/src/routes/org/index.ts +++ b/packages/api/src/routes/org/index.ts @@ -2,6 +2,7 @@ import type { Hono } from "hono"; import type { ApiEnv } from "../../types"; import { orgMemberRoutes } from "./members"; import { orgInvitationRoutes } from "./invitations"; +import { orgGroupRoutes } from "./groups"; /** * The OSS edition's `/v1/org/*` surface. @@ -21,4 +22,5 @@ import { orgInvitationRoutes } from "./invitations"; export const registerOssOrgRoutes = (app: Hono) => { app.route("/org/members", orgMemberRoutes()); app.route("/org/invitations", orgInvitationRoutes()); + app.route("/org/groups", orgGroupRoutes()); }; diff --git a/packages/api/src/services/audit-service.ts b/packages/api/src/services/audit-service.ts index c5db2cf5..1c4e7486 100644 --- a/packages/api/src/services/audit-service.ts +++ b/packages/api/src/services/audit-service.ts @@ -59,7 +59,10 @@ export const AUDIT_SERVICES = { // ACCEPTANCE is deliberately not here: accepting creates a membership, so it // audits as a MEMBER create with `via: "invitation"` metadata. INVITATION: "invitation", - // EE-only (directory): human groups (manual + SCIM-provisioned) + // Directory: human groups. OSS writes them via `/v1/org/groups` (create / + // rename / delete; membership changes audit as UPDATE with + // `change: "members"` — there is deliberately no GROUP_MEMBER service, + // matching the INVITATION→MEMBER precedent); EE adds SCIM-provisioned writes. GROUP: "group", // EE-only (directory): group→org-role mappings (the mapping config itself; // the member role changes it drives are audited under MEMBER). diff --git a/packages/api/src/services/org-group-service.ts b/packages/api/src/services/org-group-service.ts new file mode 100644 index 00000000..aa4ff9b8 --- /dev/null +++ b/packages/api/src/services/org-group-service.ts @@ -0,0 +1,497 @@ +import { db, Prisma } from "@onecli/db"; +import { ServiceError } from "./errors"; +import { + clampDirectoryLimit, + decodeCursor, + toDirectoryPage, + type DirectoryPage, +} from "../lib/cursor"; +import type { GroupListQuery } from "../validations/org"; + +// The org's human-group directory: list/create/rename/delete plus the three +// membership writers. Scoped to ONE organization on every call — the caller's +// `auth.organizationId`, never a body/query parameter — so this can never +// read or write across orgs. +// +// `source` is read-only: creates hard-code "manual", and "scim" rows +// (IdP-provisioned in EE) reject every mutation with 409 — the dashboard must +// never fight the IdP over a provisioned group. + +/** One row of the groups directory (matches the client's `GroupRow`). */ +export interface GroupListRow { + id: string; + name: string; + source: string; + externalId: string | null; + memberCount: number; + createdAt: string; + updatedAt: string; +} + +/** One row of a group's member list (matches the client's `GroupMemberRow`). */ +export interface GroupMemberListRow { + userId: string; + email: string; + name: string | null; + addedAt: string; +} + +/** What a delete actually removed — the cascade impact, read BEFORE the delete. */ +export interface GroupDeleteResult { + id: string; + name: string; + removedMembers: number; + removedProjectBindings: number; +} + +export type ListOrgGroupsParams = Partial; + +/** + * Same keyset shape as the invitations directory: ordered `createdAt asc, + * id asc` and paged by that exact two-part key, since `createdAt` alone is + * not unique. + */ +const CURSOR_PARTS = 2; + +const cursorFilter = (raw: string | undefined) => { + const parts = decodeCursor(raw, CURSOR_PARTS); + if (!parts) return undefined; + const [createdAtIso, id] = parts; + if (createdAtIso === undefined || id === undefined) return undefined; + const createdAt = new Date(createdAtIso); + // A cursor whose timestamp half is not a date is malformed — serve page one + // rather than handing an Invalid Date to the query layer. + if (Number.isNaN(createdAt.getTime())) return undefined; + return { + OR: [{ createdAt: { gt: createdAt } }, { createdAt, id: { gt: id } }], + }; +}; + +/** Member pages are keyed `createdAt asc, userId asc` (composite PK, no id). */ +const memberCursorFilter = (raw: string | undefined) => { + const parts = decodeCursor(raw, CURSOR_PARTS); + if (!parts) return undefined; + const [createdAtIso, userId] = parts; + if (createdAtIso === undefined || userId === undefined) return undefined; + const createdAt = new Date(createdAtIso); + if (Number.isNaN(createdAt.getTime())) return undefined; + return { + OR: [ + { createdAt: { gt: createdAt } }, + { createdAt, userId: { gt: userId } }, + ], + }; +}; + +export const listOrgGroups = async ( + organizationId: string, + params: ListOrgGroupsParams = {}, +): Promise> => { + const limit = clampDirectoryLimit(params.limit); + const after = cursorFilter(params.cursor); + const q = params.q?.trim(); + + const rows = await db.group.findMany({ + where: { + organizationId, + ...(params.source ? { source: params.source } : {}), + ...(q ? { name: { contains: q, mode: "insensitive" as const } } : {}), + // The keyset predicate lives under AND, never as a top-level `OR` spread: + // a future filter that also needs `OR` would otherwise overwrite the + // cursor clause and silently restart pagination from the first page. + ...(after ? { AND: [after] } : {}), + }, + select: { + id: true, + name: true, + source: true, + externalId: true, + createdAt: true, + updatedAt: true, + _count: { select: { members: true } }, + }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + take: limit + 1, + }); + + const groups: GroupListRow[] = rows.map((row) => ({ + id: row.id, + name: row.name, + source: row.source, + externalId: row.externalId, + memberCount: row._count.members, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + })); + + return toDirectoryPage(groups, limit, (row) => [row.createdAt, row.id]); +}; + +/** + * Resolve a group WITHIN the caller's org — always + * `findFirst({ id, organizationId })`, NEVER `findUnique({ where: { id } })`: + * a cross-org id must read as absent (404), not leak another org's row. + */ +const requireGroup = async (organizationId: string, groupId: string) => { + const group = await db.group.findFirst({ + where: { id: groupId, organizationId }, + select: { id: true, name: true, source: true }, + }); + if (!group) throw new ServiceError("NOT_FOUND", "Group not found."); + return group; +}; + +/** Mutations additionally require manual provenance ("scim" rows are IdP-owned). */ +const requireManualGroup = async (organizationId: string, groupId: string) => { + const group = await requireGroup(organizationId, groupId); + if (group.source !== "manual") { + throw new ServiceError( + "CONFLICT", + "This group is managed by your identity provider and cannot be changed here.", + ); + } + return group; +}; + +const isUniqueViolation = (err: unknown) => + err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002"; + +export const createOrgGroup = async ( + organizationId: string, + name: string, +): Promise => { + // Friendly pre-check for the common case; the P2002 catch below covers the + // create-create race the pre-check cannot see. + const dupe = await db.group.findFirst({ + where: { organizationId, name }, + select: { id: true }, + }); + if (dupe) { + throw new ServiceError( + "CONFLICT", + "A group with this name already exists.", + ); + } + + try { + const row = await db.group.create({ + // `source` is hard-coded: a create can never mint a "scim" row. + data: { organizationId, name, source: "manual" }, + select: { + id: true, + name: true, + source: true, + externalId: true, + createdAt: true, + updatedAt: true, + }, + }); + return { + id: row.id, + name: row.name, + source: row.source, + externalId: row.externalId, + memberCount: 0, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; + } catch (err) { + if (isUniqueViolation(err)) { + throw new ServiceError( + "CONFLICT", + "A group with this name already exists.", + ); + } + throw err; + } +}; + +export const renameOrgGroup = async ( + organizationId: string, + groupId: string, + name: string, +): Promise => { + const group = await requireManualGroup(organizationId, groupId); + + // Rename-to-self is a permitted no-op (a 409 here would make the rename + // dialog's "Save without edits" an error). + if (group.name !== name) { + const dupe = await db.group.findFirst({ + where: { organizationId, name, id: { not: groupId } }, + select: { id: true }, + }); + if (dupe) { + throw new ServiceError( + "CONFLICT", + "A group with this name already exists.", + ); + } + } + + // Org-scoped conditional write (the delete path's deleteMany pattern) — a + // count of 0 means the row vanished between the check and the write, which + // is a 404, not the P2025 500 a bare update() would surface. + try { + const { count } = await db.group.updateMany({ + where: { id: groupId, organizationId }, + data: { name }, + }); + if (count === 0) throw new ServiceError("NOT_FOUND", "Group not found."); + } catch (err) { + if (isUniqueViolation(err)) { + throw new ServiceError( + "CONFLICT", + "A group with this name already exists.", + ); + } + throw err; + } + + const row = await db.group.findFirst({ + where: { id: groupId, organizationId }, + select: { + id: true, + name: true, + source: true, + externalId: true, + createdAt: true, + updatedAt: true, + _count: { select: { members: true } }, + }, + }); + if (!row) throw new ServiceError("NOT_FOUND", "Group not found."); + return { + id: row.id, + name: row.name, + source: row.source, + externalId: row.externalId, + memberCount: row._count.members, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +}; + +/** + * Delete a group. The DB cascades take everything down with the row — + * GroupMember, the group's ProjectAccess bindings, its GroupRoleMapping, and + * PolicyRuleIdentity.group rows — so the impact is read FIRST and returned: + * the project-access cascade is a SILENT access revocation, and the confirm + * dialog must be able to say what went with the group. + * + * NOTE (reconciliation Stage C): the OSS grants engine treats a rule identity + * orphaned by an FK cascade as INERT — it is skipped at compile time, never + * widened to "any principal" (see grants-service). So a group delete needs no + * explicit orphan-neutralization pass here; the identity rows simply cascade + * away and the rules that referenced them lose one target. Role automation + * (group→org-role mappings) is likewise not a live OSS concept, so there is no + * mapping re-resolution to run. Both integrations belong to later stages. + */ +export const deleteOrgGroup = async ( + organizationId: string, + groupId: string, +): Promise => { + const group = await db.group.findFirst({ + where: { id: groupId, organizationId }, + select: { + id: true, + name: true, + source: true, + _count: { select: { members: true, projectAccess: true } }, + }, + }); + if (!group) throw new ServiceError("NOT_FOUND", "Group not found."); + if (group.source !== "manual") { + throw new ServiceError( + "CONFLICT", + "This group is managed by your identity provider and cannot be changed here.", + ); + } + + // Org-scoped conditional delete: a count of 0 means the row vanished (or + // never belonged to this org) between the read and the write — 404 either + // way, never a cross-org delete. The DB cascades (GroupMember, ProjectAccess, + // GroupRoleMapping, PolicyRuleIdentity) run with the row. + const { count } = await db.group.deleteMany({ + where: { id: groupId, organizationId }, + }); + if (count === 0) throw new ServiceError("NOT_FOUND", "Group not found."); + + return { + id: group.id, + name: group.name, + removedMembers: group._count.members, + removedProjectBindings: group._count.projectAccess, + }; +}; + +export interface ListOrgGroupMembersParams { + limit?: number; + cursor?: string; + q?: string; +} + +export const listOrgGroupMembers = async ( + organizationId: string, + groupId: string, + params: ListOrgGroupMembersParams = {}, +): Promise> => { + await requireGroup(organizationId, groupId); + const limit = clampDirectoryLimit(params.limit); + const after = memberCursorFilter(params.cursor); + const q = params.q?.trim(); + + const rows = await db.groupMember.findMany({ + where: { + groupId, + ...(q + ? { + user: { + OR: [ + { email: { contains: q, mode: "insensitive" as const } }, + { name: { contains: q, mode: "insensitive" as const } }, + ], + }, + } + : {}), + // Keyset predicate under AND — see listOrgGroups. + ...(after ? { AND: [after] } : {}), + }, + select: { + userId: true, + createdAt: true, + user: { select: { email: true, name: true } }, + }, + orderBy: [{ createdAt: "asc" }, { userId: "asc" }], + take: limit + 1, + }); + + const members: GroupMemberListRow[] = rows.map((row) => ({ + userId: row.userId, + email: row.user.email, + name: row.user.name, + addedAt: row.createdAt.toISOString(), + })); + + return toDirectoryPage(members, limit, (row) => [row.addedAt, row.userId]); +}; + +/** + * THE security invariant of every membership write: `GroupMember.userId` FKs + * the GLOBAL `User` table, so the org scope exists ONLY in this check. Every + * id must resolve to a member of the caller's org — one foreign id in the set + * and the whole write is rejected, or a group could capture users from + * another organization. + * + * Suspended members are deliberately allowed: suspension is an AUTH-time + * gate, and stripping group rows on suspend would silently rewrite the + * member's access shape on reinstate. + */ +const assertOrgMembers = async (organizationId: string, userIds: string[]) => { + if (userIds.length === 0) return; + const rows = await db.organizationMember.findMany({ + where: { organizationId, userId: { in: userIds } }, + select: { userId: true }, + }); + const known = new Set(rows.map((row) => row.userId)); + if (userIds.some((id) => !known.has(id))) { + throw new ServiceError( + "BAD_REQUEST", + "One or more users are not members of this organization.", + ); + } +}; + +/** + * Replace the group's member set. Returns the honest delta; a no-delta call + * returns `{ added: 0, removed: 0 }` WITHOUT opening a transaction (and the + * route's audit/flush still runs — cheap, and simpler than making withAudit + * conditional). + */ +export const setOrgGroupMembers = async ( + organizationId: string, + actorUserId: string, + groupId: string, + userIds: string[], +): Promise<{ added: number; removed: number }> => { + await requireManualGroup(organizationId, groupId); + + const targetIds = [...new Set(userIds)]; + await assertOrgMembers(organizationId, targetIds); + + const target = new Set(targetIds); + const currentRows = await db.groupMember.findMany({ + where: { groupId }, + select: { userId: true }, + }); + const current = new Set(currentRows.map((row) => row.userId)); + + const toAdd = [...target].filter((id) => !current.has(id)); + const toRemove = [...current].filter((id) => !target.has(id)); + + if (toAdd.length === 0 && toRemove.length === 0) { + return { added: 0, removed: 0 }; + } + + await db.$transaction([ + db.groupMember.deleteMany({ + where: { groupId, userId: { in: toRemove } }, + }), + // skipDuplicates makes a concurrent double-add idempotent (composite PK) + // instead of surfacing a P2002. + db.groupMember.createMany({ + data: toAdd.map((userId) => ({ + groupId, + userId, + createdByUserId: actorUserId, + })), + skipDuplicates: true, + }), + ]); + + return { added: toAdd.length, removed: toRemove.length }; +}; + +/** + * Idempotent single add (the scripting surface). `added` is honest: false + * when the membership already existed. + */ +export const addOrgGroupMember = async ( + organizationId: string, + actorUserId: string, + groupId: string, + userId: string, +): Promise<{ added: boolean }> => { + await requireManualGroup(organizationId, groupId); + await assertOrgMembers(organizationId, [userId]); + + const existing = await db.groupMember.findUnique({ + where: { groupId_userId: { groupId, userId } }, + select: { userId: true }, + }); + + await db.groupMember.upsert({ + where: { groupId_userId: { groupId, userId } }, + create: { groupId, userId, createdByUserId: actorUserId }, + update: {}, + }); + + return { added: !existing }; +}; + +/** + * Idempotent single remove: a missing membership is NOT a 404 (`removed: + * false`) — only a missing/cross-org GROUP is. + */ +export const removeOrgGroupMember = async ( + organizationId: string, + groupId: string, + userId: string, +): Promise<{ removed: boolean }> => { + await requireManualGroup(organizationId, groupId); + + const { count } = await db.groupMember.deleteMany({ + where: { groupId, userId }, + }); + + return { removed: count > 0 }; +}; diff --git a/packages/api/src/validations/org.ts b/packages/api/src/validations/org.ts index 1e42d62e..fe4fbaed 100644 --- a/packages/api/src/validations/org.ts +++ b/packages/api/src/validations/org.ts @@ -62,6 +62,42 @@ export const createInvitationSchema = z.object({ export type CreateInvitationInput = z.infer; +// ── Groups ──────────────────────────────────────────────────────────────── + +/** + * Group provenance — a READ-side filter only. `source` is never accepted on a + * write: creates hard-code `"manual"`, and `"scim"` rows (IdP-provisioned in + * EE) reject every mutation with 409 so the dashboard can never fight the + * IdP over ownership of a provisioned group. + */ +export const groupSourceSchema = z.enum(["manual", "scim"]); + +/** Group display name — trimmed, 1–100 chars. */ +export const groupNameSchema = z.string().trim().min(1).max(100); + +/** + * Replace-set ceiling for a single group's membership. Deliberately NOT + * `DIRECTORY_LIMIT_MAX` (a page-size bound, 200): the members dialog drains + * every page and PUTs the full set back, so the write cap must comfortably + * exceed one page while still bounding the request body. + */ +export const MAX_GROUP_MEMBERS = 1000; + +export const groupListQuerySchema = directoryListQuerySchema.extend({ + source: groupSourceSchema.optional(), +}); + +export type GroupListQuery = z.infer; + +/** Body `source`/`externalId` are ignored by construction: not in the schema. */ +export const createGroupSchema = z.object({ name: groupNameSchema }); + +export const renameGroupSchema = z.object({ name: groupNameSchema }); + +export const setGroupMembersSchema = z.object({ + userIds: z.array(z.string().min(1)).max(MAX_GROUP_MEMBERS), +}); + /** * `PATCH /v1/org/members/:userId` accepts EXACTLY ONE change per request — * either a lifecycle change (`status`) or a role change (`role`). A body From 6b798cebf79be9b96a58ed27e9f27ad9f2b1d04c Mon Sep 17 00:00:00 2001 From: marcorivm Date: Wed, 29 Jul 2026 18:34:58 -0600 Subject: [PATCH 03/10] feat(api): re-land project access and role mappings onto 1.44.0 Reconciliation Stage D. /v1/projects (rename, access bindings replace-set, safe delete with pinned cascade and last-owner/stranding guards) plus /settings/project UI; /v1/org/role-mappings (CRUD, ordering, preview) with monotonic raise-only apply re-wired into the group membership writers via the applyRoleMappingsForGroup seam Stage C had stripped. Role changes audit under MEMBER, config under ROLE_MAPPING. +136 tests, no agent-group code, no migration. (Role-mappings management UI folds into Stage E.) --- .../project/_components/admin-only-notice.tsx | 22 + .../_components/delete-project-card.tsx | 155 ++ .../project/_components/local-mode-notice.tsx | 20 + .../_components/project-access-card.tsx | 419 ++++ .../_components/project-access-dialog.tsx | 435 ++++ .../project/_components/project-name-card.tsx | 94 + .../_components/project-settings-content.tsx | 98 + .../project/_components/read-only-notice.tsx | 20 + .../(dashboard)/settings/project/loading.tsx | 23 + .../app/(dashboard)/settings/project/page.tsx | 37 + apps/web/src/hooks/use-projects.ts | 11 +- apps/web/src/lib/api/client.ts | 29 +- apps/web/src/lib/api/index.ts | 1 + apps/web/src/lib/api/keys.ts | 5 + apps/web/src/lib/api/projects.ts | 7 +- packages/api/src/lib/gateway-invalidate.ts | 9 +- packages/api/src/routes/org/groups.test.ts | 549 ++++-- packages/api/src/routes/org/groups.ts | 6 +- packages/api/src/routes/org/index.ts | 19 +- packages/api/src/routes/org/projects.test.ts | 1747 +++++++++++++++++ packages/api/src/routes/org/projects.ts | 211 ++ .../api/src/routes/org/role-mappings.test.ts | 1248 ++++++++++++ packages/api/src/routes/org/role-mappings.ts | 200 ++ .../api/src/services/org-group-service.ts | 64 +- .../services/org-role-mapping-service.test.ts | 254 +++ .../src/services/org-role-mapping-service.ts | 789 ++++++++ .../src/services/organization-service.test.ts | 423 +++- .../api/src/services/organization-service.ts | 44 + .../src/services/project-access-service.ts | 385 ++++ .../api/src/services/project-service.test.ts | 78 + packages/api/src/services/project-service.ts | 387 ++++ packages/api/src/validations/org.ts | 78 + packages/api/src/validations/project.ts | 68 + 33 files changed, 7701 insertions(+), 234 deletions(-) create mode 100644 apps/web/src/app/(dashboard)/settings/project/_components/admin-only-notice.tsx create mode 100644 apps/web/src/app/(dashboard)/settings/project/_components/delete-project-card.tsx create mode 100644 apps/web/src/app/(dashboard)/settings/project/_components/local-mode-notice.tsx create mode 100644 apps/web/src/app/(dashboard)/settings/project/_components/project-access-card.tsx create mode 100644 apps/web/src/app/(dashboard)/settings/project/_components/project-access-dialog.tsx create mode 100644 apps/web/src/app/(dashboard)/settings/project/_components/project-name-card.tsx create mode 100644 apps/web/src/app/(dashboard)/settings/project/_components/project-settings-content.tsx create mode 100644 apps/web/src/app/(dashboard)/settings/project/_components/read-only-notice.tsx create mode 100644 apps/web/src/app/(dashboard)/settings/project/loading.tsx create mode 100644 apps/web/src/app/(dashboard)/settings/project/page.tsx create mode 100644 packages/api/src/routes/org/projects.test.ts create mode 100644 packages/api/src/routes/org/projects.ts create mode 100644 packages/api/src/routes/org/role-mappings.test.ts create mode 100644 packages/api/src/routes/org/role-mappings.ts create mode 100644 packages/api/src/services/org-role-mapping-service.test.ts create mode 100644 packages/api/src/services/org-role-mapping-service.ts create mode 100644 packages/api/src/services/project-access-service.ts create mode 100644 packages/api/src/services/project-service.test.ts create mode 100644 packages/api/src/services/project-service.ts create mode 100644 packages/api/src/validations/project.ts diff --git a/apps/web/src/app/(dashboard)/settings/project/_components/admin-only-notice.tsx b/apps/web/src/app/(dashboard)/settings/project/_components/admin-only-notice.tsx new file mode 100644 index 00000000..db4e627a --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/project/_components/admin-only-notice.tsx @@ -0,0 +1,22 @@ +import { Lock } from "lucide-react"; + +/** + * Rendered inside the sharing dialog when the candidate directories 403. + * `/v1/org/members` and `/v1/org/groups` are admin-only, so a project owner who + * is not an org admin can still SEE and prune the current bindings — they just + * cannot enumerate who else exists to add. The API is the authority; this is + * what its deterministic 403 looks like. + */ +export const AdminOnlyNotice = () => ( +
+
+ +
+

Admins only

+

+ Browsing the organization's members and groups requires an admin. Ask + an admin to share this project, or remove existing access from the list + behind this dialog. +

+
+); diff --git a/apps/web/src/app/(dashboard)/settings/project/_components/delete-project-card.tsx b/apps/web/src/app/(dashboard)/settings/project/_components/delete-project-card.tsx new file mode 100644 index 00000000..45e5606d --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/project/_components/delete-project-card.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Loader2 } from "lucide-react"; +import { toast } from "sonner"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@onecli/ui/components/card"; +import { Button } from "@onecli/ui/components/button"; +import { Input } from "@onecli/ui/components/input"; +import { Label } from "@onecli/ui/components/label"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@onecli/ui/components/alert-dialog"; +import type { Project } from "@/lib/api"; +import { useDeleteProject } from "@/hooks/use-projects"; + +export interface DeleteProjectCardProps { + project: Project; + canManage: boolean; +} + +/** + * `Project.name` is nullable (legacy `accounts` rows carry NULL), and those + * neglected projects are exactly the ones an admin wants gone — so the + * type-to-confirm gate falls back to a fixed literal instead of an empty string + * nobody can type. + */ +const FALLBACK_CONFIRMATION = "delete"; + +export const DeleteProjectCard = ({ + project, + canManage, +}: DeleteProjectCardProps) => { + const [open, setOpen] = useState(false); + const [confirmation, setConfirmation] = useState(""); + const remove = useDeleteProject(); + const router = useRouter(); + + const name = project.name?.trim() ?? ""; + const expected = name || FALLBACK_CONFIRMATION; + // Client-side only: `apiDelete` sends no body, so this is friction, not a + // check. The server's refusals (last project in the org, a member who would + // be left with none) are the real guards and their messages are toasted + // verbatim by the hook. + const confirmed = confirmation.trim() === expected; + + const handleOpenChange = (next: boolean) => { + if (next) setConfirmation(""); + setOpen(next); + }; + + const handleDelete = () => { + if (!confirmed || remove.isPending) return; + remove.mutate(project.id, { + onSuccess: () => { + setOpen(false); + toast.success("Project deleted"); + // The next request re-resolves a different default project. + router.replace("/overview"); + }, + }); + }; + + return ( + <> + + + Delete this project + + Agents, API keys, secrets, connections and policy rules in this + project are deleted permanently. Activity history is kept. This + cannot be undone. + + + + + + + + {/* 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. + + +
+ + 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 && ( + + + + )} + + + {!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. + + + )} + + + + + + + + {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"} +

+
+ +
+ ); + })} +
+ ) : ( +

+ 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 + + +
+
+
+ +
+

+ + {selectedCount} + {" "} + of {candidateCount} selected +

+
+ + / + +
+
+
+ + {/* 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.status === "suspended" && ( + + Suspended + + )} + +
+ ))} + + {filteredMembers.length === 0 && ( +

+ {memberCandidates.length === 0 + ? "Invite teammates from the Team page to share this project." + : `No people match “${search}”`} +

+ )} +
+
+
+ + +
+
+ {filteredGroups.map((row) => ( + + ))} + + {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. +

+ )} +
+ + +
+
+
+
+ ); +}; 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. + + + +
+ + setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleSave(); + }} + /> + {error && dirty && ( +

{error}

+ )} +
+ +
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/settings/project/_components/project-settings-content.tsx b/apps/web/src/app/(dashboard)/settings/project/_components/project-settings-content.tsx new file mode 100644 index 00000000..3999be51 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/project/_components/project-settings-content.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { Card } from "@onecli/ui/components/card"; +import { Skeleton } from "@onecli/ui/components/skeleton"; +import { useProject } from "@/hooks/use-projects"; +import { useProjectAccess } from "@/hooks/use-project-access"; +import { useOrgMembersList } from "@/hooks/use-org-members"; +import { ProjectNameCard } from "./project-name-card"; +import { ProjectAccessCard } from "./project-access-card"; +import { DeleteProjectCard } from "./delete-project-card"; +import { ReadOnlyNotice } from "./read-only-notice"; + +export interface ProjectSettingsContentProps { + projectId: string; + /** The signed-in user's DB id — the same id `ProjectAccessUserRow` carries. */ + userId: string; + /** Threaded from the RSC page (server-only auth mode); false = local mode. */ + sharingEnabled: boolean; +} + +export const ProjectSettingsContent = ({ + projectId, + userId, + sharingEnabled, +}: ProjectSettingsContentProps) => { + const project = useProject(projectId); + const access = useProjectAccess(projectId, sharingEnabled); + // Doubles as the ADMIN PROBE and as the sharing dialog's candidate feed (one + // query key, so the dialog reuses this fetch). `/v1/org/members` is + // admin-only, so a success means "org admin" and a 403 means "not". + const orgMembers = useOrgMembersList(sharingEnabled); + + // `canManage` is a DISPLAY hint, never an authorization decision — the API's + // 403 is the authority, and every mutation surfaces its message as a toast. + // Both signals come from data already fetched: an owner binding of my own, or + // a successful admin-only directory read. + const isOrgAdmin = orgMembers.isSuccess; + const holdsOwnerBinding = Boolean( + access.data?.users.some((u) => u.userId === userId && u.role === "owner"), + ); + // LOCAL MODE (`!sharingEnabled`, the default OSS self-host) short-circuits: + // both probes above are disabled queries there, so neither can ever answer. + // That single built-in identity is the organization's owner, so rename and + // delete stay live — only the sharing card degrades. The API still decides: + // `canManageProject` resolves the local identity's org role through the + // ossRoleResolver, and its 403 would surface as a toast. + const canManage = !sharingEnabled || isOrgAdmin || holdsOwnerBinding; + + // Both probes are also the reason the page waits: rendering before they + // settle would flash every control disabled for a legitimate owner (an + // orgMembers 403 settles as `isError`, so a non-admin does not wait twice). + const probesPending = + sharingEnabled && (orgMembers.isPending || access.isPending); + + if (project.isPending || probesPending) { + return ( + <> + {[1, 2, 3].map((i) => ( + +
+ + + +
+
+ ))} + + ); + } + + if (project.isError || !project.data) { + // A plain card: no retry, no toast — the failure is deterministic. + return ( + +

Couldn't load this project

+

+ Something went wrong fetching the project. Reload the page to try + again. +

+
+ ); + } + + return ( + <> + {!canManage && } + + + + + ); +}; diff --git a/apps/web/src/app/(dashboard)/settings/project/_components/read-only-notice.tsx b/apps/web/src/app/(dashboard)/settings/project/_components/read-only-notice.tsx new file mode 100644 index 00000000..4784bcee --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/project/_components/read-only-notice.tsx @@ -0,0 +1,20 @@ +import { Eye } from "lucide-react"; + +/** + * Rendered when the signed-in user may USE this project but not manage it — a + * member holding a plain use grant. Without it the page is a wall of silently + * disabled controls (the /team and /groups pages surface the same distinction + * with their admin-only notice). + */ +export const ReadOnlyNotice = () => ( +
+ +
+

You can view these settings

+

+ Only a project owner or an organization admin can rename this project, + change who can use it, or delete it. +

+
+
+); diff --git a/apps/web/src/app/(dashboard)/settings/project/loading.tsx b/apps/web/src/app/(dashboard)/settings/project/loading.tsx new file mode 100644 index 00000000..8827ada1 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/project/loading.tsx @@ -0,0 +1,23 @@ +import { Card } from "@onecli/ui/components/card"; +import { Skeleton } from "@onecli/ui/components/skeleton"; +import { PageHeader } from "@dashboard/page-header"; + +export default function ProjectSettingsLoading() { + return ( +
+ + {[1, 2, 3].map((i) => ( + +
+ + + +
+
+ ))} +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/settings/project/page.tsx b/apps/web/src/app/(dashboard)/settings/project/page.tsx new file mode 100644 index 00000000..c0ecaed1 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/project/page.tsx @@ -0,0 +1,37 @@ +import { Suspense } from "react"; +import type { Metadata } from "next"; +import { PageHeader } from "@dashboard/page-header"; +import { getAuthMode } from "@/lib/auth/auth-mode"; +import { resolveProjectContext } from "@/lib/actions/resolve-user"; +import { ProjectSettingsContent } from "./_components/project-settings-content"; + +export const metadata: Metadata = { + title: "Project", +}; + +export default async function ProjectSettingsPage() { + // Auth mode is server-only (fs-backed runtime config), so it is resolved here + // and threaded down (the /groups + /team precedent). Local mode has exactly + // one identity, so sharing is inert — rename and delete stay live. + const sharingEnabled = getAuthMode() !== "local"; + // OSS sends no `X-Project-Id`, and the client session carries no project id, + // so the active project is resolved here — through the SAME helper the server + // actions use, which gates identically to the API's `resolveProjectId`. + const { projectId, userId } = await resolveProjectContext(); + + return ( +
+ + + + +
+ ); +} diff --git a/apps/web/src/hooks/use-projects.ts b/apps/web/src/hooks/use-projects.ts index fa84f85d..1ca3f427 100644 --- a/apps/web/src/hooks/use-projects.ts +++ b/apps/web/src/hooks/use-projects.ts @@ -1,8 +1,9 @@ "use client"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { toast } from "sonner"; import { projects } from "@/lib/api"; +import { queryKeys } from "@/lib/api/keys"; // Project rename/delete go through the audited `/v1/projects/:id` routes. Delete // flushes the gateway cache for the removed keys server-side, so there is @@ -10,6 +11,14 @@ import { projects } from "@/lib/api"; // callers handle the on-success refresh/redirect themselves (as the old actions // did) rather than invalidating a query cache. +/** The current project's row (name/slug/createdAt) for the settings page. */ +export const useProject = (projectId: string | undefined) => + useQuery({ + queryKey: queryKeys.projects.detail(projectId ?? ""), + queryFn: () => projects.get(projectId ?? ""), + enabled: Boolean(projectId), + }); + export const useRenameProject = () => useMutation({ mutationFn: ({ id, name }: { id: string; name: string }) => diff --git a/apps/web/src/lib/api/client.ts b/apps/web/src/lib/api/client.ts index 405231ad..89ca8b6c 100644 --- a/apps/web/src/lib/api/client.ts +++ b/apps/web/src/lib/api/client.ts @@ -8,11 +8,30 @@ const extractErrorMessage = (body: Record, status: number) => { return `Request failed: ${status}`; }; +/** + * A failed API response. Still a plain `Error` (every `err instanceof Error` + * toast keeps working), plus the HTTP status — a 403 from an admin-only route + * is an EXPECTED outcome some surfaces render differently from a transport + * failure, and the message alone cannot tell them apart. + */ +export class ApiError extends Error { + readonly status: number; + + constructor(message: string, status: number) { + super(message); + this.name = "ApiError"; + this.status = status; + } +} + +const toApiError = (body: Record, status: number) => + new ApiError(extractErrorMessage(body, status), status); + export const apiGet = async (path: string): Promise => { const res = await apiFetch(path); if (!res.ok) { const body = await res.json().catch(() => ({})); - throw new Error(extractErrorMessage(body, res.status)); + throw toApiError(body, res.status); } return res.json(); }; @@ -24,7 +43,7 @@ export const apiPost = async (path: string, body: unknown): Promise => { }); if (!res.ok) { const data = await res.json().catch(() => ({})); - throw new Error(extractErrorMessage(data, res.status)); + throw toApiError(data, res.status); } return res.json(); }; @@ -36,7 +55,7 @@ export const apiPatch = async (path: string, body: unknown): Promise => { }); if (!res.ok) { const data = await res.json().catch(() => ({})); - throw new Error(extractErrorMessage(data, res.status)); + throw toApiError(data, res.status); } return res.json(); }; @@ -48,7 +67,7 @@ export const apiPut = async (path: string, body: unknown): Promise => { }); if (!res.ok) { const data = await res.json().catch(() => ({})); - throw new Error(extractErrorMessage(data, res.status)); + throw toApiError(data, res.status); } return res.json(); }; @@ -57,6 +76,6 @@ export const apiDelete = async (path: string): Promise => { const res = await apiFetch(path, { method: "DELETE" }); if (!res.ok) { const body = await res.json().catch(() => ({})); - throw new Error(extractErrorMessage(body, res.status)); + throw toApiError(body, res.status); } }; diff --git a/apps/web/src/lib/api/index.ts b/apps/web/src/lib/api/index.ts index 9b662576..08c53b62 100644 --- a/apps/web/src/lib/api/index.ts +++ b/apps/web/src/lib/api/index.ts @@ -108,4 +108,5 @@ export type { AppPermissionDefinitionSummary, } from "@onecli/api/apps/app-permissions/types"; export { apiGet, apiPost, apiPatch, apiPut, apiDelete } from "./client"; +export { ApiError } from "./client"; export { queryKeys } from "./keys"; diff --git a/apps/web/src/lib/api/keys.ts b/apps/web/src/lib/api/keys.ts index 4138a1c3..cecc88c7 100644 --- a/apps/web/src/lib/api/keys.ts +++ b/apps/web/src/lib/api/keys.ts @@ -70,6 +70,11 @@ export const queryKeys = { byProvider: (provider: string) => [...queryKeys.connections.all(), "provider", provider] as const, }, + projects: { + all: () => ["projects", ...scope()] as const, + detail: (projectId: string) => + [...queryKeys.projects.all(), projectId] as const, + }, projectAccess: { all: () => ["project-access", ...scope()] as const, list: (projectId: string) => diff --git a/apps/web/src/lib/api/projects.ts b/apps/web/src/lib/api/projects.ts index 5f946cb8..311e419e 100644 --- a/apps/web/src/lib/api/projects.ts +++ b/apps/web/src/lib/api/projects.ts @@ -1,6 +1,11 @@ -import { apiPatch, apiDelete } from "./client"; +import { apiGet, apiPatch, apiDelete } from "./client"; import type { Project } from "./types"; +// The project's own row. Nothing else in the API exposes a project's name +// (`GET /v1/auth/session` returns only `projectId`), so the settings page reads +// it here. +export const get = (id: string) => apiGet(`/v1/projects/${id}`); + export const rename = (id: string, name: string) => apiPatch(`/v1/projects/${id}`, { name }); diff --git a/packages/api/src/lib/gateway-invalidate.ts b/packages/api/src/lib/gateway-invalidate.ts index 334e8e13..6256031f 100644 --- a/packages/api/src/lib/gateway-invalidate.ts +++ b/packages/api/src/lib/gateway-invalidate.ts @@ -24,8 +24,13 @@ export const invalidateGatewayCache = (request: Request) => { /** * Flush the gateway's cached config for specific API keys directly. Use this - * when the keys are about to be — or have just been — deleted, so they can no - * longer be looked up from the database: capture them first, then flush. + * when the keys are about to be deleted, so they can no longer be looked up + * from the database: capture them, flush, THEN delete. + * + * The order is load-bearing. The gateway authenticates `/v1/cache/invalidate` + * by resolving the bearer through an uncached `find_api_key` query, so a key + * that has already been deleted cannot flush its own entry — the request just + * 401s and the rejection is swallowed. */ export const invalidateGatewayCacheForKeys = (keys: string[]) => { for (const key of keys) { diff --git a/packages/api/src/routes/org/groups.test.ts b/packages/api/src/routes/org/groups.test.ts index d901f7a9..a2b64d21 100644 --- a/packages/api/src/routes/org/groups.test.ts +++ b/packages/api/src/routes/org/groups.test.ts @@ -7,14 +7,7 @@ import type { ApiEnv } from "../../types"; // RoleResolver, and `CAPS.rbac` on. Admin callers arrive with an org API key // (whose key path re-checks admin through the resolver); the non-admin cases // use a session, since a non-admin's org key fails key authentication -// outright. (Same harness as invitations.test.ts / members.test.ts — cloned, -// not shared.) -// -// Reconciliation Stage C ships USER GROUPS ONLY. Role automation -// (group→org-role mappings) and policy-rule orphan neutralization are separate -// later stages and are NOT exercised here — the OSS grants engine already -// treats a rule identity orphaned by an FK cascade as inert, so a group delete -// needs no explicit neutralization pass. +// outright. (Same harness as invitations.test.ts — cloned, not shared.) const ORG = "org-1"; const OTHER_ORG = "org-2"; @@ -34,6 +27,7 @@ vi.hoisted(() => { interface MemberRow { organizationId: string; userId: string; + userEmail: string; role: string; status: string; ssoExempt: boolean; @@ -71,6 +65,17 @@ interface ProjectAccessRow { groupId: string; } +/** Full shape since Slice 5: the membership writers re-resolve these. */ +interface RoleMappingRow { + id: string; + organizationId: string; + groupId: string; + role: string; + priority: number; + createdAt: Date; + updatedAt: Date; +} + interface AuditRow { organizationId?: string; userId: string; @@ -86,6 +91,7 @@ const store = vi.hoisted(() => ({ groups: [] as GroupRow[], groupMembers: [] as GroupMemberRow[], projectAccess: [] as ProjectAccessRow[], + roleMappings: [] as RoleMappingRow[], audits: [] as AuditRow[], seq: 0, txCount: 0, @@ -126,6 +132,7 @@ vi.mock("@onecli/db", () => { createdAt?: boolean; updatedAt?: boolean; _count?: { select: { members?: boolean; projectAccess?: boolean } }; + roleMapping?: { select: { id: boolean } }; } interface GroupMemberWhere { groupId?: string | { in: string[] }; @@ -135,6 +142,12 @@ vi.mock("@onecli/db", () => { }; AND?: { OR: KeysetClause[] }[]; } + /** The role-mapping seam's reads (Slice 5). */ + interface MappingWhere { + id?: string; + organizationId?: string; + groupId?: string; + } interface OrgMemberWhere { organizationId?: string; userId?: string | { in: string[] }; @@ -191,8 +204,8 @@ vi.mock("@onecli/db", () => { return matchesKeyset(row, where.AND); }); - // Mirror Prisma's `select` (incl. `_count`) so a route can't accidentally - // leak a column the service didn't ask for. + // Mirror Prisma's `select` (incl. `_count` and the roleMapping relation) so + // a route can't accidentally leak a column the service didn't ask for. const pickGroup = (row: GroupRow, select?: GroupSelect) => { if (!select) return { ...row }; const picked: Record = {}; @@ -220,6 +233,10 @@ vi.mock("@onecli/db", () => { } picked._count = count; } + if (select.roleMapping) { + const mapping = store.roleMappings.find((rm) => rm.groupId === row.id); + picked.roleMapping = mapping ? { id: mapping.id } : null; + } return picked; }; @@ -297,118 +314,14 @@ vi.mock("@onecli/db", () => { return true; }); - const dbGroup = { - findFirst: async ({ - where, - select, - }: { - where: GroupWhere; - select?: GroupSelect; - }) => { - // Race simulation: the create pre-check (a name-keyed findFirst) - // misses, so the create itself must surface the P2002. - if (store.race && where.name !== undefined) return null; - const row = filterGroups(where)[0]; - return row ? pickGroup(row, select) : null; - }, - findMany: async ({ - where, - select, - take, - }: { - where: GroupWhere; - select?: GroupSelect; - take?: number; - }) => { - const rows = filterGroups(where) - .slice() - .sort( - (a, b) => - a.createdAt.getTime() - b.createdAt.getTime() || - a.id.localeCompare(b.id), - ); - const limited = take === undefined ? rows : rows.slice(0, take); - return limited.map((row) => pickGroup(row, select)); - }, - create: async ({ - data, - select, - }: { - data: { - organizationId: string; - name: string; - source: string; - externalId?: string | null; - }; - select?: GroupSelect; - }) => { - const dupe = store.groups.some( - (g) => g.organizationId === data.organizationId && g.name === data.name, - ); - if (dupe) { - throw new PrismaClientKnownRequestError( - "Unique constraint failed", - "P2002", - ); - } - const row: GroupRow = { - id: `g-${++store.seq}`, - organizationId: data.organizationId, - name: data.name, - source: data.source, - externalId: data.externalId ?? null, - createdAt: new Date(), - updatedAt: new Date(), - }; - store.groups.push(row); - return pickGroup(row, select); - }, - // Org-scoped conditional write (the rename path): unique violations - // surface as P2002, a filter miss as count 0. - updateMany: async ({ - where, - data, - }: { - where: GroupWhere; - data: { name: string }; - }) => { - const rows = filterGroups(where); - for (const row of rows) { - const dupe = store.groups.some( - (g) => - g.organizationId === row.organizationId && - g.name === data.name && - g.id !== row.id, - ); - if (dupe) { - throw new PrismaClientKnownRequestError( - "Unique constraint failed", - "P2002", - ); - } - row.name = data.name; - row.updatedAt = new Date(); - } - return { count: rows.length }; - }, - // Delete applies the DB cascades the shipped migration declares: - // GroupMember and ProjectAccess group bindings go with the row (as do - // GroupRoleMapping and PolicyRuleIdentity in stages that populate them). - deleteMany: async ({ where }: { where: GroupWhere }) => { - const rows = filterGroups(where); - for (const row of rows) { - store.groupMembers = store.groupMembers.filter( - (m) => m.groupId !== row.id, - ); - store.projectAccess = store.projectAccess.filter( - (pa) => pa.groupId !== row.id, - ); - } - const ids = new Set(rows.map((r) => r.id)); - store.groups = store.groups.filter((g) => !ids.has(g.id)); - return { count: rows.length }; - }, - }; + const filterMappings = (where: MappingWhere) => + store.roleMappings.filter( + (row) => + (where.id === undefined || row.id === where.id) && + (where.organizationId === undefined || + row.organizationId === where.organizationId) && + (where.groupId === undefined || row.groupId === where.groupId), + ); return { Prisma: { JsonNull: null, PrismaClientKnownRequestError }, @@ -489,24 +402,192 @@ vi.mock("@onecli/db", () => { : where.status.not === undefined || row.status !== where.status.not)), ) ?? null, - // THE membership-validation query: { organizationId, userId: { in } }. + // THE membership-validation query: { organizationId, userId: { in } } + // — plus the role-mapping apply's candidate read, which adds + // `role: { not: "owner" }` and selects role/userEmail. findMany: async ({ where, select, }: { where: OrgMemberWhere; - select?: { userId?: boolean; role?: boolean }; + select?: { userId?: boolean; role?: boolean; userEmail?: boolean }; }) => filterOrgMembers(where).map((row) => { if (!select) return { ...row }; const picked: Record = {}; if (select.userId) picked.userId = row.userId; if (select.role) picked.role = row.role; + if (select.userEmail) picked.userEmail = row.userEmail; return picked; }), + // The apply's role write. The `role: { not: "owner" }` predicate is + // honoured, or the "a mapping never demotes an owner" cases would be + // testing the mock rather than the service. + updateMany: async ({ + where, + data, + }: { + where: OrgMemberWhere; + data: { role: string }; + }) => { + const rows = filterOrgMembers(where); + for (const row of rows) row.role = data.role; + return { count: rows.length }; + }, count: async () => 0, }, - group: dbGroup, + // Slice 5: the membership writers re-resolve group→role mappings, so + // the seam needs the mapping table to read. + groupRoleMapping: { + findFirst: async ({ where }: { where: MappingWhere }) => + filterMappings(where)[0] ?? null, + findMany: async ({ + where, + select, + }: { + where: MappingWhere; + select?: Record; + }) => + filterMappings(where) + .slice() + .sort( + (a, b) => + a.priority - b.priority || + a.createdAt.getTime() - b.createdAt.getTime() || + a.id.localeCompare(b.id), + ) + .map((row) => { + if (!select) return { ...row }; + const picked: Record = {}; + for (const key of [ + "id", + "organizationId", + "groupId", + "role", + "priority", + "createdAt", + "updatedAt", + ] as const) { + if (select[key]) picked[key] = row[key]; + } + return picked; + }), + }, + group: { + findFirst: async ({ + where, + select, + }: { + where: GroupWhere; + select?: GroupSelect; + }) => { + // Race simulation: the create pre-check (a name-keyed findFirst) + // misses, so the create itself must surface the P2002. + if (store.race && where.name !== undefined) return null; + const row = filterGroups(where)[0]; + return row ? pickGroup(row, select) : null; + }, + findMany: async ({ + where, + select, + take, + }: { + where: GroupWhere; + select?: GroupSelect; + take?: number; + }) => { + const rows = filterGroups(where) + .slice() + .sort( + (a, b) => + a.createdAt.getTime() - b.createdAt.getTime() || + a.id.localeCompare(b.id), + ); + const limited = take === undefined ? rows : rows.slice(0, take); + return limited.map((row) => pickGroup(row, select)); + }, + create: async ({ + data, + select, + }: { + data: { + organizationId: string; + name: string; + source: string; + externalId?: string | null; + }; + select?: GroupSelect; + }) => { + const dupe = store.groups.some( + (g) => + g.organizationId === data.organizationId && g.name === data.name, + ); + if (dupe) { + throw new PrismaClientKnownRequestError( + "Unique constraint failed", + "P2002", + ); + } + const row: GroupRow = { + id: `g-${++store.seq}`, + organizationId: data.organizationId, + name: data.name, + source: data.source, + externalId: data.externalId ?? null, + createdAt: new Date(), + updatedAt: new Date(), + }; + store.groups.push(row); + return pickGroup(row, select); + }, + // Org-scoped conditional write (the rename path): unique violations + // surface as P2002, a filter miss as count 0. + updateMany: async ({ + where, + data, + }: { + where: GroupWhere; + data: { name: string }; + }) => { + const rows = filterGroups(where); + for (const row of rows) { + const dupe = store.groups.some( + (g) => + g.organizationId === row.organizationId && + g.name === data.name && + g.id !== row.id, + ); + if (dupe) { + throw new PrismaClientKnownRequestError( + "Unique constraint failed", + "P2002", + ); + } + row.name = data.name; + row.updatedAt = new Date(); + } + return { count: rows.length }; + }, + // Delete applies the DB cascades the shipped migration declares: + // GroupMember, ProjectAccess group bindings, GroupRoleMapping. + deleteMany: async ({ where }: { where: GroupWhere }) => { + const rows = filterGroups(where); + for (const row of rows) { + store.groupMembers = store.groupMembers.filter( + (m) => m.groupId !== row.id, + ); + store.projectAccess = store.projectAccess.filter( + (pa) => pa.groupId !== row.id, + ); + store.roleMappings = store.roleMappings.filter( + (rm) => rm.groupId !== row.id, + ); + } + const ids = new Set(rows.map((r) => r.id)); + store.groups = store.groups.filter((g) => !ids.has(g.id)); + return { count: rows.length }; + }, + }, groupMember: { findUnique: async ({ where, @@ -611,14 +692,9 @@ vi.mock("@onecli/db", () => { return data; }, }, - // The replace-set writer runs its delete+create under ONE array-form - // transaction; the delete path is a plain conditional deleteMany. - $transaction: async (arg: unknown) => { + $transaction: async (ops: Promise[]) => { store.txCount++; - if (typeof arg === "function") { - return (arg as (tx: unknown) => Promise)({ group: dbGroup }); - } - return Promise.all(arg as Promise[]); + return Promise.all(ops); }, }, }; @@ -650,6 +726,7 @@ const member = ( ): MemberRow => ({ organizationId, userId, + userEmail: `${userId}@example.com`, role, status: "active", ssoExempt: false, @@ -745,6 +822,20 @@ beforeEach(() => { }, ]; store.projectAccess = [{ id: "pa-1", projectId: "proj-1", groupId: "g-a" }]; + // A CONVERGED baseline: `member` is the weakest role, so this mapping can + // never raise anyone and every existing membership case stays a no-op on + // the Slice 5 seam. Cases that need a real apply promote it to "admin". + store.roleMappings = [ + { + id: "rm-1", + organizationId: ORG, + groupId: "g-a", + role: "member", + priority: 0, + createdAt: at(30), + updatedAt: at(30), + }, + ]; store.audits = []; store.seq = 100; store.txCount = 0; @@ -1103,11 +1194,14 @@ describe("DELETE /v1/org/groups/:groupId", () => { name: "Engineering", removedMembers: 2, removedProjectBindings: 1, + removedRoleMappings: 1, }); - // Cascades applied: membership and project bindings went with the group. + // Cascades applied: membership, project bindings, and the role mapping + // went with the group. expect(groupRow("g-a")).toBeUndefined(); expect(membersOf("g-a")).toEqual([]); expect(store.projectAccess.some((pa) => pa.groupId === "g-a")).toBe(false); + expect(store.roleMappings.some((rm) => rm.groupId === "g-a")).toBe(false); }); it("audits counts only — never id arrays", async () => { @@ -1122,6 +1216,7 @@ describe("DELETE /v1/org/groups/:groupId", () => { name: "Engineering", removedMembers: 2, removedProjectBindings: 1, + removedRoleMappings: 1, }, }); for (const value of Object.values(store.audits[0]?.metadata ?? {})) { @@ -1386,3 +1481,191 @@ describe("DELETE /v1/org/groups/:groupId/members/:userId (single remove)", () => expect(membersOf("g-scim")).toEqual([MEMBER]); }); }); + +// ── Slice 5 seam: group→role mappings ─────────────────────────────────── +// +// The membership writers re-resolve mapped org roles after their write. The +// contract they must honour is decision C: a mapping is a FLOOR — it can +// RAISE a member's org role and can never lower one. +describe("the role-mapping seam", () => { + const mapGroupToAdmin = (groupId: string) => { + store.roleMappings = [ + { + id: "rm-admin", + organizationId: ORG, + groupId, + role: "admin", + priority: 0, + createdAt: at(30), + updatedAt: at(30), + }, + ]; + }; + + const roleOf = (userId: string) => + store.members.find((m) => m.organizationId === ORG && m.userId === userId) + ?.role; + + const memberAudits = () => store.audits.filter((a) => a.service === "member"); + + it("raises a user added to an admin-mapped group, alongside the GROUP audit", async () => { + mapGroupToAdmin("g-a"); + const res = await putMembers("g-a", { userIds: [OWNER, ADMIN, MEMBER] }); + expect(res.status).toBe(200); + expect(roleOf(MEMBER)).toBe("admin"); + // The owner is untouchable and the acting admin is skipped. + expect(roleOf(OWNER)).toBe("owner"); + + expect(memberAudits()).toHaveLength(1); + expect(memberAudits()[0]).toMatchObject({ + organizationId: ORG, + userId: ADMIN, + action: "update", + service: "member", + source: "api", + metadata: { + targetUserId: MEMBER, + role: "admin", + previousRole: "member", + via: "role-mapping", + mappingId: "rm-admin", + groupId: "g-a", + trigger: "membership", + }, + }); + expect(store.audits.filter((a) => a.service === "group")).toHaveLength(1); + }); + + it("changes no roles when the group has no mapping of its own", async () => { + mapGroupToAdmin("g-a"); + const res = await putMembers("g-b", { userIds: [MEMBER] }); + expect(res.status).toBe(200); + expect(roleOf(MEMBER)).toBe("member"); + expect(memberAudits()).toHaveLength(0); + }); + + it("does NOT demote when a user is removed from an admin-mapped group", async () => { + mapGroupToAdmin("g-a"); + expect((await putMember("g-a", MEMBER)).status).toBe(200); + expect(roleOf(MEMBER)).toBe("admin"); + + store.audits = []; + const res = await deleteMember("g-a", MEMBER); + expect(res.status).toBe(200); + // The grant sticks: only PATCH /v1/org/members/:userId can lower a role. + expect(roleOf(MEMBER)).toBe("admin"); + expect(memberAudits()).toHaveLength(0); + }); + + // The removal paths feed the ids they just removed back into the apply, so a + // user who was being SHADOWED by this group's mapping is re-resolved against + // the mappings that still cover them. Without that they are absent from the + // post-write member read and stay under-privileged until some unrelated + // write happens to converge them. + const shadowThenAdmin = () => { + store.roleMappings = [ + { + id: "rm-shadow", + organizationId: ORG, + groupId: "g-a", + role: "member", + priority: 0, + createdAt: at(30), + updatedAt: at(30), + }, + { + id: "rm-admin", + organizationId: ORG, + groupId: "g-b", + role: "admin", + priority: 1, + createdAt: at(31), + updatedAt: at(31), + }, + ]; + store.groupMembers.push( + { + groupId: "g-a", + userId: MEMBER, + createdByUserId: ADMIN, + createdAt: at(24), + }, + { + groupId: "g-b", + userId: MEMBER, + createdByUserId: ADMIN, + createdAt: at(25), + }, + ); + }; + + it("UNSHADOWS a single remove: leaving the member-mapped group raises to admin", async () => { + shadowThenAdmin(); + expect(roleOf(MEMBER)).toBe("member"); + + const res = await deleteMember("g-a", MEMBER); + expect(res.status).toBe(200); + expect(roleOf(MEMBER)).toBe("admin"); + expect(memberAudits()).toHaveLength(1); + expect(memberAudits()[0]).toMatchObject({ + metadata: { + targetUserId: MEMBER, + role: "admin", + previousRole: "member", + via: "role-mapping", + mappingId: "rm-admin", + groupId: "g-b", + trigger: "membership", + }, + }); + }); + + it("UNSHADOWS a replace-set that DROPS a user, down to the last member", async () => { + shadowThenAdmin(); + // g-a keeps OWNER + ADMIN, neither of which the apply may touch — the + // dropped MEMBER is the only candidate, and only because the writer hands + // their id over. + const res = await putMembers("g-a", { userIds: [OWNER, ADMIN] }); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ removed: 1 }); + expect(roleOf(MEMBER)).toBe("admin"); + + // Again with the group emptied entirely: the removed ids are what keep the + // apply from short-circuiting on "this group has no members left". + const row = store.members.find((m) => m.userId === MEMBER); + if (row) row.role = "member"; + store.groupMembers.push({ + groupId: "g-a", + userId: MEMBER, + createdByUserId: ADMIN, + createdAt: at(26), + }); + const cleared = await putMembers("g-a", { userIds: [] }); + expect(cleared.status).toBe(200); + expect(membersOf("g-a")).toEqual([]); + expect(roleOf(MEMBER)).toBe("admin"); + }); + + it("a no-delta replace-set opens no transaction and changes no roles", async () => { + mapGroupToAdmin("g-a"); + const res = await putMembers("g-a", { userIds: [OWNER, ADMIN] }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ added: 0, removed: 0 }); + expect(store.txCount).toBe(0); + expect(memberAudits()).toHaveLength(0); + }); + + it("deleting a mapped group cascades the mapping but never reverts roles", async () => { + mapGroupToAdmin("g-a"); + expect((await putMember("g-a", MEMBER)).status).toBe(200); + expect(roleOf(MEMBER)).toBe("admin"); + + store.audits = []; + const res = await remove("g-a"); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ removedRoleMappings: 1 }); + expect(store.roleMappings).toHaveLength(0); + expect(roleOf(MEMBER)).toBe("admin"); + expect(memberAudits()).toHaveLength(0); + }); +}); diff --git a/packages/api/src/routes/org/groups.ts b/packages/api/src/routes/org/groups.ts index 243a348f..f931e055 100644 --- a/packages/api/src/routes/org/groups.ts +++ b/packages/api/src/routes/org/groups.ts @@ -123,7 +123,7 @@ export const orgGroupRoutes = () => { const groupId = c.req.param("groupId"); const result = await withAudit( - () => deleteOrgGroup(auth.organizationId, groupId), + () => deleteOrgGroup(auth.organizationId, auth.userId, groupId), (deleted) => ({ ...auditBase(c), action: AUDIT_ACTIONS.DELETE, @@ -133,6 +133,7 @@ export const orgGroupRoutes = () => { name: deleted.name, removedMembers: deleted.removedMembers, removedProjectBindings: deleted.removedProjectBindings, + removedRoleMappings: deleted.removedRoleMappings, }, }), ); @@ -205,7 +206,8 @@ export const orgGroupRoutes = () => { const userId = c.req.param("userId"); const result = await withAudit( - () => removeOrgGroupMember(auth.organizationId, groupId, userId), + () => + removeOrgGroupMember(auth.organizationId, auth.userId, groupId, userId), (r) => ({ ...auditBase(c), action: AUDIT_ACTIONS.UPDATE, diff --git a/packages/api/src/routes/org/index.ts b/packages/api/src/routes/org/index.ts index 57c3ccfc..d14c5b6c 100644 --- a/packages/api/src/routes/org/index.ts +++ b/packages/api/src/routes/org/index.ts @@ -3,9 +3,11 @@ import type { ApiEnv } from "../../types"; import { orgMemberRoutes } from "./members"; import { orgInvitationRoutes } from "./invitations"; import { orgGroupRoutes } from "./groups"; +import { orgRoleMappingRoutes } from "./role-mappings"; +import { ossProjectRoutes } from "./projects"; /** - * The OSS edition's `/v1/org/*` surface. + * The OSS edition's EDITION SURFACE: `/v1/org/*` PLUS `/v1/projects/*`. * * OSS-ONLY BY CONSTRUCTION. This is never registered in the shared * `createApiApp` route table: it is mounted through @@ -14,13 +16,22 @@ import { orgGroupRoutes } from "./groups"; * replaces with its own org router. Registering here rather than in `app.ts` * keeps the shared file free of edition-specific routes (upstream-merge * collisions) and avoids Hono's first-registration-wins silently shadowing an - * EE route with an OSS one. + * EE route with an OSS one — which is exactly why project administration is + * registered here too, even though its URL is not under `/org`. The exported + * name stays `registerOssOrgRoutes`: renaming it buys nothing and touches the + * init seam plus every route test. * - * Later org slices (invitations, groups, role mappings) append their - * `app.route(...)` line here. + * Mounting a sub-app re-registers its `use("*")` guards under the mount path, + * so each sub-app's guard stack covers every path beneath it — and only those. + * `/projects` therefore owns the whole `/v1/projects/*` namespace in OSS; do + * not register a second router there. + * + * Later org slices (role mappings, …) append their `app.route(...)` line here. */ export const registerOssOrgRoutes = (app: Hono) => { app.route("/org/members", orgMemberRoutes()); app.route("/org/invitations", orgInvitationRoutes()); app.route("/org/groups", orgGroupRoutes()); + app.route("/org/role-mappings", orgRoleMappingRoutes()); + app.route("/projects", ossProjectRoutes()); }; diff --git a/packages/api/src/routes/org/projects.test.ts b/packages/api/src/routes/org/projects.test.ts new file mode 100644 index 00000000..9768de89 --- /dev/null +++ b/packages/api/src/routes/org/projects.test.ts @@ -0,0 +1,1747 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Hono } from "hono"; +import type { ApiEnv } from "../../types"; + +// `/v1/projects` end-to-end through the real app: the OSS routes mounted on +// the `eeRoutes` seam, the OSS role resolver wired as the RoleResolver, and +// `CAPS.rbac` on. Same harness shape as groups.test.ts — cloned, not shared — +// except that `project_access` is a REAL table here rather than the +// `findFirst: async () => null` stub the org suites use: these rows are the +// authorization data three enforcement points read. +// +// Admin callers arrive with an org API key; the non-admin cases use a session, +// since a non-admin's org key fails key authentication outright. + +const ORG = "org-1"; +const OTHER_ORG = "org-2"; +const OWNER = "user-owner"; +const ADMIN = "user-admin"; +const MEMBER = "user-member"; +const MEMBER2 = "user-member2"; +const OUTSIDER = "user-outsider"; +/** A real User row with NO membership in either org (Decision J's filter). */ +const STRANGER = "user-stranger"; +const ADMIN_KEY = "oc_org_admin-key"; +const PROJECT_KEY = "oc_project-key-of-owner"; + +vi.hoisted(() => { + process.env.NEXT_PUBLIC_EDITION = "oss"; + process.env.SECRET_ENCRYPTION_KEY = "test-secret"; + process.env.OAUTH_STATE_SECRET = "test-secret"; +}); + +interface MemberRow { + organizationId: string; + userId: string; + role: string; + status: string; + createdAt: Date; +} + +interface UserRow { + id: string; + externalAuthId: string; + email: string; + name: string | null; +} + +interface ProjectRow { + id: string; + organizationId: string; + name: string | null; + slug: string | null; + createdByUserId: string | null; + createdAt: Date; +} + +interface GroupRow { + id: string; + organizationId: string; + name: string; + source: string; +} + +interface GroupMemberRow { + groupId: string; + userId: string; +} + +interface AccessRow { + id: string; + projectId: string; + userId: string | null; + groupId: string | null; + role: string; + createdByUserId: string | null; + createdAt: Date; +} + +/** Every project-child table this suite exercises shares this shape. */ +interface ChildRow { + id: string; + projectId: string; +} + +interface KeyRow extends ChildRow { + key: string; +} + +interface AuditRow { + organizationId?: string; + projectId?: string; + userId: string; + action: string; + service: string; + source: string; + metadata: Record; +} + +const store = vi.hoisted(() => ({ + users: [] as UserRow[], + members: [] as MemberRow[], + projects: [] as ProjectRow[], + groups: [] as GroupRow[], + groupMembers: [] as GroupMemberRow[], + projectAccess: [] as AccessRow[], + agents: [] as ChildRow[], + apiKeys: [] as KeyRow[], + secrets: [] as ChildRow[], + appConnections: [] as ChildRow[], + appConfigs: [] as ChildRow[], + policyRules: [] as ChildRow[], + policyRulesV2: [] as ChildRow[], + vaultConnections: [] as ChildRow[], + budgets: [] as ChildRow[], + onboardingSurveys: [] as ChildRow[], + audits: [] as AuditRow[], + seq: 0, + txCount: 0, + /** Which user the session provider resolves to (null = no session). */ + sessionUserId: null as string | null, +})); + +/** Gateway flushes are spied, never fetched: the DELETE path must hand the + * keys it captured BEFORE the delete to invalidateGatewayCacheForKeys. */ +const flushes = vi.hoisted(() => ({ + keys: [] as string[][], + orgs: [] as string[], + accounts: [] as string[], +})); + +vi.mock("../../lib/gateway-invalidate", () => ({ + invalidateGatewayCache: () => {}, + invalidateGatewayCacheForKeys: (keys: string[]) => { + flushes.keys.push(keys); + }, + invalidateGatewayCacheForAccount: (projectId: string) => { + flushes.accounts.push(projectId); + }, + invalidateGatewayCacheForOrg: (organizationId: string) => { + flushes.orgs.push(organizationId); + }, +})); + +vi.mock("@onecli/db", () => { + // ── where shapes these routes actually build ──────────────────────────── + interface StringFilter { + not?: string | null; + in?: string[]; + } + interface BindingClause { + userId?: string; + group?: { members: { some: { userId: string } } }; + } + interface ProjectWhere { + id?: string | StringFilter; + organizationId?: string; + createdByUserId?: string; + organization?: { + members: { some: { userId: string; status?: { not?: string } } }; + }; + accessBindings?: { some: { OR: BindingClause[] } }; + OR?: ProjectWhere[]; + } + interface AccessWhere { + projectId?: string; + userId?: string | StringFilter | null; + groupId?: string | StringFilter | null; + role?: string; + user?: { organizationMemberships: { some: { organizationId: string } } }; + group?: { organizationId: string }; + OR?: BindingClause[]; + } + + const matchesString = ( + value: string | null, + filter: string | StringFilter | null | undefined, + ): boolean => { + if (filter === undefined) return true; + if (filter === null) return value === null; + if (typeof filter === "string") return value === filter; + if (filter.in !== undefined) + return value !== null && filter.in.includes(value); + if ("not" in filter) { + if (filter.not === null) return value !== null; + return value !== filter.not; + } + return true; + }; + + /** Does `projectId` carry a binding satisfying any of the OR clauses? */ + const matchesBindingClause = (projectId: string, clauses: BindingClause[]) => + clauses.some((clause) => { + if (clause.userId !== undefined) { + return store.projectAccess.some( + (pa) => pa.projectId === projectId && pa.userId === clause.userId, + ); + } + const userId = clause.group?.members.some.userId; + if (userId === undefined) return false; + return store.projectAccess.some( + (pa) => + pa.projectId === projectId && + pa.groupId !== null && + store.groupMembers.some( + (gm) => gm.groupId === pa.groupId && gm.userId === userId, + ), + ); + }); + + const matchesProject = (row: ProjectRow, where: ProjectWhere): boolean => { + if (!matchesString(row.id, where.id)) return false; + if ( + where.organizationId !== undefined && + row.organizationId !== where.organizationId + ) + return false; + if ( + where.createdByUserId !== undefined && + row.createdByUserId !== where.createdByUserId + ) + return false; + if (where.organization) { + const { userId, status } = where.organization.members.some; + const membership = store.members.find( + (m) => m.organizationId === row.organizationId && m.userId === userId, + ); + if (!membership) return false; + if (status?.not !== undefined && membership.status === status.not) + return false; + } + if ( + where.accessBindings && + !matchesBindingClause(row.id, where.accessBindings.some.OR) + ) + return false; + if (where.OR && !where.OR.some((sub) => matchesProject(row, sub))) + return false; + return true; + }; + + const matchesAccess = (row: AccessRow, where: AccessWhere): boolean => { + if (where.projectId !== undefined && row.projectId !== where.projectId) + return false; + if (!matchesString(row.userId, where.userId)) return false; + if (!matchesString(row.groupId, where.groupId)) return false; + if (where.role !== undefined && row.role !== where.role) return false; + if (where.user) { + const organizationId = + where.user.organizationMemberships.some.organizationId; + const isMember = store.members.some( + (m) => m.userId === row.userId && m.organizationId === organizationId, + ); + if (!isMember) return false; + } + if (where.group) { + const group = store.groups.find((g) => g.id === row.groupId); + if (!group || group.organizationId !== where.group.organizationId) + return false; + } + if (where.OR && !matchesBindingClause(row.projectId, where.OR)) + return false; + return true; + }; + + interface AccessSelect { + id?: boolean; + userId?: boolean; + groupId?: boolean; + role?: boolean; + createdAt?: boolean; + user?: { select: { email?: boolean; name?: boolean } }; + group?: { + select: { + name?: boolean; + _count?: { select: { members?: boolean } }; + members?: { select: { userId?: boolean } }; + }; + }; + } + + const pickAccess = (row: AccessRow, select?: AccessSelect) => { + if (!select) return { ...row }; + const picked: Record = {}; + for (const key of [ + "id", + "userId", + "groupId", + "role", + "createdAt", + ] as const) { + if (select[key]) picked[key] = row[key]; + } + if (select.user) { + const user = store.users.find((u) => u.id === row.userId); + picked.user = user + ? { email: user.email, name: user.name } + : { email: "", name: null }; + } + if (select.group) { + const group = store.groups.find((g) => g.id === row.groupId); + const members = store.groupMembers.filter( + (gm) => gm.groupId === row.groupId, + ); + const value: Record = {}; + if (select.group.select.name) value.name = group?.name ?? ""; + if (select.group.select._count) + value._count = { members: members.length }; + if (select.group.select.members) + value.members = members.map((m) => ({ userId: m.userId })); + picked.group = group ? value : null; + } + return picked; + }; + + /** Every `projects`-child table shares count/deleteMany, keyed by projectId. */ + const childDelegate = ( + read: () => T[], + write: (rows: T[]) => void, + ) => ({ + count: async ({ where }: { where: { projectId: string } }) => + read().filter((row) => row.projectId === where.projectId).length, + deleteMany: async ({ where }: { where: { projectId: string } }) => { + const before = read().length; + write(read().filter((row) => row.projectId !== where.projectId)); + return { count: before - read().length }; + }, + }); + + const delegates = { + user: { + findUnique: async ({ + where, + select, + }: { + where: { id?: string; externalAuthId?: string }; + select?: Record; + }) => { + const user = store.users.find( + (u) => + (where.id !== undefined && u.id === where.id) || + (where.externalAuthId !== undefined && + u.externalAuthId === where.externalAuthId), + ); + if (!user) return null; + if (select?.organizationMemberships) { + return { + organizationMemberships: store.members + .filter((m) => m.userId === user.id) + .map((m) => ({ organizationId: m.organizationId })), + }; + } + return user; + }, + }, + organizationMember: { + findUnique: async ({ + where, + }: { + where: { + organizationId_userId: { organizationId: string; userId: string }; + }; + }) => { + const { organizationId, userId } = where.organizationId_userId; + return ( + store.members.find( + (m) => m.organizationId === organizationId && m.userId === userId, + ) ?? null + ); + }, + findFirst: async ({ + where, + }: { + where: { + organizationId?: string; + userId?: string; + status?: string | { not?: string }; + }; + }) => + store.members + .slice() + .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) + .find( + (row) => + (where.organizationId === undefined || + row.organizationId === where.organizationId) && + (where.userId === undefined || row.userId === where.userId) && + (where.status === undefined || + (typeof where.status === "string" + ? row.status === where.status + : where.status.not === undefined || + row.status !== where.status.not)), + ) ?? null, + findMany: async ({ + where, + }: { + where: { + organizationId?: string; + userId?: { in: string[] }; + status?: { not?: string }; + }; + }) => + store.members + .filter( + (row) => + (where.organizationId === undefined || + row.organizationId === where.organizationId) && + (where.userId === undefined || + where.userId.in.includes(row.userId)) && + (where.status?.not === undefined || + row.status !== where.status.not), + ) + .map((row) => ({ userId: row.userId })), + }, + project: { + findFirst: async ({ + where, + select, + }: { + where: ProjectWhere; + select?: Record; + }) => { + const row = store.projects + .slice() + .sort( + (a, b) => + a.createdAt.getTime() - b.createdAt.getTime() || + a.id.localeCompare(b.id), + ) + .find((p) => matchesProject(p, where)); + if (!row) return null; + if (!select) return { ...row }; + const picked: Record = {}; + for (const key of Object.keys(select)) { + if (select[key]) picked[key] = row[key as keyof ProjectRow]; + } + return picked; + }, + findUnique: async ({ + where, + select, + }: { + where: { id: string }; + select?: Record; + }) => { + const row = store.projects.find((p) => p.id === where.id); + if (!row) return null; + if (!select) return { ...row }; + const picked: Record = {}; + for (const key of Object.keys(select)) { + if (select[key]) picked[key] = row[key as keyof ProjectRow]; + } + return picked; + }, + count: async ({ where }: { where: ProjectWhere }) => + store.projects.filter((p) => matchesProject(p, where)).length, + updateMany: async ({ + where, + data, + }: { + where: ProjectWhere; + data: { name?: string }; + }) => { + const rows = store.projects.filter((p) => matchesProject(p, where)); + for (const row of rows) { + if (data.name !== undefined) row.name = data.name; + } + return { count: rows.length }; + }, + deleteMany: async ({ where }: { where: ProjectWhere }) => { + const rows = store.projects.filter((p) => matchesProject(p, where)); + const ids = new Set(rows.map((r) => r.id)); + store.projects = store.projects.filter((p) => !ids.has(p.id)); + // The DB CASCADEs that ride the project row: project_access and + // policy_rules_v2. + store.projectAccess = store.projectAccess.filter( + (pa) => !ids.has(pa.projectId), + ); + store.policyRulesV2 = store.policyRulesV2.filter( + (r) => !ids.has(r.projectId), + ); + return { count: rows.length }; + }, + }, + projectAccess: { + findFirst: async ({ + where, + select, + }: { + where: AccessWhere; + select?: AccessSelect; + }) => { + const row = store.projectAccess.find((pa) => matchesAccess(pa, where)); + return row ? pickAccess(row, select) : null; + }, + findMany: async ({ + where, + select, + take, + }: { + where: AccessWhere; + select?: AccessSelect; + take?: number; + }) => { + const rows = store.projectAccess + .filter((pa) => matchesAccess(pa, where)) + .sort( + (a, b) => + a.createdAt.getTime() - b.createdAt.getTime() || + a.id.localeCompare(b.id), + ); + const limited = take === undefined ? rows : rows.slice(0, take); + return limited.map((row) => pickAccess(row, select)); + }, + count: async ({ where }: { where: AccessWhere }) => + store.projectAccess.filter((pa) => matchesAccess(pa, where)).length, + updateMany: async ({ + where, + data, + }: { + where: AccessWhere; + data: { role: string }; + }) => { + const rows = store.projectAccess.filter((pa) => + matchesAccess(pa, where), + ); + for (const row of rows) row.role = data.role; + return { count: rows.length }; + }, + deleteMany: async ({ where }: { where: AccessWhere }) => { + const rows = store.projectAccess.filter((pa) => + matchesAccess(pa, where), + ); + const ids = new Set(rows.map((r) => r.id)); + store.projectAccess = store.projectAccess.filter( + (pa) => !ids.has(pa.id), + ); + return { count: ids.size }; + }, + createMany: async ({ + data, + }: { + data: { + projectId: string; + userId?: string; + groupId?: string; + role: string; + createdByUserId: string | null; + }[]; + skipDuplicates?: boolean; + }) => { + let count = 0; + for (const row of data) { + const dupe = store.projectAccess.some( + (pa) => + pa.projectId === row.projectId && + ((row.userId !== undefined && pa.userId === row.userId) || + (row.groupId !== undefined && pa.groupId === row.groupId)), + ); + if (dupe) continue; // skipDuplicates + store.projectAccess.push({ + id: `pa-${++store.seq}`, + projectId: row.projectId, + // Stored verbatim so the test can assert the exactly-one-of DB + // CHECK the mock itself cannot enforce. + userId: row.userId ?? null, + groupId: row.groupId ?? null, + role: row.role, + createdByUserId: row.createdByUserId, + createdAt: new Date(Date.UTC(2026, 1, 1, 0, store.seq)), + }); + count++; + } + return { count }; + }, + }, + group: { + findMany: async ({ + where, + }: { + where: { organizationId: string; id: { in: string[] } }; + }) => + store.groups + .filter( + (g) => + g.organizationId === where.organizationId && + where.id.in.includes(g.id), + ) + .map((g) => ({ id: g.id })), + }, + apiKey: { + findUnique: async ({ where }: { where: { key?: string } }) => { + if (where.key === ADMIN_KEY) + return { + userId: ADMIN, + organizationId: ORG, + scope: "organization", + }; + // A PROJECT-scoped key owned by the org's OWNER: it authenticates + // fine, which is exactly why the router needs its own scope guard. + if (where.key === PROJECT_KEY) + return { userId: OWNER, projectId: "proj-2" }; + return null; + }, + findFirst: async () => null, + findMany: async ({ + where, + }: { + where: { projectId?: string; project?: { organizationId: string } }; + }) => + store.apiKeys + .filter( + (row) => + where.projectId === undefined || + row.projectId === where.projectId, + ) + .map((row) => ({ key: row.key })), + ...childDelegate( + () => store.apiKeys, + (rows) => { + store.apiKeys = rows; + }, + ), + }, + agent: childDelegate( + () => store.agents, + (rows) => { + store.agents = rows; + }, + ), + secret: childDelegate( + () => store.secrets, + (rows) => { + store.secrets = rows; + }, + ), + appConnection: childDelegate( + () => store.appConnections, + (rows) => { + store.appConnections = rows; + }, + ), + appConfig: childDelegate( + () => store.appConfigs, + (rows) => { + store.appConfigs = rows; + }, + ), + policyRule: childDelegate( + () => store.policyRules, + (rows) => { + store.policyRules = rows; + }, + ), + policyRuleV2: childDelegate( + () => store.policyRulesV2, + (rows) => { + store.policyRulesV2 = rows; + }, + ), + vaultConnection: childDelegate( + () => store.vaultConnections, + (rows) => { + store.vaultConnections = rows; + }, + ), + budget: childDelegate( + () => store.budgets, + (rows) => { + store.budgets = rows; + }, + ), + onboardingSurvey: childDelegate( + () => store.onboardingSurveys, + (rows) => { + store.onboardingSurveys = rows; + }, + ), + auditLog: { + create: async ({ data }: { data: AuditRow }) => { + store.audits.push(data); + return data; + }, + }, + }; + + return { + Prisma: { JsonNull: null }, + db: { + ...delegates, + // Both forms: the access replace-set uses the array form, the delete + // cascade the interactive (callback) form. + $transaction: async (arg: unknown) => { + store.txCount++; + if (typeof arg === "function") { + return (arg as (tx: typeof delegates) => Promise)(delegates); + } + return Promise.all(arg as Promise[]); + }, + }, + }; +}); + +import { createApiApp } from "../../app"; +import { registerOssOrgRoutes } from "./index"; +import { ossRoleResolver } from "../../services/org-role-resolver"; + +const sessionProvider = { + getSession: async () => { + const user = store.users.find((u) => u.id === store.sessionUserId); + return user ? { id: user.externalAuthId, email: user.email } : null; + }, +}; + +const app: Hono = createApiApp(sessionProvider, { + eeRoutes: registerOssOrgRoutes, + roleResolver: ossRoleResolver, +}); + +const at = (minutes: number) => new Date(Date.UTC(2026, 0, 1, 0, minutes)); + +const member = ( + userId: string, + role: string, + createdAt: Date, + organizationId = ORG, +): MemberRow => ({ + organizationId, + userId, + role, + status: "active", + createdAt, +}); + +const access = ( + id: string, + projectId: string, + principal: { userId?: string; groupId?: string }, + role: string, + createdAt: Date, +): AccessRow => ({ + id, + projectId, + userId: principal.userId ?? null, + groupId: principal.groupId ?? null, + role, + createdByUserId: null, + createdAt, +}); + +beforeEach(() => { + store.users = [ + { + id: OWNER, + externalAuthId: "ext-owner", + email: "owner@example.com", + name: "Olive Owner", + }, + { + id: ADMIN, + externalAuthId: "ext-admin", + email: "admin@example.com", + name: "Adam Admin", + }, + { + id: MEMBER, + externalAuthId: "ext-member", + email: "member@example.com", + name: null, + }, + { + id: MEMBER2, + externalAuthId: "ext-member2", + email: "member2@example.com", + name: "Mia Member", + }, + { + id: OUTSIDER, + externalAuthId: "ext-outsider", + email: "outsider@other.test", + name: "Odette Outsider", + }, + { + id: STRANGER, + externalAuthId: "ext-stranger", + email: "stranger@nowhere.test", + name: "Sam Stranger", + }, + ]; + store.members = [ + member(OWNER, "owner", at(0)), + member(ADMIN, "admin", at(1)), + member(MEMBER, "member", at(2)), + member(MEMBER2, "member", at(3)), + member(OUTSIDER, "admin", at(4), OTHER_ORG), + ]; + store.projects = [ + { + id: "proj-1", + organizationId: ORG, + name: "Alpha", + slug: "alpha", + createdByUserId: MEMBER, + createdAt: at(0), + }, + { + id: "proj-2", + organizationId: ORG, + name: "Beta", + slug: "beta", + createdByUserId: OWNER, + createdAt: at(1), + }, + { + id: "proj-3", + organizationId: ORG, + name: "Gamma", + slug: "gamma", + createdByUserId: ADMIN, + createdAt: at(2), + }, + // No bindings at all — the legacy zero-binding shape (L5). + { + id: "proj-4", + organizationId: ORG, + name: "Delta", + slug: "delta", + createdByUserId: OWNER, + createdAt: at(3), + }, + { + id: "proj-x", + organizationId: OTHER_ORG, + name: "Foreign", + slug: "foreign", + createdByUserId: OUTSIDER, + createdAt: at(4), + }, + ]; + store.groups = [ + { id: "g-a", organizationId: ORG, name: "Engineering", source: "manual" }, + { id: "g-scim", organizationId: ORG, name: "Provisioned", source: "scim" }, + { id: "g-x", organizationId: OTHER_ORG, name: "Foreign", source: "manual" }, + ]; + store.groupMembers = [ + { groupId: "g-a", userId: MEMBER2 }, + { groupId: "g-x", userId: OUTSIDER }, + ]; + store.projectAccess = [ + access("pa-1", "proj-1", { userId: MEMBER }, "owner", at(10)), + // A GROUP row carrying role "owner" on purpose: group bindings must never + // confer management, whatever the column says. + access("pa-2", "proj-1", { groupId: "g-a" }, "owner", at(11)), + access("pa-3", "proj-1", { userId: ADMIN }, "member", at(12)), + access("pa-4", "proj-2", { userId: OWNER }, "owner", at(13)), + access("pa-5", "proj-2", { userId: MEMBER2 }, "member", at(14)), + access("pa-6", "proj-3", { userId: ADMIN }, "owner", at(15)), + // Inert rows, filtered out of GET /access (Decision J + the org fence). + access("pa-7", "proj-3", { userId: STRANGER }, "member", at(16)), + access("pa-8", "proj-3", { groupId: "g-x" }, "member", at(17)), + access("pa-9", "proj-x", { userId: OUTSIDER }, "owner", at(18)), + ]; + store.agents = [ + { id: "ag-1", projectId: "proj-1" }, + { id: "ag-2", projectId: "proj-1" }, + { id: "ag-3", projectId: "proj-2" }, + ]; + store.apiKeys = [ + { id: "k-1", projectId: "proj-1", key: "oc_key-1" }, + { id: "k-2", projectId: "proj-1", key: "oc_key-2" }, + { id: "k-3", projectId: "proj-2", key: "oc_key-3" }, + ]; + store.secrets = [ + { id: "s-1", projectId: "proj-1" }, + { id: "s-2", projectId: "proj-2" }, + ]; + store.appConnections = [{ id: "ac-1", projectId: "proj-1" }]; + store.appConfigs = [{ id: "cfg-1", projectId: "proj-1" }]; + store.policyRules = [{ id: "pr-1", projectId: "proj-1" }]; + store.policyRulesV2 = [{ id: "pv-1", projectId: "proj-1" }]; + store.vaultConnections = [{ id: "vc-1", projectId: "proj-1" }]; + store.budgets = [{ id: "b-1", projectId: "proj-1" }]; + store.onboardingSurveys = [{ id: "os-1", projectId: "proj-1" }]; + store.audits = []; + store.seq = 100; + store.txCount = 0; + store.sessionUserId = null; + flushes.keys = []; + flushes.orgs = []; + flushes.accounts = []; +}); + +const asAdmin = { headers: { Authorization: `Bearer ${ADMIN_KEY}` } }; +const asProjectKey = { headers: { Authorization: `Bearer ${PROJECT_KEY}` } }; + +const projectRow = (id: string) => store.projects.find((p) => p.id === id); +const bindings = (projectId: string) => + store.projectAccess.filter((pa) => pa.projectId === projectId); +const userBinding = (projectId: string, userId: string) => + bindings(projectId).find((pa) => pa.userId === userId); + +interface ProjectBody { + id: string; + name: string | null; + slug: string | null; + createdAt: string; +} + +interface AccessBody { + users: { + id: string; + userId: string; + name: string | null; + email: string; + role: string; + isOwner: boolean; + createdAt: string; + }[]; + groups: { + id: string; + groupId: string; + name: string; + memberCount: number; + createdAt: string; + }[]; +} + +const get = (id: string, init: RequestInit = asAdmin) => + app.request(`/v1/projects/${id}`, init); + +const patch = (id: string, body: unknown, init: RequestInit = asAdmin) => + app.request(`/v1/projects/${id}`, { + ...init, + method: "PATCH", + body: JSON.stringify(body), + }); + +const remove = (id: string, init: RequestInit = asAdmin) => + app.request(`/v1/projects/${id}`, { ...init, method: "DELETE" }); + +const getAccess = (id: string, init: RequestInit = asAdmin) => + app.request(`/v1/projects/${id}/access`, init); + +const putAccess = (id: string, body: unknown, init: RequestInit = asAdmin) => + app.request(`/v1/projects/${id}/access`, { + ...init, + method: "PUT", + body: JSON.stringify(body), + }); + +describe("guard stack", () => { + it("401s an unauthenticated caller on every route", async () => { + expect((await get("proj-1", {})).status).toBe(401); + expect((await patch("proj-1", { name: "X" }, {})).status).toBe(401); + expect((await remove("proj-1", {})).status).toBe(401); + expect((await getAccess("proj-1", {})).status).toBe(401); + expect( + (await putAccess("proj-1", { users: [], groupIds: [] }, {})).status, + ).toBe(401); + expect(store.audits).toHaveLength(0); + }); + + it("403s a project-scoped key on every route, even when its user is an org owner", async () => { + expect((await get("proj-2", asProjectKey)).status).toBe(403); + expect((await patch("proj-2", { name: "X" }, asProjectKey)).status).toBe( + 403, + ); + expect((await remove("proj-2", asProjectKey)).status).toBe(403); + expect((await getAccess("proj-2", asProjectKey)).status).toBe(403); + expect( + ( + await putAccess( + "proj-2", + { users: [{ userId: OWNER, role: "owner" }], groupIds: [] }, + asProjectKey, + ) + ).status, + ).toBe(403); + expect(projectRow("proj-2")?.name).toBe("Beta"); + expect(store.audits).toHaveLength(0); + }); + + it("200s a NON-ADMIN member holding an owner binding (the point of this stack)", async () => { + store.sessionUserId = MEMBER; + const res = await patch("proj-1", { name: "Renamed" }, {}); + expect(res.status).toBe(200); + expect(projectRow("proj-1")?.name).toBe("Renamed"); + }); + + it("403s an active member whose binding is a plain use grant", async () => { + store.sessionUserId = MEMBER2; // `member` binding on proj-2 + const res = await patch("proj-2", { name: "Nope" }, {}); + expect(res.status).toBe(403); + expect(projectRow("proj-2")?.name).toBe("Beta"); + expect(store.audits).toHaveLength(0); + }); + + it("403s a member whose only binding is a GROUP row, even at role owner", async () => { + store.sessionUserId = MEMBER2; // in g-a, which is bound to proj-1 as "owner" + const res = await patch("proj-1", { name: "Nope" }, {}); + expect(res.status).toBe(403); + expect(projectRow("proj-1")?.name).toBe("Alpha"); + }); + + it("rejects a suspended admin's org key (suspended reads as no role)", async () => { + const row = store.members.find((m) => m.userId === ADMIN); + if (row) row.status = "suspended"; + // The key path fails first, so this is a 401 rather than the 403 a + // suspended session would get — either way the stale binding on proj-3 + // never rescues them. + expect((await patch("proj-3", { name: "X" })).status).toBe(401); + expect(projectRow("proj-3")?.name).toBe("Gamma"); + }); + + it("rejects a suspended owner-binding holder's session", async () => { + const row = store.members.find((m) => m.userId === MEMBER); + if (row) row.status = "suspended"; + store.sessionUserId = MEMBER; + expect((await patch("proj-1", { name: "X" }, {})).status).toBe(401); + expect(projectRow("proj-1")?.name).toBe("Alpha"); + }); +}); + +describe("GET /v1/projects/:projectId", () => { + it("returns the project row with createdAt as an ISO string", async () => { + const res = await get("proj-1"); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + id: "proj-1", + name: "Alpha", + slug: "alpha", + createdAt: at(0).toISOString(), + }); + }); + + it("404s a project of another organization and an unknown id", async () => { + expect((await get("proj-x")).status).toBe(404); + expect((await get("proj-nope")).status).toBe(404); + }); + + it("200s a plain member holding a use-only binding", async () => { + store.sessionUserId = MEMBER2; // group binding on proj-1 + const res = await get("proj-1", {}); + expect(res.status).toBe(200); + expect(((await res.json()) as ProjectBody).id).toBe("proj-1"); + }); + + it("200s an org admin with no binding at all", async () => { + const res = await get("proj-4"); // zero bindings + expect(res.status).toBe(200); + }); + + it("403s an active member with no binding on the project", async () => { + store.sessionUserId = MEMBER2; + expect((await get("proj-3", {})).status).toBe(403); + }); + + it("never audits a read", async () => { + await get("proj-1"); + expect(store.audits).toHaveLength(0); + }); +}); + +describe("PATCH /v1/projects/:projectId", () => { + it("renames, returns the row, and audits with organizationId AND projectId", async () => { + const res = await patch("proj-1", { name: "Alpha Prime" }); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ + id: "proj-1", + name: "Alpha Prime", + slug: "alpha", + }); + expect(projectRow("proj-1")?.name).toBe("Alpha Prime"); + expect(store.audits).toHaveLength(1); + expect(store.audits[0]).toMatchObject({ + organizationId: ORG, + projectId: "proj-1", + userId: ADMIN, + action: "update", + service: "project", + source: "api", + metadata: { projectId: "proj-1", change: "name", name: "Alpha Prime" }, + }); + }); + + it("trims the name before storing", async () => { + const res = await patch("proj-1", { name: " Padded " }); + expect(res.status).toBe(200); + expect(projectRow("proj-1")?.name).toBe("Padded"); + }); + + it("422s an empty / whitespace-only / overlong / missing name", async () => { + for (const body of [ + { name: "" }, + { name: " " }, + { name: "x".repeat(101) }, + {}, + ]) { + expect((await patch("proj-1", body)).status).toBe(422); + } + expect(projectRow("proj-1")?.name).toBe("Alpha"); + expect(store.audits).toHaveLength(0); + }); + + it("422s an unparseable body rather than 500ing", async () => { + const res = await app.request("/v1/projects/proj-1", { + ...asAdmin, + method: "PATCH", + }); + expect(res.status).toBe(422); + }); + + it("permits a rename-to-self as a 200 no-op", async () => { + const res = await patch("proj-1", { name: "Alpha" }); + expect(res.status).toBe(200); + expect(projectRow("proj-1")?.name).toBe("Alpha"); + }); + + it("lets two projects in the same org share a name (the 'Default' reality)", async () => { + const res = await patch("proj-2", { name: "Alpha" }); + expect(res.status).toBe(200); + expect(projectRow("proj-1")?.name).toBe("Alpha"); + expect(projectRow("proj-2")?.name).toBe("Alpha"); + }); + + it("never writes slug", async () => { + await patch("proj-1", { name: "Alpha Prime", slug: "hijacked" }); + expect(projectRow("proj-1")?.slug).toBe("alpha"); + }); + + it("404s a project of another organization and audits nothing", async () => { + const res = await patch("proj-x", { name: "Captured" }); + expect(res.status).toBe(404); + expect(projectRow("proj-x")?.name).toBe("Foreign"); + expect(store.audits).toHaveLength(0); + }); + + it("403s a non-manager and writes/audits nothing", async () => { + store.sessionUserId = MEMBER2; + const res = await patch("proj-2", { name: "Nope" }, {}); + expect(res.status).toBe(403); + expect(projectRow("proj-2")?.name).toBe("Beta"); + expect(store.audits).toHaveLength(0); + }); +}); + +describe("GET /v1/projects/:projectId/access", () => { + it("returns users and groups in the client's exact shape, createdAt asc", async () => { + const res = await getAccess("proj-1"); + expect(res.status).toBe(200); + const body = (await res.json()) as AccessBody; + expect(body.users).toEqual([ + { + id: "pa-1", + userId: MEMBER, + name: null, + email: "member@example.com", + role: "owner", + isOwner: true, + createdAt: at(10).toISOString(), + }, + { + id: "pa-3", + userId: ADMIN, + name: "Adam Admin", + email: "admin@example.com", + role: "member", + isOwner: false, + createdAt: at(12).toISOString(), + }, + ]); + expect(body.groups).toEqual([ + { + id: "pa-2", + groupId: "g-a", + name: "Engineering", + memberCount: 1, + createdAt: at(11).toISOString(), + }, + ]); + }); + + it("normalizes a garbage role string to member instead of casting it", async () => { + const row = store.projectAccess.find((pa) => pa.id === "pa-3"); + if (row) row.role = "superuser"; + const body = (await (await getAccess("proj-1")).json()) as AccessBody; + expect(body.users.find((u) => u.userId === ADMIN)?.role).toBe("member"); + }); + + it("keeps isOwner as creator provenance, independent of the management role", async () => { + // Creator demoted, a non-creator promoted: the badge follows creation. + const creatorRow = store.projectAccess.find((pa) => pa.id === "pa-1"); + if (creatorRow) creatorRow.role = "member"; + const otherRow = store.projectAccess.find((pa) => pa.id === "pa-3"); + if (otherRow) otherRow.role = "owner"; + + const body = (await (await getAccess("proj-1")).json()) as AccessBody; + expect(body.users.find((u) => u.userId === MEMBER)).toMatchObject({ + role: "member", + isOwner: true, + }); + expect(body.users.find((u) => u.userId === ADMIN)).toMatchObject({ + role: "owner", + isOwner: false, + }); + }); + + it("excludes a user who is not a member of the org, and a group of another org", async () => { + const body = (await (await getAccess("proj-3")).json()) as AccessBody; + expect(body.users.map((u) => u.userId)).toEqual([ADMIN]); + expect(body.groups).toEqual([]); + }); + + it("includes a SUSPENDED member's row (suspension is an auth-time gate)", async () => { + const row = store.members.find((m) => m.userId === ADMIN); + if (row) row.status = "suspended"; + store.sessionUserId = MEMBER; // the suspended admin can no longer call in + const body = (await (await getAccess("proj-1", {})).json()) as AccessBody; + expect(body.users.map((u) => u.userId)).toEqual([MEMBER, ADMIN]); + }); + + it("returns empty arrays for a project with no bindings, not a 404", async () => { + const res = await getAccess("proj-4"); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ users: [], groups: [] }); + }); + + it("404s a project of another organization", async () => { + expect((await getAccess("proj-x")).status).toBe(404); + }); +}); + +describe("PUT /v1/projects/:projectId/access (replace-set)", () => { + it("applies the exact set and returns the aggregated delta", async () => { + // proj-1 currently: users {MEMBER owner, ADMIN member}, groups {g-a}. + const res = await putAccess("proj-1", { + users: [ + { userId: MEMBER, role: "owner" }, + { userId: MEMBER2, role: "member" }, + ], + groupIds: ["g-scim"], + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ added: 2, removed: 2, roleChanged: 0 }); + + expect( + bindings("proj-1") + .map((pa) => pa.userId ?? `group:${pa.groupId}`) + .sort(), + ).toEqual([MEMBER, MEMBER2, "group:g-scim"].sort()); + expect(store.txCount).toBe(1); + }); + + it("changes a role in place without recreating the row", async () => { + const before = userBinding("proj-1", MEMBER)?.id; + const res = await putAccess("proj-1", { + users: [ + { userId: MEMBER, role: "member" }, + { userId: ADMIN, role: "owner" }, + ], + groupIds: ["g-a"], + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ added: 0, removed: 0, roleChanged: 2 }); + // The row id (and therefore its createdAt provenance) survives. + expect(userBinding("proj-1", MEMBER)?.id).toBe(before); + expect(userBinding("proj-1", MEMBER)?.role).toBe("member"); + expect(userBinding("proj-1", ADMIN)?.role).toBe("owner"); + }); + + it("returns {0,0,0} for a no-op set WITHOUT opening a transaction", async () => { + const res = await putAccess("proj-1", { + users: [ + { userId: MEMBER, role: "owner" }, + { userId: ADMIN, role: "member" }, + ], + groupIds: ["g-a"], + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ added: 0, removed: 0, roleChanged: 0 }); + expect(store.txCount).toBe(0); + expect(bindings("proj-1")).toHaveLength(3); + }); + + it("deduplicates repeated groupIds silently", async () => { + const res = await putAccess("proj-1", { + users: [ + { userId: MEMBER, role: "owner" }, + { userId: ADMIN, role: "member" }, + ], + groupIds: ["g-a", "g-a"], + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ added: 0, removed: 0, roleChanged: 0 }); + }); + + it("422s a duplicate userId (ambiguous role), not 'last wins'", async () => { + const res = await putAccess("proj-1", { + users: [ + { userId: MEMBER, role: "owner" }, + { userId: MEMBER, role: "member" }, + ], + groupIds: [], + }); + expect(res.status).toBe(422); + expect(userBinding("proj-1", MEMBER)?.role).toBe("owner"); + }); + + it("422s a body missing either key — never a half-wipe", async () => { + expect( + ( + await putAccess("proj-1", { + users: [{ userId: MEMBER, role: "owner" }], + }) + ).status, + ).toBe(422); + expect((await putAccess("proj-1", { groupIds: [] })).status).toBe(422); + expect(bindings("proj-1")).toHaveLength(3); + expect(store.audits).toHaveLength(0); + }); + + it("422s arrays beyond the caps", async () => { + const users = Array.from({ length: 1001 }, (_, i) => ({ + userId: `u-${i}`, + role: "member" as const, + })); + expect((await putAccess("proj-1", { users, groupIds: [] })).status).toBe( + 422, + ); + const groupIds = Array.from({ length: 201 }, (_, i) => `g-${i}`); + expect( + ( + await putAccess("proj-1", { + users: [{ userId: MEMBER, role: "owner" }], + groupIds, + }) + ).status, + ).toBe(422); + }); + + it("400s when ANY userId is not a member of this org — the security core", async () => { + const res = await putAccess("proj-1", { + users: [ + { userId: MEMBER, role: "owner" }, + { userId: OUTSIDER, role: "member" }, + ], + groupIds: ["g-a"], + }); + expect(res.status).toBe(400); + expect(bindings("proj-1")).toHaveLength(3); + expect(store.txCount).toBe(0); + expect(store.audits).toHaveLength(0); + }); + + it("400s when a groupId belongs to another organization", async () => { + const res = await putAccess("proj-1", { + users: [{ userId: MEMBER, role: "owner" }], + groupIds: ["g-x"], + }); + expect(res.status).toBe(400); + expect(bindings("proj-1")).toHaveLength(3); + expect(store.audits).toHaveLength(0); + }); + + it("accepts a scim group as a grantee (a project grant is OneCLI-owned)", async () => { + const res = await putAccess("proj-1", { + users: [ + { userId: MEMBER, role: "owner" }, + { userId: ADMIN, role: "member" }, + ], + groupIds: ["g-a", "g-scim"], + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ added: 1, removed: 0, roleChanged: 0 }); + }); + + it("allows granting a suspended member (auth-time gate)", async () => { + const row = store.members.find((m) => m.userId === MEMBER2); + if (row) row.status = "suspended"; + const res = await putAccess("proj-1", { + users: [ + { userId: MEMBER, role: "owner" }, + { userId: ADMIN, role: "member" }, + { userId: MEMBER2, role: "member" }, + ], + groupIds: ["g-a"], + }); + expect(res.status).toBe(200); + expect(userBinding("proj-1", MEMBER2)).toBeTruthy(); + }); + + it("400s when the resulting set has no owner (demoted, or cleared)", async () => { + const demoted = await putAccess("proj-1", { + users: [ + { userId: MEMBER, role: "member" }, + { userId: ADMIN, role: "member" }, + ], + groupIds: ["g-a"], + }); + expect(demoted.status).toBe(400); + + const cleared = await putAccess("proj-1", { users: [], groupIds: [] }); + expect(cleared.status).toBe(400); + + expect(bindings("proj-1")).toHaveLength(3); + expect(userBinding("proj-1", MEMBER)?.role).toBe("owner"); + expect(store.audits).toHaveLength(0); + }); + + it("400s a NON-ADMIN actor removing or demoting their own binding", async () => { + store.sessionUserId = MEMBER; + const removed = await putAccess( + "proj-1", + { users: [{ userId: ADMIN, role: "owner" }], groupIds: ["g-a"] }, + {}, + ); + expect(removed.status).toBe(400); + + const demoted = await putAccess( + "proj-1", + { + users: [ + { userId: MEMBER, role: "member" }, + { userId: ADMIN, role: "owner" }, + ], + groupIds: ["g-a"], + }, + {}, + ); + expect(demoted.status).toBe(400); + + expect(userBinding("proj-1", MEMBER)?.role).toBe("owner"); + expect(store.audits).toHaveLength(0); + }); + + it("lets an ORG ADMIN remove their own binding (the hand-off exemption)", async () => { + const res = await putAccess("proj-1", { + users: [{ userId: MEMBER, role: "owner" }], + groupIds: ["g-a"], + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ added: 0, removed: 1, roleChanged: 0 }); + expect(userBinding("proj-1", ADMIN)).toBeUndefined(); + }); + + it("400s an ORG ADMIN whose own removal would leave them no project", async () => { + // Strip every path ADMIN has outside proj-1: the binding under the knife + // becomes their ONLY route to any project, so removing it would 401 them + // out of the dashboard — including out of the endpoint that re-grants. + const gamma = store.projects.find((p) => p.id === "proj-3"); + if (gamma) gamma.createdByUserId = OWNER; + store.projectAccess = store.projectAccess.filter((pa) => pa.id !== "pa-6"); + + const res = await putAccess("proj-1", { + users: [{ userId: MEMBER, role: "owner" }], + groupIds: ["g-a"], + }); + expect(res.status).toBe(400); + expect(JSON.stringify(await res.json())).toContain( + "leave you with no project", + ); + expect(userBinding("proj-1", ADMIN)).toBeTruthy(); + expect(store.txCount).toBe(0); + expect(store.audits).toHaveLength(0); + }); + + it("lets an ORG ADMIN drop their own binding on a project they CREATED", async () => { + // proj-3 is ADMIN's own project, so the created-by arm survives the write + // even with no binding left anywhere (their proj-1 row is removed here). + store.projectAccess = store.projectAccess.filter((pa) => pa.id !== "pa-3"); + + const res = await putAccess("proj-3", { + users: [{ userId: MEMBER, role: "owner" }], + groupIds: [], + }); + expect(res.status).toBe(200); + expect(userBinding("proj-3", ADMIN)).toBeUndefined(); + }); + + it("writes exactly one of userId/groupId per created row (the DB CHECK)", async () => { + const res = await putAccess("proj-1", { + users: [ + { userId: MEMBER, role: "owner" }, + { userId: MEMBER2, role: "member" }, + ], + groupIds: ["g-a", "g-scim"], + }); + expect(res.status).toBe(200); + for (const row of store.projectAccess) { + const principals = [row.userId, row.groupId].filter((v) => v !== null); + expect(principals).toHaveLength(1); + } + }); + + it("always stores group rows with role member", async () => { + await putAccess("proj-1", { + users: [{ userId: MEMBER, role: "owner" }], + groupIds: ["g-scim"], + }); + const groupRows = bindings("proj-1").filter((pa) => pa.groupId !== null); + expect(groupRows.map((pa) => pa.groupId)).toEqual(["g-scim"]); + expect(groupRows.every((pa) => pa.role === "member")).toBe(true); + }); + + it("audits counts only — never id arrays", async () => { + const res = await putAccess("proj-1", { + users: [ + { userId: MEMBER, role: "owner" }, + { userId: MEMBER2, role: "member" }, + ], + groupIds: [], + }); + expect(res.status).toBe(200); + expect(store.audits).toHaveLength(1); + expect(store.audits[0]).toMatchObject({ + organizationId: ORG, + projectId: "proj-1", + action: "update", + service: "project", + source: "api", + metadata: { + projectId: "proj-1", + change: "access", + added: 1, + removed: 2, + roleChanged: 0, + }, + }); + for (const value of Object.values(store.audits[0]?.metadata ?? {})) { + expect(Array.isArray(value)).toBe(false); + } + }); + + it("404s a cross-org project BEFORE validating the payload (no existence oracle)", async () => { + const res = await putAccess("proj-x", { + users: [{ userId: OUTSIDER, role: "owner" }], + groupIds: ["g-x"], + }); + expect(res.status).toBe(404); + expect(bindings("proj-x")).toHaveLength(1); + }); + + it("403s a non-manager and writes nothing", async () => { + store.sessionUserId = MEMBER2; + const res = await putAccess( + "proj-2", + { users: [{ userId: MEMBER2, role: "owner" }], groupIds: [] }, + {}, + ); + expect(res.status).toBe(403); + expect(userBinding("proj-2", MEMBER2)?.role).toBe("member"); + expect(store.audits).toHaveLength(0); + }); +}); + +describe("DELETE /v1/projects/:projectId", () => { + it("deletes the project and every child table, in ONE transaction", async () => { + // proj-4 has no bindings and its creator (OWNER) still has proj-2. + // Give it children so the pinned cascade has something to remove — with a + // DISTINCT count per table, so a mis-ordered `Promise.all` destructure in + // the audit metadata cannot pass unnoticed. + const seed = (n: number, push: (id: string) => void) => { + for (let i = 0; i < n; i++) push(`x-${i}`); + }; + seed(1, (id) => store.agents.push({ id: `ag-${id}`, projectId: "proj-4" })); + seed(2, (id) => + store.apiKeys.push({ + id: `k-${id}`, + projectId: "proj-4", + key: `oc_key-4-${id}`, + }), + ); + seed(3, (id) => store.secrets.push({ id: `s-${id}`, projectId: "proj-4" })); + seed(4, (id) => + store.policyRules.push({ id: `pr-${id}`, projectId: "proj-4" }), + ); + seed(5, (id) => + store.policyRulesV2.push({ id: `pv-${id}`, projectId: "proj-4" }), + ); + seed(6, (id) => + store.appConnections.push({ id: `ac-${id}`, projectId: "proj-4" }), + ); + seed(7, (id) => + store.appConfigs.push({ id: `cfg-${id}`, projectId: "proj-4" }), + ); + seed(8, (id) => + store.vaultConnections.push({ id: `vc-${id}`, projectId: "proj-4" }), + ); + seed(9, (id) => store.budgets.push({ id: `b-${id}`, projectId: "proj-4" })); + seed(10, (id) => + store.onboardingSurveys.push({ id: `os-${id}`, projectId: "proj-4" }), + ); + + const res = await remove("proj-4"); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ + id: "proj-4", + name: "Delta", + removed: { + agents: 1, + apiKeys: 2, + secrets: 3, + policyRules: 4, + policyRulesV2: 5, + appConnections: 6, + appConfigs: 7, + vaultConnections: 8, + budgets: 9, + onboardingSurvey: 10, + accessBindings: 0, + }, + }); + + expect(projectRow("proj-4")).toBeUndefined(); + for (const rows of [ + store.agents, + store.apiKeys, + store.secrets, + store.appConnections, + store.appConfigs, + store.policyRules, + store.policyRulesV2, + store.vaultConnections, + store.budgets, + store.onboardingSurveys, + ]) { + expect(rows.some((r) => r.projectId === "proj-4")).toBe(false); + } + // Other projects' rows are untouched. + expect(store.agents.filter((a) => a.projectId === "proj-1")).toHaveLength( + 2, + ); + expect(bindings("proj-1")).toHaveLength(3); + expect(store.txCount).toBe(1); + }); + + it("hands the keys captured BEFORE the delete to the gateway flush", async () => { + store.apiKeys.push({ id: "k-4", projectId: "proj-4", key: "oc_key-4" }); + const res = await remove("proj-4"); + expect(res.status).toBe(200); + expect(flushes.keys).toContainEqual(["oc_key-4"]); + }); + + it("audits with organizationId and NO projectId (the FK would drop the row)", async () => { + const res = await remove("proj-4"); + expect(res.status).toBe(200); + expect(store.audits).toHaveLength(1); + expect(store.audits[0]).toMatchObject({ + organizationId: ORG, + userId: ADMIN, + action: "delete", + service: "project", + source: "api", + metadata: { projectId: "proj-4", name: "Delta" }, + }); + expect(store.audits[0]?.projectId).toBeUndefined(); + }); + + it("409s the organization's last project and deletes nothing", async () => { + store.projects = store.projects.filter( + (p) => p.id === "proj-1" || p.organizationId === OTHER_ORG, + ); + const res = await remove("proj-1"); + expect(res.status).toBe(409); + expect(await res.text()).toContain("at least one project"); + expect(projectRow("proj-1")).toBeTruthy(); + expect(store.audits).toHaveLength(0); + }); + + it("409s when a directly-bound member would be left with no project", async () => { + // proj-1 is MEMBER's only project (created + bound). + const res = await remove("proj-1"); + expect(res.status).toBe(409); + expect(await res.text()).toContain("1 member(s) with no project"); + expect(projectRow("proj-1")).toBeTruthy(); + expect(store.agents.some((a) => a.projectId === "proj-1")).toBe(true); + expect(store.audits).toHaveLength(0); + }); + + it("409s when the only path is a GROUP binding", async () => { + // Give MEMBER another project so only the group-bound MEMBER2 is stranded. + store.projectAccess.push( + access("pa-20", "proj-3", { userId: MEMBER }, "member", at(20)), + ); + // ...and take away MEMBER2's direct binding elsewhere. + store.projectAccess = store.projectAccess.filter((pa) => pa.id !== "pa-5"); + + const res = await remove("proj-1"); + expect(res.status).toBe(409); + expect(await res.text()).toContain("1 member(s) with no project"); + expect(projectRow("proj-1")).toBeTruthy(); + }); + + it("409s with the sharper message when the ACTOR would be stranded", async () => { + store.sessionUserId = MEMBER; // owner binding on proj-1, their only project + const res = await remove("proj-1", {}); + expect(res.status).toBe(409); + expect(await res.text()).toContain("leave you with no project"); + expect(projectRow("proj-1")).toBeTruthy(); + }); + + it("200s when the bound users resolve another project through a BINDING", async () => { + // proj-2's candidates: OWNER (also created proj-4) and MEMBER2 (bound to + // proj-1 through g-a) — `hasResolvableProjectExcluding`'s binding arm. + const res = await remove("proj-2"); + expect(res.status).toBe(200); + expect(projectRow("proj-2")).toBeUndefined(); + }); + + it("200s when a bound user resolves a project they CREATED", async () => { + // The other arm: MEMBER's only path was proj-1 until they create proj-5. + store.projects.push({ + id: "proj-5", + organizationId: ORG, + name: "Epsilon", + slug: "epsilon", + createdByUserId: MEMBER, + createdAt: at(30), + }); + const res = await remove("proj-1"); + expect(res.status).toBe(200); + expect(projectRow("proj-1")).toBeUndefined(); + }); + + it("does not let a SUSPENDED member's binding block the delete", async () => { + const row = store.members.find((m) => m.userId === MEMBER); + if (row) row.status = "suspended"; + // MEMBER (suspended) is the only otherwise-stranded candidate on proj-1. + const res = await remove("proj-1"); + expect(res.status).toBe(200); + expect(projectRow("proj-1")).toBeUndefined(); + }); + + it("404s a project of another organization", async () => { + const res = await remove("proj-x"); + expect(res.status).toBe(404); + expect(projectRow("proj-x")).toBeTruthy(); + expect(store.audits).toHaveLength(0); + }); + + it("403s a non-manager and deletes nothing", async () => { + store.sessionUserId = MEMBER2; + const res = await remove("proj-2", {}); + expect(res.status).toBe(403); + expect(projectRow("proj-2")).toBeTruthy(); + expect(store.audits).toHaveLength(0); + }); +}); diff --git a/packages/api/src/routes/org/projects.ts b/packages/api/src/routes/org/projects.ts new file mode 100644 index 00000000..c6569691 --- /dev/null +++ b/packages/api/src/routes/org/projects.ts @@ -0,0 +1,211 @@ +import { Hono } from "hono"; +import type { Context } from "hono"; +import type { ApiEnv } from "../../types"; +import { auth } from "../../middleware/auth"; +import { ServiceError } from "../../services/errors"; +import { parse } from "./parse"; +import { canAccessProjectAsUser } from "../../middleware/auth/resolve"; +import { + deleteProject, + getProject, + renameProject, + requireManageableProject, + requireProject, +} from "../../services/project-service"; +import { + listProjectAccess, + setProjectAccess, +} from "../../services/project-access-service"; +import { + renameProjectSchema, + setProjectAccessSchema, +} from "../../validations/project"; +import { + withAudit, + AUDIT_ACTIONS, + AUDIT_SERVICES, + AUDIT_SOURCE, +} from "../../services/audit-service"; + +/** + * `/v1/projects/*` — project administration (rename, delete, sharing). + * + * Lives under `routes/org/` because that is the OSS-owned, package-exported + * route folder — the URL is `/v1/projects/...`, NOT `/v1/org/projects`. + * + * The guard stack deliberately DIFFERS from `/v1/org/*`: + * + * `requireProject: false` — the project is named in the path. Demanding an + * `X-Project-Id` header would 401 the OSS web (which sends no headers at all) + * and would introduce a second, conflicting project scope on every request. + * + * NO `role: "admin"` — unlike the org directory, this surface is legitimately + * reachable by a plain member who holds an `owner` binding (13c: the project + * owner may rename / share / delete). Authorization is therefore PER-RESOURCE, + * in the service (`requireManageableProject` / `canAccessProjectAsUser`), never + * in the middleware. + * + * The `scope === "project"` fence is kept for exactly the org routers' reason: + * a project-scoped key is the credential an AGENT carries, and a leaked agent + * key must never be able to rename, delete or re-share the project it lives in. + */ +export const ossProjectRoutes = () => { + const app = new Hono(); + app.use("*", auth({ requireProject: false })); + app.use("*", async (c, next) => { + if (c.get("auth").scope === "project") { + throw new ServiceError( + "FORBIDDEN", + "Managing a project requires a session or an organization-scoped credential.", + ); + } + return next(); + }); + + // `organizationId` on every write is load-bearing, not decoration: besides + // scoping the audit row it flushes the gateway's org cache + // (invalidateGatewayCacheForOrg). ProjectAccess IS the `PrincipalSet` the + // Rust policy engine resolves, so a missed flush is a stale AUTHORIZATION + // decision for as long as the cache window lasts. + const auditBase = (c: Context) => ({ + organizationId: c.get("auth").organizationId, + userId: c.get("auth").userId, + userEmail: c.get("auth").userEmail, + service: AUDIT_SERVICES.PROJECT, + source: AUDIT_SOURCE.API, + }); + + /** Read authorization: anyone who may USE the project may read it. Resolve + * first (404 for unknown/cross-org), then authorize (403). */ + const requireReadableProject = async ( + organizationId: string, + userId: string, + projectId: string, + ) => { + const project = await requireProject(organizationId, projectId); + if ( + !(await canAccessProjectAsUser(userId, { + id: project.id, + organizationId, + })) + ) { + throw new ServiceError( + "FORBIDDEN", + "You do not have access to this project.", + ); + } + return project; + }; + + // GET /projects/:projectId — the sharing page's name/slug source. Nothing + // else in the API exposes a project's name (the session route returns only + // `projectId`). + app.get("/:projectId", async (c) => { + const auth = c.get("auth"); + const projectId = c.req.param("projectId"); + await requireReadableProject(auth.organizationId, auth.userId, projectId); + return c.json(await getProject(auth.organizationId, projectId)); + }); + + // PATCH /projects/:projectId — rename (name only; `slug` is immutable). + app.patch("/:projectId", async (c) => { + const auth = c.get("auth"); + const projectId = c.req.param("projectId"); + await requireManageableProject(auth.organizationId, auth.userId, projectId); + const body = await c.req.json().catch(() => null); + const input = parse(renameProjectSchema, body); + + const project = await withAudit( + () => renameProject(auth.organizationId, projectId, input.name), + (renamed) => ({ + ...auditBase(c), + projectId, + action: AUDIT_ACTIONS.UPDATE, + metadata: { projectId, change: "name", name: renamed.name }, + }), + ); + return c.json(project); + }); + + // DELETE /projects/:projectId — explicit pinned cascade, three refusals. + app.delete("/:projectId", async (c) => { + const auth = c.get("auth"); + const projectId = c.req.param("projectId"); + await requireManageableProject(auth.organizationId, auth.userId, projectId); + + const result = await withAudit( + () => deleteProject(auth.organizationId, auth.userId, projectId), + (deleted) => ({ + ...auditBase(c), + // NO `projectId` here, deliberately: withAudit writes the audit row + // AFTER the delete resolves, so an audit_logs row pointing at the + // just-deleted project violates audit_logs_project_id_fkey — and + // logAuditEvent SWALLOWS its own errors, so the delete would end up + // completely unaudited. `organizationId` keeps it attributable. + action: AUDIT_ACTIONS.DELETE, + // Counts only, never id arrays — audit metadata must stay bounded. + metadata: { projectId, name: deleted.name, removed: deleted.removed }, + }), + ); + + // No flush here: withAudit's `invalidateGatewayCacheForAccount` cannot work + // (it looks keys up by projectId, and they are gone) and a by-key flush is + // impossible once the keys no longer authenticate — so `deleteProject` + // flushes them itself, before the cascade. `organizationId` on the audit + // still flushes every SURVIVING project in the org. + return c.json({ + id: result.id, + name: result.name, + removed: result.removed, + }); + }); + + // GET /projects/:projectId/access — the sharing surface's current bindings. + app.get("/:projectId/access", async (c) => { + const auth = c.get("auth"); + const projectId = c.req.param("projectId"); + await requireReadableProject(auth.organizationId, auth.userId, projectId); + return c.json(await listProjectAccess(auth.organizationId, projectId)); + }); + + // PUT /projects/:projectId/access — bulk replace-set (the dialog's save). + // Returns a JSON body: the client's apiPut ALWAYS parses, so a 204 here + // would throw in the browser. + app.put("/:projectId/access", async (c) => { + const auth = c.get("auth"); + const projectId = c.req.param("projectId"); + const { isOrgAdmin } = await requireManageableProject( + auth.organizationId, + auth.userId, + projectId, + ); + const body = await c.req.json().catch(() => null); + const input = parse(setProjectAccessSchema, body); + + const result = await withAudit( + () => + setProjectAccess( + auth.organizationId, + auth.userId, + isOrgAdmin, + projectId, + input, + ), + (delta) => ({ + ...auditBase(c), + projectId, + action: AUDIT_ACTIONS.UPDATE, + metadata: { + projectId, + change: "access", + added: delta.added, + removed: delta.removed, + roleChanged: delta.roleChanged, + }, + }), + ); + return c.json(result); + }); + + return app; +}; diff --git a/packages/api/src/routes/org/role-mappings.test.ts b/packages/api/src/routes/org/role-mappings.test.ts new file mode 100644 index 00000000..9a17198e --- /dev/null +++ b/packages/api/src/routes/org/role-mappings.test.ts @@ -0,0 +1,1248 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Hono } from "hono"; +import type { ApiEnv } from "../../types"; + +// `/v1/org/role-mappings` end-to-end through the real app: the OSS org routes +// mounted on the `eeRoutes` seam, the OSS role resolver wired as the +// RoleResolver, and `CAPS.rbac` on. Admin callers arrive with an org API key +// (whose key path re-checks admin through the resolver); the non-admin cases +// use a session, since a non-admin's org key fails key authentication +// outright. (Same harness as groups.test.ts — cloned, not shared.) +// +// The apply engine is exercised THROUGH these routes, so the mock has to be +// honest about two things or the security tests prove nothing: the +// `role: { not: "owner" }` predicate on `organizationMember.updateMany`, and +// the difference between the two `$transaction` forms (see the counters). + +const ORG = "org-1"; +const OTHER_ORG = "org-2"; +const OWNER = "user-owner"; +const ADMIN = "user-admin"; +const MEMBER = "user-member"; +const MEMBER2 = "user-member-2"; +const SUSPENDED = "user-suspended"; +const OUTSIDER = "user-outsider"; +const ADMIN_KEY = "oc_org_admin-key"; +const PROJECT_KEY = "oc_project-key-of-owner"; + +vi.hoisted(() => { + process.env.NEXT_PUBLIC_EDITION = "oss"; + process.env.SECRET_ENCRYPTION_KEY = "test-secret"; + process.env.OAUTH_STATE_SECRET = "test-secret"; +}); + +interface MemberRow { + organizationId: string; + userId: string; + userEmail: string; + role: string; + status: string; + ssoExempt: boolean; + suspendedAt: Date | null; + createdAt: Date; +} + +interface UserRow { + id: string; + externalAuthId: string; + email: string; + name: string | null; +} + +interface GroupRow { + id: string; + organizationId: string; + name: string; + source: string; + externalId: string | null; + createdAt: Date; + updatedAt: Date; +} + +interface GroupMemberRow { + groupId: string; + userId: string; + createdAt: Date; +} + +interface MappingRow { + id: string; + organizationId: string; + groupId: string; + role: string; + priority: number; + createdAt: Date; + updatedAt: Date; +} + +interface AuditRow { + organizationId?: string; + userId: string; + userEmail: string; + action: string; + service: string; + source: string; + metadata: Record; +} + +const store = vi.hoisted(() => ({ + members: [] as MemberRow[], + users: [] as UserRow[], + groups: [] as GroupRow[], + groupMembers: [] as GroupMemberRow[], + roleMappings: [] as MappingRow[], + audits: [] as AuditRow[], + seq: 0, + /** Array-form `$transaction` — the APPLY's role writes. */ + txCount: 0, + /** Interactive `$transaction` — the advisory-locked create/reorder. */ + lockedTxCount: 0, + /** Simulate a create-create race: the pre-check misses, create P2002s. */ + race: false, + /** Which user the session provider resolves to (null = no session). */ + sessionUserId: null as string | null, +})); + +vi.mock("@onecli/db", () => { + class PrismaClientKnownRequestError extends Error { + code: string; + constructor(message: string, code: string) { + super(message); + this.code = code; + } + } + + interface GroupWhere { + id?: string; + organizationId?: string; + } + interface GroupMemberWhere { + groupId?: string | { in: string[] }; + userId?: { in: string[] }; + } + interface MappingWhere { + id?: string; + organizationId?: string; + groupId?: string; + } + interface MappingSelect { + id?: boolean; + organizationId?: boolean; + groupId?: boolean; + role?: boolean; + priority?: boolean; + createdAt?: boolean; + updatedAt?: boolean; + group?: { + select: { name?: boolean; _count?: { select: { members?: boolean } } }; + }; + } + interface MemberWhere { + organizationId?: string; + userId?: string | { in: string[] }; + role?: string | { not?: string }; + status?: string | { not?: string }; + } + + const matchesRole = (row: MemberRow, where: MemberWhere) => { + if (where.role === undefined) return true; + if (typeof where.role === "string") return row.role === where.role; + return where.role.not === undefined || row.role !== where.role.not; + }; + + const filterMembers = (where: MemberWhere) => + store.members.filter((row) => { + if ( + where.organizationId !== undefined && + row.organizationId !== where.organizationId + ) + return false; + if (typeof where.userId === "string" && row.userId !== where.userId) + return false; + if ( + typeof where.userId === "object" && + where.userId !== null && + !where.userId.in.includes(row.userId) + ) + return false; + if (where.status !== undefined) { + const ok = + typeof where.status === "string" + ? row.status === where.status + : where.status.not === undefined || row.status !== where.status.not; + if (!ok) return false; + } + return matchesRole(row, where); + }); + + const pickMember = ( + row: MemberRow, + select?: { userId?: boolean; role?: boolean; userEmail?: boolean }, + ) => { + if (!select) return { ...row }; + const picked: Record = {}; + if (select.userId) picked.userId = row.userId; + if (select.role) picked.role = row.role; + if (select.userEmail) picked.userEmail = row.userEmail; + return picked; + }; + + const filterGroupMembers = (where: GroupMemberWhere) => + store.groupMembers.filter((row) => { + if (typeof where.groupId === "string" && row.groupId !== where.groupId) + return false; + if ( + typeof where.groupId === "object" && + where.groupId !== null && + !where.groupId.in.includes(row.groupId) + ) + return false; + if (where.userId !== undefined && !where.userId.in.includes(row.userId)) + return false; + return true; + }); + + const filterMappings = (where: MappingWhere = {}) => + store.roleMappings.filter((row) => { + if (where.id !== undefined && row.id !== where.id) return false; + if ( + where.organizationId !== undefined && + row.organizationId !== where.organizationId + ) + return false; + if (where.groupId !== undefined && row.groupId !== where.groupId) + return false; + return true; + }); + + // The ONE resolution order: priority asc, createdAt asc, id asc. + const sortMappings = (rows: MappingRow[]) => + rows + .slice() + .sort( + (a, b) => + a.priority - b.priority || + a.createdAt.getTime() - b.createdAt.getTime() || + a.id.localeCompare(b.id), + ); + + const pickMapping = (row: MappingRow, select?: MappingSelect) => { + if (!select) return { ...row }; + const picked: Record = {}; + for (const key of [ + "id", + "organizationId", + "groupId", + "role", + "priority", + "createdAt", + "updatedAt", + ] as const) { + if (select[key]) picked[key] = row[key]; + } + if (select.group) { + const group = store.groups.find((g) => g.id === row.groupId); + const relation: Record = {}; + if (select.group.select.name) relation.name = group?.name ?? ""; + if (select.group.select._count) { + relation._count = { + members: store.groupMembers.filter((m) => m.groupId === row.groupId) + .length, + }; + } + picked.group = relation; + } + return picked; + }; + + const dbClient = { + apiKey: { + findUnique: async ({ where }: { where: { key?: string } }) => { + if (where.key === "oc_org_admin-key") + return { + userId: "user-admin", + organizationId: "org-1", + scope: "organization", + }; + // A PROJECT-scoped key owned by the org's OWNER: it authenticates + // fine, which is exactly why the router needs its own scope guard. + if (where.key === "oc_project-key-of-owner") + return { userId: "user-owner", projectId: "proj-1" }; + return null; + }, + findFirst: async () => null, + findMany: async () => [], + }, + user: { + findUnique: async ({ + where, + select, + }: { + where: { id?: string; externalAuthId?: string; email?: string }; + select?: Record; + }) => { + if (select?.organizationMemberships) { + return { + organizationMemberships: store.members + .filter((m) => m.userId === where.id) + .map((m) => ({ organizationId: m.organizationId })), + }; + } + return ( + store.users.find( + (u) => + (where.id !== undefined && u.id === where.id) || + (where.externalAuthId !== undefined && + u.externalAuthId === where.externalAuthId) || + (where.email !== undefined && u.email === where.email), + ) ?? null + ); + }, + }, + organizationMember: { + findUnique: async ({ + where, + select, + }: { + where: { + organizationId_userId: { organizationId: string; userId: string }; + }; + select?: { userId?: boolean; role?: boolean; userEmail?: boolean }; + }) => { + const { organizationId, userId } = where.organizationId_userId; + const row = store.members.find( + (m) => m.organizationId === organizationId && m.userId === userId, + ); + // The role resolver reads role+status off the whole row. + return row ? (select ? pickMember(row, select) : { ...row }) : null; + }, + findFirst: async ({ where }: { where: MemberWhere }) => + filterMembers(where)[0] ?? null, + findMany: async ({ + where, + select, + }: { + where: MemberWhere; + select?: { userId?: boolean; role?: boolean; userEmail?: boolean }; + }) => filterMembers(where).map((row) => pickMember(row, select)), + // THE last-owner invariant at the write layer: the predicate is honoured + // here, or the "a mapping can never strip the last owner" tests would be + // testing the mock rather than the service. + updateMany: async ({ + where, + data, + }: { + where: MemberWhere; + data: { role: string }; + }) => { + const rows = filterMembers(where); + for (const row of rows) row.role = data.role; + return { count: rows.length }; + }, + count: async () => 0, + }, + group: { + findFirst: async ({ + where, + select, + }: { + where: GroupWhere; + select?: { id?: boolean; name?: boolean }; + }) => { + const row = store.groups.find( + (g) => + (where.id === undefined || g.id === where.id) && + (where.organizationId === undefined || + g.organizationId === where.organizationId), + ); + if (!row) return null; + if (!select) return { ...row }; + const picked: Record = {}; + if (select.id) picked.id = row.id; + if (select.name) picked.name = row.name; + return picked; + }, + findMany: async () => [], + }, + groupMember: { + findMany: async ({ + where, + select, + }: { + where: GroupMemberWhere; + select?: { groupId?: boolean; userId?: boolean }; + }) => + filterGroupMembers(where).map((row) => { + if (!select) return { ...row }; + const picked: Record = {}; + if (select.groupId) picked.groupId = row.groupId; + if (select.userId) picked.userId = row.userId; + return picked; + }), + }, + groupRoleMapping: { + findFirst: async ({ + where, + select, + }: { + where: MappingWhere; + select?: MappingSelect; + }) => { + // Race simulation: the create pre-check (a groupId-keyed findFirst + // with no id) misses, so the create itself must surface the P2002. + if (store.race && where.groupId !== undefined && where.id === undefined) + return null; + const row = sortMappings(filterMappings(where))[0]; + return row ? pickMapping(row, select) : null; + }, + findMany: async ({ + where, + select, + }: { + where: MappingWhere; + select?: MappingSelect; + }) => + sortMappings(filterMappings(where)).map((row) => + pickMapping(row, select), + ), + count: async ({ where }: { where: MappingWhere }) => + filterMappings(where).length, + aggregate: async ({ where }: { where: MappingWhere }) => { + const rows = filterMappings(where); + return { + _max: { + priority: rows.length + ? Math.max(...rows.map((r) => r.priority)) + : null, + }, + }; + }, + create: async ({ + data, + select, + }: { + data: { + organizationId: string; + groupId: string; + role: string; + priority: number; + }; + select?: MappingSelect; + }) => { + // `groupId` is @unique — at most one mapping per group. + if (store.roleMappings.some((m) => m.groupId === data.groupId)) { + throw new PrismaClientKnownRequestError( + "Unique constraint failed", + "P2002", + ); + } + const row: MappingRow = { + id: `rm-${++store.seq}`, + organizationId: data.organizationId, + groupId: data.groupId, + role: data.role, + priority: data.priority, + createdAt: new Date(), + updatedAt: new Date(), + }; + store.roleMappings.push(row); + return pickMapping(row, select); + }, + update: async ({ + where, + data, + }: { + where: { id: string }; + data: { priority?: number; role?: string }; + }) => { + const row = store.roleMappings.find((m) => m.id === where.id); + if (!row) + throw new PrismaClientKnownRequestError("Record not found", "P2025"); + if (data.priority !== undefined) row.priority = data.priority; + if (data.role !== undefined) row.role = data.role; + row.updatedAt = new Date(); + return { ...row }; + }, + updateMany: async ({ + where, + data, + }: { + where: MappingWhere; + data: { role?: string; priority?: number }; + }) => { + const rows = filterMappings(where); + for (const row of rows) { + if (data.role !== undefined) row.role = data.role; + if (data.priority !== undefined) row.priority = data.priority; + row.updatedAt = new Date(); + } + return { count: rows.length }; + }, + deleteMany: async ({ where }: { where: MappingWhere }) => { + const rows = filterMappings(where); + const ids = new Set(rows.map((r) => r.id)); + store.roleMappings = store.roleMappings.filter((m) => !ids.has(m.id)); + return { count: rows.length }; + }, + }, + project: { + findFirst: async () => ({ id: "proj-1", organizationId: "org-1" }), + findUnique: async () => ({ id: "proj-1", organizationId: "org-1" }), + }, + projectAccess: { findFirst: async () => null }, + auditLog: { + create: async ({ data }: { data: AuditRow }) => { + store.audits.push(data); + return data; + }, + }, + // Both forms, counted SEPARATELY: the apply batches its role writes as an + // array, while create/reorder open an interactive one to take the + // advisory lock. "No apply transaction was opened" is `txCount`. + $transaction: async (arg: unknown) => { + if (typeof arg === "function") { + store.lockedTxCount++; + return (arg as (tx: unknown) => Promise)(dbClient); + } + store.txCount++; + return Promise.all(arg as Promise[]); + }, + /** The advisory lock — a no-op tagged-template stub. */ + $executeRaw: async () => 1, + }; + + return { + Prisma: { JsonNull: null, PrismaClientKnownRequestError }, + db: dbClient, + }; +}); + +import { createApiApp } from "../../app"; +import { registerOssOrgRoutes } from "./index"; +import { ossRoleResolver } from "../../services/org-role-resolver"; + +const sessionProvider = { + getSession: async () => { + const user = store.users.find((u) => u.id === store.sessionUserId); + return user ? { id: user.externalAuthId, email: user.email } : null; + }, +}; + +const app: Hono = createApiApp(sessionProvider, { + eeRoutes: registerOssOrgRoutes, + roleResolver: ossRoleResolver, +}); + +const at = (minutes: number) => new Date(Date.UTC(2026, 0, 1, 0, minutes)); + +const member = ( + userId: string, + role: string, + createdAt: Date, + organizationId = ORG, +): MemberRow => ({ + organizationId, + userId, + userEmail: `${userId}@example.com`, + role, + status: "active", + ssoExempt: false, + suspendedAt: null, + createdAt, +}); + +const group = ( + id: string, + name: string, + overrides: Partial = {}, +): GroupRow => ({ + id, + organizationId: ORG, + name, + source: "manual", + externalId: null, + createdAt: at(10), + updatedAt: at(10), + ...overrides, +}); + +const mapping = ( + id: string, + groupId: string, + role: string, + priority: number, + organizationId = ORG, +): MappingRow => ({ + id, + organizationId, + groupId, + role, + priority, + createdAt: at(30 + priority), + updatedAt: at(30 + priority), +}); + +beforeEach(() => { + store.users = [ + { + id: OWNER, + externalAuthId: "ext-owner", + email: "owner@example.com", + name: "Olive Owner", + }, + { + id: ADMIN, + externalAuthId: "ext-admin", + email: "admin@example.com", + name: "Adam Admin", + }, + { + id: MEMBER, + externalAuthId: "ext-member", + email: "member@example.com", + name: null, + }, + { + id: MEMBER2, + externalAuthId: "ext-member-2", + email: "member2@example.com", + name: null, + }, + { + id: SUSPENDED, + externalAuthId: "ext-suspended", + email: "suspended@example.com", + name: null, + }, + { + id: OUTSIDER, + externalAuthId: "ext-outsider", + email: "outsider@other.test", + name: "Odette Outsider", + }, + ]; + store.members = [ + member(OWNER, "owner", at(0)), + member(ADMIN, "admin", at(1)), + member(MEMBER, "member", at(2)), + member(MEMBER2, "member", at(3)), + { ...member(SUSPENDED, "member", at(4)), status: "suspended" }, + member(OUTSIDER, "admin", at(5), OTHER_ORG), + ]; + store.groups = [ + group("g-a", "Engineering"), + group("g-b", "Design", { createdAt: at(11), updatedAt: at(11) }), + group("g-c", "Everyone", { createdAt: at(12), updatedAt: at(12) }), + group("g-scim", "Provisioned", { + source: "scim", + externalId: "idp-77", + createdAt: at(13), + updatedAt: at(13), + }), + // A group in a DIFFERENT org — never visible through this org's routes. + group("g-x", "Foreign", { organizationId: OTHER_ORG, createdAt: at(14) }), + ]; + store.groupMembers = [ + { groupId: "g-a", userId: OWNER, createdAt: at(20) }, + { groupId: "g-a", userId: ADMIN, createdAt: at(21) }, + { groupId: "g-a", userId: MEMBER, createdAt: at(22) }, + { groupId: "g-b", userId: MEMBER2, createdAt: at(23) }, + { groupId: "g-b", userId: SUSPENDED, createdAt: at(24) }, + { groupId: "g-c", userId: MEMBER, createdAt: at(25) }, + { groupId: "g-c", userId: MEMBER2, createdAt: at(26) }, + { groupId: "g-scim", userId: MEMBER, createdAt: at(27) }, + { groupId: "g-x", userId: OUTSIDER, createdAt: at(28) }, + ]; + // A CONVERGED baseline: the only mapping grants `member`, which nobody can + // be raised to, so any apply over the untouched fixture is a no-op. + store.roleMappings = [ + mapping("rm-1", "g-b", "member", 0), + mapping("rm-x", "g-x", "admin", 0, OTHER_ORG), + ]; + store.audits = []; + store.seq = 100; + store.txCount = 0; + store.lockedTxCount = 0; + store.race = false; + store.sessionUserId = null; +}); + +const asAdmin = { headers: { Authorization: `Bearer ${ADMIN_KEY}` } }; +const asProjectKey = { headers: { Authorization: `Bearer ${PROJECT_KEY}` } }; + +interface RoleMappingBody { + id: string; + groupId: string; + groupName: string; + role: string; + priority: number; + memberCount: number; + createdAt: string; + updatedAt: string; +} + +const base = "/v1/org/role-mappings"; + +const list = async (init: RequestInit = asAdmin) => + app.request(base, init) as Promise; + +const listRows = async (): Promise => { + const res = await list(); + expect(res.status).toBe(200); + return (await res.json()) as RoleMappingBody[]; +}; + +const create = (body: unknown, init: RequestInit = asAdmin) => + app.request(base, { ...init, method: "POST", body: JSON.stringify(body) }); + +const update = (id: string, body: unknown, init: RequestInit = asAdmin) => + app.request(`${base}/${id}`, { + ...init, + method: "PATCH", + body: JSON.stringify(body), + }); + +const remove = (id: string, init: RequestInit = asAdmin) => + app.request(`${base}/${id}`, { ...init, method: "DELETE" }); + +const reorder = (orderedIds: string[], init: RequestInit = asAdmin) => + app.request(`${base}/order`, { + ...init, + method: "PUT", + body: JSON.stringify({ orderedIds }), + }); + +const preview = (body: unknown, init: RequestInit = asAdmin) => + app.request(`${base}/preview`, { + ...init, + method: "POST", + body: JSON.stringify(body), + }); + +const roleOf = (userId: string) => + store.members.find((m) => m.organizationId === ORG && m.userId === userId) + ?.role; + +const activeOwners = () => + store.members.filter( + (m) => + m.organizationId === ORG && + m.role === "owner" && + m.status !== "suspended", + ); + +const memberAudits = () => store.audits.filter((a) => a.service === "member"); +const mappingAudits = () => + store.audits.filter((a) => a.service === "role-mapping"); + +describe("the guard stack", () => { + it("401s an unauthenticated caller", async () => { + const res = await app.request(base); + expect(res.status).toBe(401); + }); + + it("403s a non-admin member (deterministic, not a 401)", async () => { + store.sessionUserId = MEMBER; + const res = await app.request(base); + expect(res.status).toBe(403); + }); + + it("403s a project-scoped key on EVERY verb, even for an org owner", async () => { + const responses = await Promise.all([ + list(asProjectKey), + create({ groupId: "g-a", role: "admin" }, asProjectKey), + update("rm-1", { role: "admin" }, asProjectKey), + remove("rm-1", asProjectKey), + reorder(["rm-1"], asProjectKey), + preview({ groupId: "g-a", role: "admin" }, asProjectKey), + ]); + for (const res of responses) expect(res.status).toBe(403); + expect(store.audits).toHaveLength(0); + expect(roleOf(MEMBER)).toBe("member"); + }); +}); + +describe("GET /v1/org/role-mappings", () => { + it("returns a BARE ARRAY (not a page envelope) — the client contract", async () => { + const res = await list(); + expect(res.status).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + }); + + it("orders by priority ascending and carries groupName + memberCount", async () => { + store.roleMappings.push(mapping("rm-2", "g-a", "admin", 5)); + store.roleMappings.push(mapping("rm-3", "g-c", "member", 2)); + const rows = await listRows(); + expect(rows.map((r) => r.id)).toEqual(["rm-1", "rm-3", "rm-2"]); + expect(rows[0]).toEqual({ + id: "rm-1", + groupId: "g-b", + groupName: "Design", + role: "member", + priority: 0, + memberCount: 2, + createdAt: at(30).toISOString(), + updatedAt: at(30).toISOString(), + }); + }); + + it("never leaks another organization's mappings", async () => { + const rows = await listRows(); + expect(rows.some((r) => r.id === "rm-x")).toBe(false); + }); +}); + +describe("POST /v1/org/role-mappings", () => { + it("creates a mapping, appends at max+1, and audits it", async () => { + const res = await create({ groupId: "g-c", role: "admin" }); + expect(res.status).toBe(200); + const body = (await res.json()) as RoleMappingBody; + expect(body).toMatchObject({ + groupId: "g-c", + groupName: "Everyone", + role: "admin", + priority: 1, // rm-1 sits at 0 + memberCount: 2, + }); + expect(mappingAudits()).toHaveLength(1); + expect(mappingAudits()[0]).toMatchObject({ + organizationId: ORG, + userId: ADMIN, + action: "create", + service: "role-mapping", + source: "api", + metadata: { + mappingId: body.id, + groupId: "g-c", + groupName: "Everyone", + role: "admin", + priority: 1, + // g-c is {MEMBER, MEMBER2}, but MEMBER2 is also in g-b whose `member` + // mapping sits at priority 0 and shadows this one. + rolesChanged: 1, + }, + }); + expect(roleOf(MEMBER)).toBe("admin"); + expect(roleOf(MEMBER2)).toBe("member"); + }); + + it("uses priority 0 for the org's FIRST mapping", async () => { + store.roleMappings = store.roleMappings.filter((m) => m.id === "rm-x"); + const res = await create({ groupId: "g-c", role: "member" }); + expect(res.status).toBe(200); + expect(((await res.json()) as RoleMappingBody).priority).toBe(0); + }); + + it("honours an explicit priority", async () => { + const res = await create({ groupId: "g-c", role: "member", priority: 7 }); + expect(res.status).toBe(200); + expect(((await res.json()) as RoleMappingBody).priority).toBe(7); + }); + + // `priority` is NOT coerced: a body that meant "no priority" must 422, not + // land in slot 0 — the highest-precedence slot, which shadows everything. + it("422s a non-numeric priority instead of reading it as 0", async () => { + for (const priority of [null, "", false, [], "3"]) { + const res = await create({ groupId: "g-c", role: "admin", priority }); + expect(res.status).toBe(422); + } + expect(store.roleMappings.some((m) => m.groupId === "g-c")).toBe(false); + expect(store.audits).toHaveLength(0); + }); + + it("409s once the org is at the mapping ceiling, keeping PUT /order reachable", async () => { + store.roleMappings = Array.from({ length: 500 }, (_, i) => + mapping(`rm-bulk-${i}`, `g-bulk-${i}`, "member", i), + ); + const res = await create({ groupId: "g-a", role: "admin" }); + expect(res.status).toBe(409); + expect(store.roleMappings).toHaveLength(500); + expect(store.audits).toHaveLength(0); + }); + + it("409s a second mapping for the same group (groupId is unique)", async () => { + const res = await create({ groupId: "g-b", role: "admin" }); + expect(res.status).toBe(409); + expect(store.audits).toHaveLength(0); + expect(store.roleMappings.filter((m) => m.groupId === "g-b")).toHaveLength( + 1, + ); + }); + + it("409s a create-create race surfaced as P2002", async () => { + store.race = true; + const res = await create({ groupId: "g-b", role: "admin" }); + expect(res.status).toBe(409); + expect(store.audits).toHaveLength(0); + }); + + it("422s role: owner — mappings can never mint an owner", async () => { + const res = await create({ groupId: "g-c", role: "owner" }); + expect(res.status).toBe(422); + expect(store.audits).toHaveLength(0); + expect(store.roleMappings.some((m) => m.groupId === "g-c")).toBe(false); + }); + + it("422s a malformed body", async () => { + for (const body of [{}, { groupId: "g-c" }, { role: "admin" }, null]) { + expect((await create(body)).status).toBe(422); + } + }); + + it("404s an unknown group and a group of another organization", async () => { + expect((await create({ groupId: "g-nope", role: "admin" })).status).toBe( + 404, + ); + expect((await create({ groupId: "g-x", role: "admin" })).status).toBe(404); + expect(store.roleMappings.filter((m) => m.groupId === "g-x")).toHaveLength( + 1, + ); + }); + + it("MAPS a scim-provisioned group: a mapping is a OneCLI artifact, not an IdP one", async () => { + const res = await create({ groupId: "g-scim", role: "admin" }); + expect(res.status).toBe(200); + expect(((await res.json()) as RoleMappingBody).groupId).toBe("g-scim"); + expect(roleOf(MEMBER)).toBe("admin"); + }); +}); + +describe("PATCH /v1/org/role-mappings/:id", () => { + it("changes the role, audits the discriminator, and re-applies", async () => { + const res = await update("rm-1", { role: "admin" }); + expect(res.status).toBe(200); + const body = (await res.json()) as RoleMappingBody; + expect(body).toMatchObject({ id: "rm-1", role: "admin", priority: 0 }); + expect(mappingAudits()[0]).toMatchObject({ + action: "update", + service: "role-mapping", + metadata: { + mappingId: "rm-1", + groupId: "g-b", + change: "role", + role: "admin", + rolesChanged: 2, + }, + }); + }); + + it("accepts an optional priority and records change: role+priority", async () => { + const res = await update("rm-1", { role: "member", priority: 9 }); + expect(res.status).toBe(200); + expect(((await res.json()) as RoleMappingBody).priority).toBe(9); + expect(mappingAudits()[0]?.metadata).toMatchObject({ + change: "role+priority", + priority: 9, + }); + }); + + it("404s an unknown id and another org's mapping", async () => { + expect((await update("rm-nope", { role: "admin" })).status).toBe(404); + expect((await update("rm-x", { role: "member" })).status).toBe(404); + expect(store.roleMappings.find((m) => m.id === "rm-x")?.role).toBe("admin"); + }); + + it("422s role: owner", async () => { + expect((await update("rm-1", { role: "owner" })).status).toBe(422); + expect(store.roleMappings.find((m) => m.id === "rm-1")?.role).toBe( + "member", + ); + }); +}); + +describe("DELETE /v1/org/role-mappings/:id", () => { + it("removes the row, returns a JSON body, and audits", async () => { + const res = await remove("rm-1"); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("application/json"); + expect(await res.json()).toEqual({ + id: "rm-1", + groupId: "g-b", + role: "member", + rolesChanged: 0, + }); + expect(store.roleMappings.some((m) => m.id === "rm-1")).toBe(false); + expect(mappingAudits()[0]).toMatchObject({ + action: "delete", + service: "role-mapping", + source: "api", + metadata: { mappingId: "rm-1", groupId: "g-b", role: "member" }, + }); + }); + + it("404s an unknown id and another org's mapping", async () => { + expect((await remove("rm-nope")).status).toBe(404); + expect((await remove("rm-x")).status).toBe(404); + expect(store.roleMappings.some((m) => m.id === "rm-x")).toBe(true); + }); + + it("UNSHADOWS: deleting the priority-0 member mapping promotes the suppressed", async () => { + // g-c (Everyone → member) at 0 shadows g-a (Engineering → admin) at 1. + store.roleMappings = [ + mapping("rm-shadow", "g-c", "member", 0), + mapping("rm-eng", "g-a", "admin", 1), + ]; + expect(roleOf(MEMBER)).toBe("member"); + + const res = await remove("rm-shadow"); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ rolesChanged: 1 }); + // MEMBER is in both groups; with the shadow gone the admin mapping wins. + expect(roleOf(MEMBER)).toBe("admin"); + // MEMBER2 is only in g-c, which no longer has a mapping. + expect(roleOf(MEMBER2)).toBe("member"); + }); +}); + +describe("PUT /v1/org/role-mappings/order", () => { + beforeEach(() => { + store.roleMappings = [ + mapping("rm-shadow", "g-c", "member", 0), + mapping("rm-eng", "g-a", "admin", 1), + ]; + }); + + it("reassigns 0..n-1, flips the list, and promotes the unshadowed", async () => { + const res = await reorder(["rm-eng", "rm-shadow"]); + expect(res.status).toBe(200); + const body = (await res.json()) as RoleMappingBody[]; + expect(body.map((r) => r.id)).toEqual(["rm-eng", "rm-shadow"]); + expect(body.map((r) => r.priority)).toEqual([0, 1]); + expect(roleOf(MEMBER)).toBe("admin"); + expect(mappingAudits()[0]).toMatchObject({ + action: "update", + service: "role-mapping", + source: "api", + organizationId: ORG, + metadata: { change: "order", count: 2, rolesChanged: 1 }, + }); + }); + + it("409s a body that does not name every mapping exactly once", async () => { + expect((await reorder(["rm-eng"])).status).toBe(409); + expect((await reorder(["rm-eng", "rm-shadow", "rm-x"])).status).toBe(409); + // Priorities untouched. + expect(store.roleMappings.find((m) => m.id === "rm-eng")?.priority).toBe(1); + expect(store.audits).toHaveLength(0); + }); + + it("422s duplicate ids (malformed, not stale)", async () => { + const res = await reorder(["rm-eng", "rm-eng"]); + expect(res.status).toBe(422); + expect(store.audits).toHaveLength(0); + }); + + it("accepts [] for an org with no mappings", async () => { + store.roleMappings = store.roleMappings.filter( + (m) => m.organizationId !== ORG, + ); + const res = await reorder([]); + expect(res.status).toBe(200); + expect(await res.json()).toEqual([]); + }); + + it("submitting the identical settled order writes nothing", async () => { + const before = store.roleMappings.map((m) => ({ ...m })); + const res = await reorder(["rm-shadow", "rm-eng"]); + expect(res.status).toBe(200); + expect((await res.json()).length).toBe(2); + // No update was issued (updatedAt is byte-identical) and no apply + // transaction was opened. + expect(store.roleMappings).toEqual(before); + expect(store.txCount).toBe(0); + expect(memberAudits()).toHaveLength(0); + }); +}); + +describe("POST /v1/org/role-mappings/preview", () => { + it("counts the raises a new admin mapping would make", async () => { + const res = await preview({ groupId: "g-a", role: "admin" }); + expect(res.status).toBe(200); + // g-a is {OWNER, ADMIN, MEMBER}: the owner is skipped and ADMIN is the + // caller, so only MEMBER is counted. + expect(await res.json()).toEqual({ affectedCount: 1 }); + }); + + it("returns 0 for a member mapping (its effect is shadowing, not demotion)", async () => { + const res = await preview({ groupId: "g-a", role: "member" }); + expect(await res.json()).toEqual({ affectedCount: 0 }); + }); + + it("previews an EXISTING mapping at its current priority, so a shadowed group reads 0", async () => { + // g-c (Everyone → member) sits at priority 0; g-b's mapping is below it, + // and both cover MEMBER2. + store.roleMappings = [ + mapping("rm-shadow", "g-c", "member", 0), + mapping("rm-1", "g-b", "member", 1), + ]; + const shadowed = await preview({ groupId: "g-b", role: "admin" }); + // MEMBER2 is shadowed by g-c; SUSPENDED is only in g-b, so it still counts. + expect(await shadowed.json()).toEqual({ affectedCount: 1 }); + }); + + it("includes suspended members (their stored role is their reinstate shape)", async () => { + const res = await preview({ groupId: "g-b", role: "admin" }); + expect(await res.json()).toEqual({ affectedCount: 2 }); + }); + + it("writes nothing and audits nothing", async () => { + const mappingsBefore = store.roleMappings.map((m) => ({ ...m })); + const membersBefore = store.members.map((m) => ({ ...m })); + const res = await preview({ groupId: "g-a", role: "admin" }); + expect(res.status).toBe(200); + expect(store.roleMappings).toEqual(mappingsBefore); + expect(store.members).toEqual(membersBefore); + expect(store.audits).toHaveLength(0); + expect(store.txCount).toBe(0); + }); + + it("404s a group of another organization (no existence oracle)", async () => { + expect((await preview({ groupId: "g-x", role: "admin" })).status).toBe(404); + expect((await preview({ groupId: "g-nope", role: "admin" })).status).toBe( + 404, + ); + }); + + it("422s role: owner", async () => { + expect((await preview({ groupId: "g-a", role: "owner" })).status).toBe(422); + }); +}); + +describe("applying the mappings", () => { + it("raises every non-owner, non-self member and audits one MEMBER row each", async () => { + const res = await create({ groupId: "g-a", role: "admin" }); + expect(res.status).toBe(200); + expect(roleOf(MEMBER)).toBe("admin"); + // Untouched: the owner (C1) and the acting admin (C2, already admin). + expect(roleOf(OWNER)).toBe("owner"); + expect(roleOf(MEMBER2)).toBe("member"); + + expect(memberAudits()).toHaveLength(1); + expect(memberAudits()[0]).toMatchObject({ + organizationId: ORG, + userId: ADMIN, + userEmail: `${ADMIN}@example.com`, + action: "update", + service: "member", + source: "api", + metadata: { + targetUserId: MEMBER, + role: "admin", + previousRole: "member", + via: "role-mapping", + groupId: "g-a", + trigger: "mapping", + }, + }); + }); + + it("is idempotent: re-applying changes nothing and writes no extra audits", async () => { + const first = await create({ groupId: "g-a", role: "admin" }); + expect(first.status).toBe(200); + const created = (await first.json()) as RoleMappingBody; + expect(memberAudits()).toHaveLength(1); + + store.txCount = 0; + store.audits = []; + const again = await update(created.id, { role: "admin" }); + expect(again.status).toBe(200); + expect(memberAudits()).toHaveLength(0); + // No apply transaction was opened at all. + expect(store.txCount).toBe(0); + expect(mappingAudits()[0]?.metadata).toMatchObject({ rolesChanged: 0 }); + }); + + it("NEVER strips the last owner — create, reorder, or delete", async () => { + const before = store.members.find((m) => m.userId === OWNER); + expect(activeOwners()).toHaveLength(1); + + // The owner is a member of g-a; map it to the weakest role there is. + const created = await create({ groupId: "g-a", role: "member" }); + expect(created.status).toBe(200); + const id = ((await created.json()) as RoleMappingBody).id; + expect(activeOwners()).toHaveLength(1); + + expect((await reorder([id, "rm-1"])).status).toBe(200); + expect(activeOwners()).toHaveLength(1); + + expect((await remove(id)).status).toBe(200); + expect(activeOwners()).toHaveLength(1); + // The owner's row is byte-identical throughout. + expect(store.members.find((m) => m.userId === OWNER)).toEqual(before); + }); + + it("never demotes: a hand-promoted admin in a member-mapped group keeps admin", async () => { + const row = store.members.find((m) => m.userId === MEMBER2); + if (row) row.role = "admin"; + const res = await create({ groupId: "g-c", role: "member" }); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ role: "member" }); + expect(roleOf(MEMBER2)).toBe("admin"); + expect(memberAudits()).toHaveLength(0); + }); + + // The other face of "a mapping is a FLOOR": a hand demotion is NOT a durable + // undo while the mapping is still live. Pinned deliberately — the UI copy and + // the roadmap's decision C both have to say so. + it("re-raises a hand-demoted member who is still in an admin-mapped group", async () => { + const created = await create({ groupId: "g-a", role: "admin" }); + expect(created.status).toBe(200); + const id = ((await created.json()) as RoleMappingBody).id; + expect(roleOf(MEMBER)).toBe("admin"); + + // As `PATCH /v1/org/members/:userId` would: the admin demotes them by hand. + const row = store.members.find((m) => m.userId === MEMBER); + if (row) row.role = "member"; + store.audits = []; + + // ANY later apply — here a reorder — puts the role straight back, because + // MEMBER is still a member of the still-mapped group. + expect((await reorder([id, "rm-1"])).status).toBe(200); + expect(roleOf(MEMBER)).toBe("admin"); + expect(memberAudits()).toHaveLength(1); + expect(memberAudits()[0]).toMatchObject({ + metadata: { targetUserId: MEMBER, role: "admin", via: "role-mapping" }, + }); + + // The durable remedy: drop the mapping FIRST, then demote. + expect((await remove(id)).status).toBe(200); + if (row) row.role = "member"; + expect((await reorder(["rm-1"])).status).toBe(200); + expect(roleOf(MEMBER)).toBe("member"); + }); + + it("raises a SUSPENDED member too (their stored role is their reinstate shape)", async () => { + const res = await update("rm-1", { role: "admin" }); + expect(res.status).toBe(200); + expect(roleOf(SUSPENDED)).toBe("admin"); + expect(store.members.find((m) => m.userId === SUSPENDED)?.status).toBe( + "suspended", + ); + expect( + memberAudits() + .map((a) => a.metadata.targetUserId) + .sort(), + ).toEqual([MEMBER2, SUSPENDED].sort()); + }); + + it("keeps mapping audit metadata bounded — counts only, never id arrays", async () => { + const res = await create({ groupId: "g-a", role: "admin" }); + expect(res.status).toBe(200); + for (const audit of mappingAudits()) { + expect(audit.organizationId).toBe(ORG); + expect(audit.source).toBe("api"); + for (const value of Object.values(audit.metadata)) { + expect(Array.isArray(value)).toBe(false); + } + } + }); + + it("never touches another organization's members", async () => { + const res = await create({ groupId: "g-a", role: "admin" }); + expect(res.status).toBe(200); + expect( + store.members.find((m) => m.organizationId === OTHER_ORG)?.role, + ).toBe("admin"); + }); +}); diff --git a/packages/api/src/routes/org/role-mappings.ts b/packages/api/src/routes/org/role-mappings.ts new file mode 100644 index 00000000..76ab4c2b --- /dev/null +++ b/packages/api/src/routes/org/role-mappings.ts @@ -0,0 +1,200 @@ +import { Hono } from "hono"; +import type { Context } from "hono"; +import type { ApiEnv } from "../../types"; +import { auth } from "../../middleware/auth"; +import { ServiceError } from "../../services/errors"; +import { parse } from "./parse"; +import { + createOrgRoleMapping, + deleteOrgRoleMapping, + listOrgRoleMappings, + previewOrgRoleMapping, + reorderOrgRoleMappings, + updateOrgRoleMapping, +} from "../../services/org-role-mapping-service"; +import { + createRoleMappingSchema, + previewRoleMappingSchema, + reorderRoleMappingsSchema, + updateRoleMappingSchema, +} from "../../validations/org"; +import { + withAudit, + AUDIT_ACTIONS, + AUDIT_SERVICES, + AUDIT_SOURCE, +} from "../../services/audit-service"; + +/** + * `/v1/org/role-mappings` — group → org-role mappings. + * + * Same guard stack as `/v1/org/groups`, for the same reasons: + * + * `requireProject: false`: these are ORG-scoped routes, so a caller with no + * project context (an org API key without `X-Project-Id`) must still get + * through. `role: "admin"` makes the whole router admin-only — a plain member + * gets a deterministic 403, which is exactly what the web client expects + * (directory queries are not retried on 403). + * + * `role` alone is SCOPE-BLIND, so it is not sufficient on its own: a + * project-scoped key (the credential an agent carries) resolves to its owning + * user, and if that user happens to be an org admin the role check passes. + * Here that is at its sharpest — a leaked agent key would be able to rewrite + * WHO IS AN ORG ADMIN, minting the very authority the guard exists to + * protect. Org-wide authority requires an org-wide credential, so + * project-scoped callers are rejected outright. + */ +export const orgRoleMappingRoutes = () => { + const app = new Hono(); + app.use("*", auth({ requireProject: false, role: "admin" })); + app.use("*", async (c, next) => { + if (c.get("auth").scope === "project") { + throw new ServiceError( + "FORBIDDEN", + "Organization management requires an organization-scoped credential.", + ); + } + return next(); + }); + + // `organizationId` in every audit params below is deliberate: besides + // scoping the audit row it flushes the gateway's org cache + // (invalidateGatewayCacheForOrg). A mapping write can change a member's org + // role, which is exactly what authorization reads — a missed flush becomes a + // stale authorization decision. The MEMBER audit rows the apply writes + // deliberately do NOT flush again; this one flush covers the request. + const auditBase = (c: Context) => ({ + organizationId: c.get("auth").organizationId, + userId: c.get("auth").userId, + userEmail: c.get("auth").userEmail, + service: AUDIT_SERVICES.ROLE_MAPPING, + source: AUDIT_SOURCE.API, + }); + + // GET /org/role-mappings — the WHOLE ordered set as a BARE ARRAY (not a + // DirectoryPage): `groupId` is unique so the set is bounded by group count, + // the client types it as `RoleMappingRow[]`, and resolution needs all of it. + app.get("/", async (c) => { + const auth = c.get("auth"); + return c.json(await listOrgRoleMappings(auth.organizationId)); + }); + + // Literal paths are registered BEFORE any parameterized path of the same + // method (`/preview` before `/`, `/order` ahead of a future `PUT /:id`). + // Nothing collides by method today; keeping the order is the convention + // that stops the first such addition from being silently shadowed. + + // POST /org/role-mappings/preview — dry run. Deliberately NOT wrapped in + // withAudit: it writes nothing, so auditing it would both log a phantom + // change and wrongly flush the gateway org cache. + app.post("/preview", async (c) => { + const auth = c.get("auth"); + const body = await c.req.json().catch(() => null); + const input = parse(previewRoleMappingSchema, body); + return c.json( + await previewOrgRoleMapping(auth.organizationId, auth.userId, input), + ); + }); + + // PUT /org/role-mappings/order — reassign priorities from the FULL ordered + // id set. Returns the new order as a JSON body: the client's apiPut ALWAYS + // parses, so a 204 here would throw in the browser. + app.put("/order", async (c) => { + const auth = c.get("auth"); + const body = await c.req.json().catch(() => null); + const input = parse(reorderRoleMappingsSchema, body); + + const result = await withAudit( + () => + reorderOrgRoleMappings( + auth.organizationId, + auth.userId, + input.orderedIds, + ), + (r) => ({ + ...auditBase(c), + action: AUDIT_ACTIONS.UPDATE, + // Counts only, never id arrays — audit metadata must stay bounded. + metadata: { + change: "order", + count: r.mappings.length, + rolesChanged: r.rolesChanged, + }, + }), + ); + return c.json(result.mappings); + }); + + // POST /org/role-mappings — create (at most one per group: 409 otherwise). + app.post("/", async (c) => { + const auth = c.get("auth"); + const body = await c.req.json().catch(() => null); + const input = parse(createRoleMappingSchema, body); + + const result = await withAudit( + () => createOrgRoleMapping(auth.organizationId, auth.userId, input), + (r) => ({ + ...auditBase(c), + action: AUDIT_ACTIONS.CREATE, + metadata: { + mappingId: r.mapping.id, + groupId: r.mapping.groupId, + groupName: r.mapping.groupName, + role: r.mapping.role, + priority: r.mapping.priority, + rolesChanged: r.rolesChanged, + }, + }), + ); + return c.json(result.mapping); + }); + + // PATCH /org/role-mappings/:id — change the role and/or the priority. + app.patch("/:id", async (c) => { + const auth = c.get("auth"); + const id = c.req.param("id"); + const body = await c.req.json().catch(() => null); + const input = parse(updateRoleMappingSchema, body); + + const result = await withAudit( + () => updateOrgRoleMapping(auth.organizationId, auth.userId, id, input), + (r) => ({ + ...auditBase(c), + action: AUDIT_ACTIONS.UPDATE, + metadata: { + mappingId: r.mapping.id, + groupId: r.mapping.groupId, + change: input.priority !== undefined ? "role+priority" : "role", + role: r.mapping.role, + priority: r.mapping.priority, + rolesChanged: r.rolesChanged, + }, + }), + ); + return c.json(result.mapping); + }); + + // DELETE /org/role-mappings/:id — the response carries a JSON body like + // every other route here (apiDelete discards it; the error path parses). + app.delete("/:id", async (c) => { + const auth = c.get("auth"); + const id = c.req.param("id"); + + const result = await withAudit( + () => deleteOrgRoleMapping(auth.organizationId, auth.userId, id), + (r) => ({ + ...auditBase(c), + action: AUDIT_ACTIONS.DELETE, + metadata: { + mappingId: r.id, + groupId: r.groupId, + role: r.role, + rolesChanged: r.rolesChanged, + }, + }), + ); + return c.json(result); + }); + + return app; +}; diff --git a/packages/api/src/services/org-group-service.ts b/packages/api/src/services/org-group-service.ts index aa4ff9b8..f82409d5 100644 --- a/packages/api/src/services/org-group-service.ts +++ b/packages/api/src/services/org-group-service.ts @@ -7,6 +7,10 @@ import { type DirectoryPage, } from "../lib/cursor"; import type { GroupListQuery } from "../validations/org"; +import { + applyOrgRoleMappings, + applyRoleMappingsForGroup, +} from "./org-role-mapping-service"; // The org's human-group directory: list/create/rename/delete plus the three // membership writers. Scoped to ONE organization on every call — the caller's @@ -42,6 +46,7 @@ export interface GroupDeleteResult { name: string; removedMembers: number; removedProjectBindings: number; + removedRoleMappings: number; } export type ListOrgGroupsParams = Partial; @@ -283,11 +288,15 @@ export const renameOrgGroup = async ( * widened to "any principal" (see grants-service). So a group delete needs no * explicit orphan-neutralization pass here; the identity rows simply cascade * away and the rules that referenced them lose one target. Role automation - * (group→org-role mappings) is likewise not a live OSS concept, so there is no - * mapping re-resolution to run. Both integrations belong to later stages. + * (group→org-role mappings), re-added in Stage D, IS re-resolved here: the + * cascade takes this group's GroupRoleMapping with it, which can unshadow a + * lower-priority mapping, so a mapping re-resolution runs after the delete — + * guarded on the selected roleMapping, org-wide, trigger "group-deleted", and + * raise-only, so it is never a demotion (see below). */ export const deleteOrgGroup = async ( organizationId: string, + actorUserId: string, groupId: string, ): Promise => { const group = await db.group.findFirst({ @@ -297,6 +306,7 @@ export const deleteOrgGroup = async ( name: true, source: true, _count: { select: { members: true, projectAccess: true } }, + roleMapping: { select: { id: true } }, }, }); if (!group) throw new ServiceError("NOT_FOUND", "Group not found."); @@ -316,11 +326,26 @@ export const deleteOrgGroup = async ( }); if (count === 0) throw new ServiceError("NOT_FOUND", "Group not found."); + // The cascade took this group's mapping with it, which can UNSHADOW a + // lower-priority mapping (e.g. the deleted group was the priority-0 `member` + // mapping suppressing an `admin` one), so the whole org is re-resolved — + // group-scoped would be wrong here, the group is gone. Guarded on the + // mapping the impact read already selected, so the overwhelmingly common + // "delete an unmapped group" path costs nothing extra. Roles are never + // REVERTED by a delete: nothing in this system lowers a role (decision C). + // The trigger is its own value: the cause is a mapping cascade, not a + // membership edit, and the whole point of the discriminator is letting an + // operator split those apart in the MEMBER audit rows. + if (group.roleMapping) { + await applyOrgRoleMappings(organizationId, actorUserId, "group-deleted"); + } + return { id: group.id, name: group.name, removedMembers: group._count.members, removedProjectBindings: group._count.projectAccess, + removedRoleMappings: group.roleMapping ? 1 : 0, }; }; @@ -401,6 +426,27 @@ const assertOrgMembers = async (organizationId: string, userIds: string[]) => { } }; +// Group→role mappings are re-resolved after every membership write (cloud +// applies them at SSO login; OSS has no SSO, so the write IS the trigger). +// They are a FLOOR: a mapping can only ever RAISE a member's org role (see +// org-role-mapping-service.ts, decision C), so a removal almost never changes +// a role — but the remove paths call the same hook anyway. Keeping the seam +// one uniform shape is the point: an edition that flips to two-way sync would +// otherwise silently skip the path that matters most. The cost is one indexed +// lookup on `group_role_mappings`. +// +// REMOVALS ARE UNSHADOWED. The group-scoped apply resolves the group's members +// as they are AFTER the write, so the removal paths hand it the ids they just +// removed (`applyRoleMappingsForGroup(orgId, groupId, actorId, removedIds)`). +// Without that a user held down by a high-priority `member` mapping on this +// group, while a lower-priority `admin` mapping also covered them, would stay +// under-privileged after leaving it — the same unshadow decision E handles +// org-wide for `deleteOrgGroup`. Adders pass nothing: their ids are already in +// the group. +// +// Each call happens AFTER its own write commits, never inside the +// transaction — the apply writes member rows and audit rows of its own. + /** * Replace the group's member set. Returns the honest delta; a no-delta call * returns `{ added: 0, removed: 0 }` WITHOUT opening a transaction (and the @@ -448,6 +494,13 @@ export const setOrgGroupMembers = async ( }), ]); + await applyRoleMappingsForGroup( + organizationId, + groupId, + actorUserId, + toRemove, + ); + return { added: toAdd.length, removed: toRemove.length }; }; @@ -475,6 +528,8 @@ export const addOrgGroupMember = async ( update: {}, }); + await applyRoleMappingsForGroup(organizationId, groupId, actorUserId); + return { added: !existing }; }; @@ -484,6 +539,7 @@ export const addOrgGroupMember = async ( */ export const removeOrgGroupMember = async ( organizationId: string, + actorUserId: string, groupId: string, userId: string, ): Promise<{ removed: boolean }> => { @@ -493,5 +549,9 @@ export const removeOrgGroupMember = async ( where: { groupId, userId }, }); + await applyRoleMappingsForGroup(organizationId, groupId, actorUserId, [ + userId, + ]); + return { removed: count > 0 }; }; diff --git a/packages/api/src/services/org-role-mapping-service.test.ts b/packages/api/src/services/org-role-mapping-service.test.ts new file mode 100644 index 00000000..5fca0c61 --- /dev/null +++ b/packages/api/src/services/org-role-mapping-service.test.ts @@ -0,0 +1,254 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// The pure resolver behind group→role mappings. No DB mock at all (the +// org-role-resolver.test.ts / policy-target.test.ts precedent): this function +// decides who gets promoted, so a wrong answer here is a privilege escalation +// or a silent demotion. Every decision letter it encodes is pinned below. + +vi.hoisted(() => { + process.env.NEXT_PUBLIC_EDITION = "oss"; +}); + +const warn = vi.hoisted(() => vi.fn()); + +vi.mock("../lib/logger", () => ({ + logger: { child: () => ({ warn }) }, +})); + +vi.mock("@onecli/db", () => ({ Prisma: {}, db: {} })); + +import { + resolveRoleMappingChanges, + type MappingRule, + type MemberState, + type RoleChange, +} from "./org-role-mapping-service"; + +const ACTOR = "user-actor"; + +const at = (minutes: number) => new Date(Date.UTC(2026, 0, 1, 0, minutes)); + +const rule = ( + id: string, + groupId: string, + role: string, + priority: number, + createdAt = at(priority), +): MappingRule => ({ id, groupId, role, priority, createdAt }); + +const member = (userId: string, role: string): MemberState => ({ + userId, + role, + userEmail: `${userId}@example.com`, +}); + +const groups = (entries: Record) => + new Map(Object.entries(entries).map(([g, ids]) => [g, new Set(ids)])); + +const resolve = (input: { + mappings: MappingRule[]; + membersByGroup: Map>; + candidates: MemberState[]; + actorUserId?: string; +}): RoleChange[] => + resolveRoleMappingChanges({ + mappings: input.mappings, + membersByGroup: input.membersByGroup, + candidates: input.candidates, + actorUserId: input.actorUserId ?? ACTOR, + }); + +beforeEach(() => { + warn.mockClear(); +}); + +describe("resolveRoleMappingChanges", () => { + it("returns nothing when the org has no mappings", () => { + expect( + resolve({ + mappings: [], + membersByGroup: groups({ "g-a": ["u-1"] }), + candidates: [member("u-1", "member")], + }), + ).toEqual([]); + }); + + it("raises a member through a single admin mapping", () => { + const changes = resolve({ + mappings: [rule("rm-1", "g-a", "admin", 0)], + membersByGroup: groups({ "g-a": ["u-1"] }), + candidates: [member("u-1", "member")], + }); + expect(changes).toEqual([ + { + userId: "u-1", + userEmail: "u-1@example.com", + from: "member", + to: "admin", + mappingId: "rm-1", + groupId: "g-a", + }, + ]); + }); + + it("raises nobody through a member mapping (everyone is already >= member)", () => { + expect( + resolve({ + mappings: [rule("rm-1", "g-a", "member", 0)], + membersByGroup: groups({ "g-a": ["u-1", "u-2"] }), + candidates: [member("u-1", "member"), member("u-2", "member")], + }), + ).toEqual([]); + }); + + it("lets a member mapping at priority 0 SHADOW an admin mapping at priority 1", () => { + // The load-bearing case: role strength plays no part in picking the + // winner, only explicit order does (decision A3/C5). + expect( + resolve({ + mappings: [ + rule("rm-shadow", "g-everyone", "member", 0), + rule("rm-eng", "g-eng", "admin", 1), + ], + membersByGroup: groups({ + "g-everyone": ["u-1"], + "g-eng": ["u-1"], + }), + candidates: [member("u-1", "member")], + }), + ).toEqual([]); + }); + + it("...and reordering the admin mapping to priority 0 raises them", () => { + const changes = resolve({ + mappings: [ + rule("rm-eng", "g-eng", "admin", 0), + rule("rm-shadow", "g-everyone", "member", 1), + ], + membersByGroup: groups({ "g-everyone": ["u-1"], "g-eng": ["u-1"] }), + candidates: [member("u-1", "member")], + }); + expect(changes).toHaveLength(1); + expect(changes[0]).toMatchObject({ to: "admin", mappingId: "rm-eng" }); + }); + + it("breaks a priority tie by createdAt asc, then id asc", () => { + const older = resolve({ + mappings: [ + rule("rm-b", "g-b", "admin", 0, at(9)), + rule("rm-a", "g-a", "member", 0, at(5)), + ], + membersByGroup: groups({ "g-a": ["u-1"], "g-b": ["u-1"] }), + candidates: [member("u-1", "member")], + }); + // rm-a is older, so its `member` wins and nothing is raised. + expect(older).toEqual([]); + + const sameInstant = resolve({ + mappings: [ + rule("rm-z", "g-b", "admin", 0, at(5)), + rule("rm-a", "g-a", "member", 0, at(5)), + ], + membersByGroup: groups({ "g-a": ["u-1"], "g-b": ["u-1"] }), + candidates: [member("u-1", "member")], + }); + // Same instant: id asc decides, and "rm-a" sorts first. + expect(sameInstant).toEqual([]); + }); + + it("NEVER demotes: an admin under a winning member mapping is untouched", () => { + expect( + resolve({ + mappings: [rule("rm-1", "g-a", "member", 0)], + membersByGroup: groups({ "g-a": ["u-1"] }), + candidates: [member("u-1", "admin")], + }), + ).toEqual([]); + }); + + it("never touches an owner, under either kind of mapping", () => { + for (const role of ["admin", "member"] as const) { + expect( + resolve({ + mappings: [rule("rm-1", "g-a", role, 0)], + membersByGroup: groups({ "g-a": ["u-owner"] }), + candidates: [member("u-owner", "owner")], + }), + ).toEqual([]); + } + }); + + it("skips the acting user (mirrors 'you cannot change your own role')", () => { + expect( + resolve({ + mappings: [rule("rm-1", "g-a", "admin", 0)], + membersByGroup: groups({ "g-a": [ACTOR] }), + candidates: [member(ACTOR, "member")], + }), + ).toEqual([]); + }); + + it("skips — and warns about — a current role it does not understand", () => { + expect( + resolve({ + mappings: [rule("rm-1", "g-a", "admin", 0)], + membersByGroup: groups({ "g-a": ["u-1"] }), + candidates: [member("u-1", "superadmin")], + }), + ).toEqual([]); + expect(warn).toHaveBeenCalledTimes(1); + }); + + it("skips — and warns about — an unrecognized role ON THE MAPPING", () => { + // Fail closed on the winner rather than falling through to the next + // mapping, which would grant more than the config says. + expect( + resolve({ + mappings: [ + rule("rm-bad", "g-a", "superadmin", 0), + rule("rm-ok", "g-b", "admin", 1), + ], + membersByGroup: groups({ "g-a": ["u-1"], "g-b": ["u-1"] }), + candidates: [member("u-1", "member")], + }), + ).toEqual([]); + expect(warn).toHaveBeenCalledTimes(1); + }); + + it("leaves a user who is in no mapped group alone", () => { + expect( + resolve({ + mappings: [rule("rm-1", "g-a", "admin", 0)], + membersByGroup: groups({ "g-a": ["u-1"] }), + candidates: [member("u-1", "member"), member("u-2", "member")], + }).map((c) => c.userId), + ).toEqual(["u-1"]); + }); + + it("is idempotent: feeding the result back as current state changes nothing", () => { + const mappings = [ + rule("rm-1", "g-a", "admin", 0), + rule("rm-2", "g-b", "member", 1), + ]; + const membersByGroup = groups({ + "g-a": ["u-1", "u-2"], + "g-b": ["u-2", "u-3"], + }); + const candidates = [ + member("u-1", "member"), + member("u-2", "member"), + member("u-3", "member"), + ]; + + const first = resolve({ mappings, membersByGroup, candidates }); + expect(first.map((c) => c.userId).sort()).toEqual(["u-1", "u-2"]); + + const applied = candidates.map((c) => { + const change = first.find((ch) => ch.userId === c.userId); + return change ? { ...c, role: change.to } : c; + }); + expect(resolve({ mappings, membersByGroup, candidates: applied })).toEqual( + [], + ); + }); +}); diff --git a/packages/api/src/services/org-role-mapping-service.ts b/packages/api/src/services/org-role-mapping-service.ts new file mode 100644 index 00000000..044658b6 --- /dev/null +++ b/packages/api/src/services/org-role-mapping-service.ts @@ -0,0 +1,789 @@ +import { db, Prisma } from "@onecli/db"; +import { ServiceError } from "./errors"; +import { logger } from "../lib/logger"; +import { ROLE_HIERARCHY } from "../providers"; +import type { OrgRole } from "../providers"; +import { + recordAuditEvent, + AUDIT_ACTIONS, + AUDIT_SERVICES, + AUDIT_SOURCE, +} from "./audit-service"; +import { MAX_ROLE_MAPPINGS } from "../validations/org"; +import type { + CreateRoleMappingInput, + PreviewRoleMappingInput, + UpdateRoleMappingInput, +} from "../validations/org"; + +/** + * Group → org-role mappings: the mapping CONFIG (list/create/update/delete/ + * reorder/preview) plus the engine that APPLIES it to `OrganizationMember.role`. + * Scoped to ONE organization on every call — the caller's + * `auth.organizationId`, never a body parameter — so this can never read or + * write across orgs. + * + * WHEN IT APPLIES. In the cloud edition mappings are re-resolved at SSO login. + * OSS has no SSO, so the apply is driven by writes instead: (a) every + * membership write in `org-group-service.ts` calls + * `applyRoleMappingsForGroup`, and (b) every mapping-config write in + * `routes/org/role-mappings.ts` calls `applyOrgRoleMappings`. + * + * PRECEDENCE. `priority` is an ASCENDING rank — 0 wins. The FIRST mapping (by + * `priority asc, createdAt asc, id asc`) whose group contains the user + * decides that user's mapped role. Role strength plays NO part in choosing + * the winner, which is what makes ordering load-bearing: a `member` mapping at + * priority 0 SHADOWS an `admin` mapping at priority 3 for anyone in both + * groups. That is the documented way to carve an exception out of a broad + * grant. + * + * ── DECISION C: a mapping is a FLOOR, never a ceiling ──────────────────── + * + * `OrganizationMember` has NO provenance column (`organizationId, userId, + * userEmail, role, status, suspendedAt, ssoExempt, createdAt`), so the system + * cannot tell a mapping-assigned `admin` from a hand-promoted one. Deriving + * provenance from the audit log was rejected: audit writes are best-effort by + * design (`logAuditEvent` swallows its own errors), and an authorization + * decision must never hang off a log that is allowed to drop rows. + * + * The apply is therefore MONOTONIC — it may only ever RAISE a role: + * + * target(U) = strongest(current(U), mappedRole(U)) + * write iff target(U) !== current(U) // i.e. iff it is a strict raise + * + * Consequences, all deliberate: + * - a `member` mapping demotes nobody; its only effect is shadowing; + * - removing a user from an `admin`-mapped group does not demote them — the + * grant sticks; + * - a mapping is a FLOOR, so `PATCH /v1/org/members/:userId` does NOT durably + * undo one. Hand-demoting a user who is STILL in a live `admin`-mapped + * group lasts only until the next apply, which re-raises them (the apply is + * a `max`, and it has no way to know the demotion was deliberate). The + * durable remedy is to remove the user from the mapped group, or to + * delete/reorder the mapping — THEN demote. Route-pinned in + * role-mappings.test.ts ("a hand-demoted member in an admin-mapped group is + * re-raised on the next apply"); + * - the org's active-owner count is structurally unable to fall as a result + * of a mapping: owners are skipped in the resolver AND excluded by the + * `role: { not: "owner" }` predicate on both the candidate read and the + * write, so the apply never issues a statement that touches an owner row. + * + * IF YOU EVER ADD A PROVENANCE COLUMN to `OrganizationMember` (a `roleSource` + * discriminator), `resolveRoleMappingChanges` below is the function to + * revisit — it is the only place the raise-only rule is expressed. + */ + +const log = logger.child({ component: "org-role-mappings" }); + +/** One row of the mappings list (matches the client's `RoleMappingRow`). */ +export interface RoleMappingListRow { + id: string; + groupId: string; + groupName: string; + role: string; + priority: number; + memberCount: number; + createdAt: string; + updatedAt: string; +} + +/** A stored mapping, reduced to what resolution actually needs. */ +export interface MappingRule { + id: string; + groupId: string; + role: string; + priority: number; + createdAt: Date; +} + +/** A candidate member's current state. */ +export interface MemberState { + userId: string; + role: string; + userEmail: string; +} + +/** A single strict RAISE the apply will perform. */ +export interface RoleChange { + userId: string; + userEmail: string; + from: string; + to: string; + mappingId: string; + groupId: string; +} + +/** + * Which seam fired the apply — carried into the MEMBER audit rows so an + * operator can tell "someone joined a mapped group" from "someone edited the + * mapping config" from "a group delete cascaded its mapping away". + */ +export type ApplyTrigger = "membership" | "mapping" | "group-deleted"; + +const ROW_SELECT = { + id: true, + groupId: true, + role: true, + priority: true, + createdAt: true, + updatedAt: true, + group: { select: { name: true, _count: { select: { members: true } } } }, +} as const; + +const RULE_SELECT = { + id: true, + groupId: true, + role: true, + priority: true, + createdAt: true, +} as const; + +/** `priority asc, createdAt asc, id asc` — the ONE resolution order. */ +const LIST_ORDER = [ + { priority: "asc" as const }, + { createdAt: "asc" as const }, + { id: "asc" as const }, +]; + +interface MappingRowShape { + id: string; + groupId: string; + role: string; + priority: number; + createdAt: Date; + updatedAt: Date; + group: { name: string; _count: { members: number } }; +} + +const toRow = (row: MappingRowShape): RoleMappingListRow => ({ + id: row.id, + groupId: row.groupId, + groupName: row.group.name, + role: row.role, + priority: row.priority, + memberCount: row.group._count.members, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), +}); + +/** + * The list is UNPAGED, by contract: `groupId` is unique so mappings are + * bounded by group count, the client types the response as a bare array (not + * a `DirectoryPage`), and resolution needs the whole ordered set anyway. + */ +export const listOrgRoleMappings = async ( + organizationId: string, +): Promise => { + const rows = await db.groupRoleMapping.findMany({ + where: { organizationId }, + select: ROW_SELECT, + orderBy: LIST_ORDER, + }); + return rows.map(toRow); +}; + +/** + * Resolve a mapping WITHIN the caller's org — always + * `findFirst({ id, organizationId })`, NEVER `findUnique({ where: { id } })`: + * a cross-org id must read as absent (404), not leak another org's row. + */ +const requireMapping = async (organizationId: string, id: string) => { + const mapping = await db.groupRoleMapping.findFirst({ + where: { id, organizationId }, + select: { id: true, groupId: true, role: true, priority: true }, + }); + if (!mapping) throw new ServiceError("NOT_FOUND", "Role mapping not found."); + return mapping; +}; + +/** + * Resolve the mapped group, org-scoped. Deliberately NOT the groups service's + * `requireManualGroup`: a `GroupRoleMapping` is an admin-authored OneCLI + * artifact, not an IdP-owned object, so mapping a `scim` group to a role is + * the canonical use case rather than a conflict. + */ +const requireGroup = async (organizationId: string, groupId: string) => { + const group = await db.group.findFirst({ + where: { id: groupId, organizationId }, + select: { id: true, name: true }, + }); + if (!group) throw new ServiceError("NOT_FOUND", "Group not found."); + return group; +}; + +const readRow = async (organizationId: string, id: string) => { + const row = await db.groupRoleMapping.findFirst({ + where: { id, organizationId }, + select: ROW_SELECT, + }); + if (!row) throw new ServiceError("NOT_FOUND", "Role mapping not found."); + return toRow(row); +}; + +const isUniqueViolation = (err: unknown) => + err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002"; + +/** + * Per-org advisory lock, cloned in spirit from `policy-service.ts`'s + * `lockScope` (whose comment spells out why: an unlocked read-then-append can + * mint duplicate priorities under concurrency). Not imported from there — + * that helper is policy-scope-shaped. + */ +const lockOrgRoleMappings = ( + tx: Prisma.TransactionClient, + organizationId: string, +) => + tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${`role-mappings:${organizationId}`}))`; + +// ── Resolution ──────────────────────────────────────────────────────────── + +const isOrgRole = (role: string): role is OrgRole => + Object.prototype.hasOwnProperty.call(ROLE_HIERARCHY, role); + +const byPrecedence = (a: MappingRule, b: MappingRule) => + a.priority - b.priority || + a.createdAt.getTime() - b.createdAt.getTime() || + a.id.localeCompare(b.id); + +/** + * PURE. No DB, no clock. Given the org's mapping set, who is in which mapped + * group, and each candidate's current role, return the strict RAISES to + * apply. Preview and apply both call this — which is what makes the preview + * structurally incapable of lying about what the apply will do. + * + * `O(candidates × mappings)` with set lookups. Mappings are bounded by group + * count on a single-instance OSS surface, so no index gymnastics are needed. + */ +export const resolveRoleMappingChanges = (input: { + mappings: readonly MappingRule[]; + /** groupId → userIds, restricted to mapped groups. */ + membersByGroup: ReadonlyMap>; + candidates: readonly MemberState[]; + actorUserId: string; +}): RoleChange[] => { + const { mappings, membersByGroup, candidates, actorUserId } = input; + if (mappings.length === 0) return []; + + // Defensive: the loaders already order by this, but resolution must never + // depend on the caller having done so. + const ordered = [...mappings].sort(byPrecedence); + const changes: RoleChange[] = []; + + for (const candidate of candidates) { + // C2 — the acting user is skipped, mirroring `updateOrgMemberRole`'s "you + // cannot change your own role". Under raise-only this can only ever fail + // to promote the actor (fail-closed); anyone else's apply converges them. + if (candidate.userId === actorUserId) continue; + // C1 — owners are untouchable, so the org can never lose its last owner. + if (candidate.role === "owner") continue; + // C3 — never overwrite a role the system does not understand (the same + // fail-closed stance `ossRoleResolver` takes for garbage role strings). + if (!isOrgRole(candidate.role)) { + log.warn( + { userId: candidate.userId, role: candidate.role }, + "unrecognized organization member role — skipping role mapping", + ); + continue; + } + + const winner = ordered.find( + (mapping) => + membersByGroup.get(mapping.groupId)?.has(candidate.userId) ?? false, + ); + // Unmapped: the apply never touches this user. + if (!winner) continue; + if (!isOrgRole(winner.role)) { + log.warn( + { mappingId: winner.id, role: winner.role }, + "unrecognized role on a group role mapping — skipping", + ); + continue; + } + + // THE raise-only rule (decision C): write only on a strict raise. + if (ROLE_HIERARCHY[winner.role] <= ROLE_HIERARCHY[candidate.role]) continue; + + changes.push({ + userId: candidate.userId, + userEmail: candidate.userEmail, + from: candidate.role, + to: winner.role, + mappingId: winner.id, + groupId: winner.groupId, + }); + } + + return changes; +}; + +// ── Applying ────────────────────────────────────────────────────────────── + +type ApplyScope = + | { kind: "org" } + | { + kind: "group"; + groupId: string; + /** Ids the writer just REMOVED from the group (see below). */ + extraUserIds: readonly string[]; + }; + +const loadMappingRules = (organizationId: string): Promise => + db.groupRoleMapping.findMany({ + where: { organizationId }, + select: RULE_SELECT, + orderBy: LIST_ORDER, + }); + +/** + * Load the candidate members and the mapped groups' membership. + * `scopedUserIds` narrows both reads to one group's members; `undefined` + * means "every member of the org". + */ +const loadResolverState = async ( + organizationId: string, + mappings: readonly MappingRule[], + scopedUserIds: string[] | undefined, +) => { + const scoped = scopedUserIds ? { userId: { in: scopedUserIds } } : {}; + + // `role: { not: "owner" }` is decision C1 at the query layer; the resolver + // repeats the skip so it stays correct in isolation (and under unit test). + const candidates = await db.organizationMember.findMany({ + where: { organizationId, role: { not: "owner" }, ...scoped }, + select: { userId: true, role: true, userEmail: true }, + }); + + const mappedGroupIds = [...new Set(mappings.map((m) => m.groupId))]; + const memberships = await db.groupMember.findMany({ + where: { groupId: { in: mappedGroupIds }, ...scoped }, + select: { groupId: true, userId: true }, + }); + + const membersByGroup = new Map>(); + for (const row of memberships) { + const set = membersByGroup.get(row.groupId); + if (set) set.add(row.userId); + else membersByGroup.set(row.groupId, new Set([row.userId])); + } + + return { candidates, membersByGroup }; +}; + +/** + * The actor's email for the MEMBER audit rows (`AuditEventParams.userEmail` is + * required). Resolved lazily — only on the path that already writes N rows. + */ +const resolveActorEmail = async ( + organizationId: string, + actorUserId: string, +): Promise => { + const member = await db.organizationMember.findUnique({ + where: { organizationId_userId: { organizationId, userId: actorUserId } }, + select: { userEmail: true }, + }); + if (member?.userEmail) return member.userEmail; + const user = await db.user.findUnique({ + where: { id: actorUserId }, + select: { email: true }, + }); + return user?.email ?? ""; +}; + +const applyRoleMappings = async ( + organizationId: string, + actorUserId: string, + scope: ApplyScope, + trigger: ApplyTrigger, +): Promise<{ changed: number }> => { + const mappings = await loadMappingRules(organizationId); + // Short-circuit before touching membership: an org with no mappings has + // nothing to resolve. + if (mappings.length === 0) return { changed: 0 }; + + let scopedUserIds: string[] | undefined; + if (scope.kind === "group") { + // Adding to (or removing from) an UNMAPPED group cannot change anybody's + // mapped set — the winner is chosen only among mapped groups. + if (!mappings.some((m) => m.groupId === scope.groupId)) + return { changed: 0 }; + const rows = await db.groupMember.findMany({ + where: { groupId: scope.groupId }, + select: { userId: true }, + }); + // The candidate set is this group's members AFTER the write, UNIONED with + // the ids the writer just removed. The union is what makes the removal + // paths converge: a user dropped from a high-priority `member` group that + // was SHADOWING a lower-priority `admin` mapping must be re-resolved + // against the mappings that still cover them (the unshadow case decision E + // handles org-wide for `deleteOrgGroup`), and the post-write read can no + // longer see them. The membership load below is filtered on the same id + // list, so a removed user simply has no row for this group and falls + // through to the next mapping — the resolver stays exactly as correct. + scopedUserIds = [ + ...new Set([...rows.map((row) => row.userId), ...scope.extraUserIds]), + ]; + // Nothing to resolve only when the union is empty — removing the LAST + // member of a mapped group still has that member as a candidate. + if (scopedUserIds.length === 0) return { changed: 0 }; + } + + const { candidates, membersByGroup } = await loadResolverState( + organizationId, + mappings, + scopedUserIds, + ); + + const changes = resolveRoleMappingChanges({ + mappings, + membersByGroup, + candidates, + actorUserId, + }); + // Idempotence made observable: `strongest(current, mapped)` is a max over a + // total order, so a converged org produces an empty change set — and we + // return BEFORE opening any transaction (the `setOrgGroupMembers` no-delta + // precedent), writing zero rows and zero audit events. + if (changes.length === 0) return { changed: 0 }; + + const byRole = new Map(); + for (const change of changes) { + const ids = byRole.get(change.to); + if (ids) ids.push(change.userId); + else byRole.set(change.to, [change.userId]); + } + + await db.$transaction( + [...byRole].map(([role, userIds]) => + db.organizationMember.updateMany({ + // `role: { not: "owner" }` again: the last-owner invariant enforced at + // the WRITE layer, so even a resolver bug cannot lower an owner. + where: { + organizationId, + userId: { in: userIds }, + role: { not: "owner" }, + }, + data: { role }, + }), + ), + ); + + // Audits are written AFTER the transaction commits, never inside it: a + // swallowed audit error must not roll back a role change. `recordAuditEvent` + // deliberately does NOT flush the gateway cache — every caller of the apply + // is already inside a `withAudit` carrying `organizationId`, which flushes + // once at the end of the request. Do not "fix" this into N flushes. + const actorEmail = await resolveActorEmail(organizationId, actorUserId); + for (const change of changes) { + await recordAuditEvent({ + organizationId, + userId: actorUserId, + userEmail: actorEmail, + action: AUDIT_ACTIONS.UPDATE, + service: AUDIT_SERVICES.MEMBER, + source: AUDIT_SOURCE.API, + metadata: { + targetUserId: change.userId, + role: change.to, + previousRole: change.from, + // THE discriminator: what lets an operator answer "why is this person + // suddenly an admin?" from the audit log alone (the `via: + // "invitation"` precedent in audit-service.ts). + via: "role-mapping", + mappingId: change.mappingId, + groupId: change.groupId, + trigger, + }, + }); + } + + return { changed: changes.length }; +}; + +/** + * Re-resolve mapped org roles after a change to ONE group's membership. + * Called by the three membership writers in `org-group-service.ts`. + * + * `extraUserIds` are the ids the caller just REMOVED from the group: they are + * gone from the group by the time this runs, so the caller is the only thing + * that still knows they need re-resolving. Adders pass nothing. + */ +export const applyRoleMappingsForGroup = ( + organizationId: string, + groupId: string, + actorUserId: string, + extraUserIds: readonly string[] = [], +): Promise<{ changed: number }> => + applyRoleMappings( + organizationId, + actorUserId, + { kind: "group", groupId, extraUserIds }, + "membership", + ); + +/** + * Re-resolve every member's mapped role. Called by the mapping-config routes + * (a create/update/reorder/delete can change who wins for any group) and by + * `deleteOrgGroup`, whose cascade can UNSHADOW a lower-priority mapping. + */ +export const applyOrgRoleMappings = ( + organizationId: string, + actorUserId: string, + trigger: ApplyTrigger = "mapping", +): Promise<{ changed: number }> => + applyRoleMappings(organizationId, actorUserId, { kind: "org" }, trigger); + +// ── CRUD ────────────────────────────────────────────────────────────────── + +export const createOrgRoleMapping = async ( + organizationId: string, + actorUserId: string, + input: CreateRoleMappingInput, +): Promise<{ mapping: RoleMappingListRow; rolesChanged: number }> => { + await requireGroup(organizationId, input.groupId); + + // `groupId` is UNIQUE — at most one mapping per group. Friendly pre-check + // for the common case; the P2002 catch below covers the create-create race + // the pre-check cannot see (the `createOrgGroup` shape). + const dupe = await db.groupRoleMapping.findFirst({ + where: { groupId: input.groupId }, + select: { id: true }, + }); + if (dupe) { + throw new ServiceError( + "CONFLICT", + "This group already has a role mapping.", + ); + } + + let createdId: string; + try { + const created = await db.$transaction(async (tx) => { + await lockOrgRoleMappings(tx, organizationId); + // The set ceiling, checked under the same lock as the append so a race + // cannot slip past it. This is what keeps `PUT /order` reachable: the + // reorder body must be able to name every mapping at once + // (MAX_ROLE_MAPPINGS in validations/org.ts spells out the invariant). + const total = await tx.groupRoleMapping.count({ + where: { organizationId }, + }); + if (total >= MAX_ROLE_MAPPINGS) { + throw new ServiceError( + "CONFLICT", + `An organization can have at most ${MAX_ROLE_MAPPINGS} role mappings.`, + ); + } + // Append at max+1 (0 for the first mapping). Priorities are a RELATIVE + // rank and need not be dense — a cascade delete leaves holes, and + // resolution only ever compares them. + const agg = await tx.groupRoleMapping.aggregate({ + where: { organizationId }, + _max: { priority: true }, + }); + const priority = input.priority ?? (agg._max.priority ?? -1) + 1; + return tx.groupRoleMapping.create({ + data: { + organizationId, + groupId: input.groupId, + role: input.role, + priority, + }, + select: { id: true }, + }); + }); + createdId = created.id; + } catch (err) { + if (isUniqueViolation(err)) { + throw new ServiceError( + "CONFLICT", + "This group already has a role mapping.", + ); + } + throw err; + } + + // Outside the transaction: the apply writes member rows and audit rows, and + // must not hold the mapping lock while it does. + const { changed } = await applyOrgRoleMappings(organizationId, actorUserId); + return { + mapping: await readRow(organizationId, createdId), + rolesChanged: changed, + }; +}; + +export const updateOrgRoleMapping = async ( + organizationId: string, + actorUserId: string, + id: string, + input: UpdateRoleMappingInput, +): Promise<{ mapping: RoleMappingListRow; rolesChanged: number }> => { + await requireMapping(organizationId, id); + + // Org-scoped conditional write (the `renameOrgGroup` pattern): a count of 0 + // means the row vanished between the check and the write — a 404, not the + // P2025 500 a bare `update()` would surface. + const { count } = await db.groupRoleMapping.updateMany({ + where: { id, organizationId }, + data: { + role: input.role, + ...(input.priority !== undefined ? { priority: input.priority } : {}), + }, + }); + if (count === 0) + throw new ServiceError("NOT_FOUND", "Role mapping not found."); + + const { changed } = await applyOrgRoleMappings(organizationId, actorUserId); + return { mapping: await readRow(organizationId, id), rolesChanged: changed }; +}; + +export const deleteOrgRoleMapping = async ( + organizationId: string, + actorUserId: string, + id: string, +): Promise<{ + id: string; + groupId: string; + role: string; + rolesChanged: number; +}> => { + const mapping = await requireMapping(organizationId, id); + + const { count } = await db.groupRoleMapping.deleteMany({ + where: { id, organizationId }, + }); + if (count === 0) + throw new ServiceError("NOT_FOUND", "Role mapping not found."); + + // The UNSHADOW case: removing a high-priority `member` mapping can promote + // everyone it was suppressing, so the delete re-resolves the whole org. + const { changed } = await applyOrgRoleMappings(organizationId, actorUserId); + return { + id: mapping.id, + groupId: mapping.groupId, + role: mapping.role, + rolesChanged: changed, + }; +}; + +export const reorderOrgRoleMappings = async ( + organizationId: string, + actorUserId: string, + orderedIds: string[], +): Promise<{ mappings: RoleMappingListRow[]; rolesChanged: number }> => { + try { + await db.$transaction(async (tx) => { + // Validate + write under the same per-org lock `create` takes, so a + // reorder cannot interleave with a concurrent append. + await lockOrgRoleMappings(tx, organizationId); + const current = await tx.groupRoleMapping.findMany({ + where: { organizationId }, + select: { id: true, priority: true }, + orderBy: LIST_ORDER, + }); + const ids = new Set(current.map((row) => row.id)); + const namesEveryMappingOnce = + orderedIds.length === ids.size && + new Set(orderedIds).size === orderedIds.length && + orderedIds.every((id) => ids.has(id)); + if (!namesEveryMappingOnce) { + throw new ServiceError( + "CONFLICT", + "Role mapping set changed — refresh and try again.", + ); + } + + // No-delta early return (the `setOrgGroupMembers` convention): the + // stored order already IS the requested one and priorities are already + // dense `0..n-1`, so there is nothing to write. + const settled = current.every( + (row, i) => row.id === orderedIds[i] && row.priority === i, + ); + if (settled) return; + + // Ascending, 0-based: index 0 → priority 0 (highest precedence). + for (const [i, id] of orderedIds.entries()) { + await tx.groupRoleMapping.update({ + where: { id }, + data: { priority: i }, + }); + } + }); + } catch (err) { + // A delete committed between the in-tx read and an update (deletes don't + // take the lock) surfaces as P2025 — same staleness, same 409. + if ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === "P2025" + ) { + throw new ServiceError( + "CONFLICT", + "Role mapping set changed — refresh and try again.", + ); + } + throw err; + } + + // Runs even on the settled path: it is the only thing that converges an org + // whose mapping set was edited out of band, and it is a no-op when nothing + // changed. + const { changed } = await applyOrgRoleMappings(organizationId, actorUserId); + return { + mappings: await listOrgRoleMappings(organizationId), + rolesChanged: changed, + }; +}; + +/** + * Dry run for the create/edit dialog: how many members the proposed mapping + * would RAISE. Zero writes, zero audit rows — it is a POST only because the + * client made it one. + * + * Because the count only ever counts raises, a `member` mapping (and an + * `admin → member` edit) previews as `0`. That is truthful: such a mapping's + * effect is shadowing, not demotion. + */ +export const previewOrgRoleMapping = async ( + organizationId: string, + actorUserId: string, + input: PreviewRoleMappingInput, +): Promise<{ affectedCount: number }> => { + // Org-scoped: a preview against a foreign group must not leak its existence. + await requireGroup(organizationId, input.groupId); + + const stored = await loadMappingRules(organizationId); + const existing = stored.find((m) => m.groupId === input.groupId); + const proposed: MappingRule[] = existing + ? // An existing mapping keeps its SLOT — only the role is proposed, so a + // shadowed group correctly previews as 0. + stored.map((m) => + m.groupId === input.groupId ? { ...m, role: input.role } : m, + ) + : [ + ...stored, + { + id: "preview", + groupId: input.groupId, + role: input.role, + // Mirrors create's default, so the preview matches what the create + // button will actually do. + priority: + stored.reduce((max, m) => Math.max(max, m.priority), -1) + 1, + createdAt: new Date(), + }, + ]; + + const { candidates, membersByGroup } = await loadResolverState( + organizationId, + proposed, + undefined, + ); + + // Same resolver, same actor: the preview inherits the owner skip, the self + // skip and the raise-only rule, and therefore cannot over-promise. + const changes = resolveRoleMappingChanges({ + mappings: proposed, + membersByGroup, + candidates, + actorUserId, + }); + return { affectedCount: changes.length }; +}; diff --git a/packages/api/src/services/organization-service.test.ts b/packages/api/src/services/organization-service.test.ts index e93a98b4..5d3e73b7 100644 --- a/packages/api/src/services/organization-service.test.ts +++ b/packages/api/src/services/organization-service.test.ts @@ -14,6 +14,8 @@ interface MemberRow { userId: string; userEmail: string; role: string; + /** Absent on the provisioning writes; Prisma defaults it to "active". */ + status?: string; } interface ProjectRow { id: string; @@ -24,6 +26,17 @@ interface ProjectRow { createdByUserEmail: string | null; seq: number; } +interface BindingRow { + projectId: string; + /** Exactly one of userId/groupId, as the DB CHECK requires. */ + userId?: string; + groupId?: string; + role: string; +} +interface GroupMemberRow { + groupId: string; + userId: string; +} interface ApiKeyRow { key: string; userId: string; @@ -36,99 +49,208 @@ const store = vi.hoisted(() => ({ orgs: [] as OrgRow[], members: [] as MemberRow[], projects: [] as ProjectRow[], + bindings: [] as BindingRow[], + groupMembers: [] as GroupMemberRow[], apiKeys: [] as ApiKeyRow[], seq: 0, })); -vi.mock("@onecli/db", () => ({ - db: { - organization: { - findUnique: async ({ where: { slug } }: { where: { slug: string } }) => - store.orgs.find((o) => o.slug === slug) ?? null, - findUniqueOrThrow: async ({ - where: { slug }, - }: { - where: { slug: string }; - }) => { - const org = store.orgs.find((o) => o.slug === slug); - if (!org) throw new Error(`org ${slug} not found`); - return org; - }, - create: async ({ data }: { data: OrgRow }) => { - if (store.orgs.some((o) => o.slug === data.slug)) { - throw new Error("unique constraint: organization.slug"); - } - const org: OrgRow = { id: data.id, slug: data.slug, name: data.name }; - store.orgs.push(org); - return org; - }, - }, - organizationMember: { - upsert: async ({ - where: { organizationId_userId }, - create, - }: { - where: { - organizationId_userId: { organizationId: string; userId: string }; - }; - create: MemberRow; - }) => { - const existing = store.members.find( - (m) => - m.organizationId === organizationId_userId.organizationId && - m.userId === organizationId_userId.userId, +vi.mock("@onecli/db", () => { + /** The subset of the Prisma project `where` shapes these helpers build. */ + interface BindingClause { + userId?: string; + group?: { members: { some: { userId: string } } }; + } + interface ProjectWhere { + id?: { not: string }; + organizationId?: string; + createdByUserId?: string; + organization?: { + members: { some: { userId: string; status?: { not?: string } } }; + }; + accessBindings?: { some: { OR: BindingClause[] } }; + OR?: ProjectWhere[]; + } + + /** A binding on `projectId` satisfying any clause — direct or via a group. */ + const matchesBinding = (projectId: string, clauses: BindingClause[]) => + clauses.some((clause) => { + if (clause.userId !== undefined) { + return store.bindings.some( + (b) => b.projectId === projectId && b.userId === clause.userId, ); - if (existing) return existing; - store.members.push(create); - return create; + } + const userId = clause.group?.members.some.userId; + if (userId === undefined) return false; + return store.bindings.some( + (b) => + b.projectId === projectId && + b.groupId !== undefined && + store.groupMembers.some( + (gm) => gm.groupId === b.groupId && gm.userId === userId, + ), + ); + }); + + const matchesProject = (p: ProjectRow, where: ProjectWhere): boolean => { + if (where.id?.not !== undefined && p.id === where.id.not) return false; + if ( + where.organizationId !== undefined && + p.organizationId !== where.organizationId + ) + return false; + if ( + where.createdByUserId !== undefined && + p.createdByUserId !== where.createdByUserId + ) + return false; + if (where.organization) { + const { userId, status } = where.organization.members.some; + const membership = store.members.find( + (m) => m.organizationId === p.organizationId && m.userId === userId, + ); + if (!membership) return false; + if ( + status?.not !== undefined && + (membership.status ?? "active") === status.not + ) + return false; + } + if ( + where.accessBindings && + !matchesBinding(p.id, where.accessBindings.some.OR) + ) + return false; + if (where.OR && !where.OR.some((sub) => matchesProject(p, sub))) + return false; + return true; + }; + + return { + db: { + organization: { + findUnique: async ({ where: { slug } }: { where: { slug: string } }) => + store.orgs.find((o) => o.slug === slug) ?? null, + findUniqueOrThrow: async ({ + where: { slug }, + }: { + where: { slug: string }; + }) => { + const org = store.orgs.find((o) => o.slug === slug); + if (!org) throw new Error(`org ${slug} not found`); + return org; + }, + create: async ({ data }: { data: OrgRow }) => { + if (store.orgs.some((o) => o.slug === data.slug)) { + throw new Error("unique constraint: organization.slug"); + } + const org: OrgRow = { id: data.id, slug: data.slug, name: data.name }; + store.orgs.push(org); + return org; + }, }, - }, - project: { - findFirst: async ({ - where: { organizationId, createdByUserId }, - }: { - where: { organizationId: string; createdByUserId: string }; - }) => - store.projects - .filter( - (p) => - p.organizationId === organizationId && - p.createdByUserId === createdByUserId, - ) - .sort((a, b) => a.seq - b.seq)[0] ?? null, - create: async ({ data }: { data: Omit }) => { - if ( - store.projects.some( - (p) => - p.organizationId === data.organizationId && p.slug === data.slug, - ) - ) { - throw new Error("unique constraint: (organizationId, slug)"); - } - const project: ProjectRow = { ...data, seq: store.seq++ }; - store.projects.push(project); - return project; + organizationMember: { + upsert: async ({ + where: { organizationId_userId }, + create, + }: { + where: { + organizationId_userId: { organizationId: string; userId: string }; + }; + create: MemberRow; + }) => { + const existing = store.members.find( + (m) => + m.organizationId === organizationId_userId.organizationId && + m.userId === organizationId_userId.userId, + ); + if (existing) return existing; + store.members.push(create); + return create; + }, + findFirst: async ({ + where, + }: { + where: { userId: string; status?: { not?: string } }; + }) => + store.members.find( + (m) => + m.userId === where.userId && + !( + where.status?.not !== undefined && + (m.status ?? "active") === where.status.not + ), + ) ?? null, }, - }, - apiKey: { - findFirst: async ({ - where: { organizationId, scope }, - }: { - where: { organizationId: string; scope: string }; - }) => - store.apiKeys.find( - (k) => k.organizationId === organizationId && k.scope === scope, - ) ?? null, - create: async ({ data }: { data: ApiKeyRow }) => { - if (store.apiKeys.some((k) => k.key === data.key)) { - throw new Error("unique constraint: api_key.key"); - } - store.apiKeys.push(data); - return data; + project: { + findFirst: async ({ + where, + select, + }: { + where: ProjectWhere; + select?: { id?: boolean; organizationId?: boolean }; + }) => { + const row = + store.projects + .filter((p) => matchesProject(p, where)) + .sort((a, b) => a.seq - b.seq)[0] ?? null; + // Honour Prisma's `select` so callers see the same narrow shape they + // asked for (these helpers only ever select id + organizationId). + if (!row || !select) return row; + const picked: Record = {}; + if (select.id) picked.id = row.id; + if (select.organizationId) picked.organizationId = row.organizationId; + return picked; + }, + create: async ({ + data, + }: { + data: Omit & { + accessBindings?: { create: { userId: string; role: string } }; + }; + }) => { + if ( + store.projects.some( + (p) => + p.organizationId === data.organizationId && + p.slug === data.slug, + ) + ) { + throw new Error("unique constraint: (organizationId, slug)"); + } + const project: ProjectRow = { ...data, seq: store.seq++ }; + store.projects.push(project); + // Materialize the nested binding write so the binding-fallback arm of + // findUserDefaultProject has something real to find. + if (data.accessBindings) { + store.bindings.push({ + projectId: data.id, + ...data.accessBindings.create, + }); + } + return project; + }, + }, + apiKey: { + findFirst: async ({ + where: { organizationId, scope }, + }: { + where: { organizationId: string; scope: string }; + }) => + store.apiKeys.find( + (k) => k.organizationId === organizationId && k.scope === scope, + ) ?? null, + create: async ({ data }: { data: ApiKeyRow }) => { + if (store.apiKeys.some((k) => k.key === data.key)) { + throw new Error("unique constraint: api_key.key"); + } + store.apiKeys.push(data); + return data; + }, }, }, - }, -})); + }; +}); vi.mock("../lib/logger", () => ({ logger: { warn: () => {}, info: () => {}, error: () => {} }, @@ -136,6 +258,7 @@ vi.mock("../lib/logger", () => ({ import { joinSharedOrganization, + hasResolvableProjectExcluding, SHARED_ORG_SLUG, } from "./organization-service"; @@ -143,12 +266,28 @@ beforeEach(() => { store.orgs = []; store.members = []; store.projects = []; + store.bindings = []; + store.groupMembers = []; store.apiKeys = []; store.seq = 0; delete process.env.ONECLI_ORG_API_KEY; delete process.env.ONECLI_ORG_API_KEY_FILE; }); +const ORG = "org-host"; +const HOST = "user-host"; +const GUEST = "user-guest"; + +const seedOrgWithMember = (userId: string, role = "member") => { + store.orgs.push({ id: ORG, slug: "host", name: "Host" }); + store.members.push({ + organizationId: ORG, + userId, + userEmail: `${userId}@example.com`, + role, + }); +}; + describe("joinSharedOrganization", () => { it("creates the one shared org and a project for the first user", async () => { const { organization, project } = await joinSharedOrganization( @@ -257,3 +396,119 @@ describe("bootstrap org API key (via joinSharedOrganization)", () => { ).rejects.toThrow(/ONECLI_ORG_API_KEY/); }); }); + +// `hasResolvableProjectExcluding` is `deleteProject`'s lockout oracle, and it +// must answer exactly what `findUserDefaultProject` would find once the named +// project is gone. Every arm below has a twin above; drift between the two is a +// lockout (the user resolves no project and gets a 401 on every request). + +describe("hasResolvableProjectExcluding", () => { + const seedProject = ( + id: string, + createdByUserId: string | null, + organizationId = ORG, + ) => { + store.projects.push({ + id, + name: "Default", + slug: id, + organizationId, + createdByUserId, + createdByUserEmail: null, + seq: store.seq++, + }); + }; + + it("is false when the excluded project is the user's only one", async () => { + seedOrgWithMember(GUEST); + seedProject("proj-only", GUEST); + store.bindings.push({ + projectId: "proj-only", + userId: GUEST, + role: "owner", + }); + + await expect( + hasResolvableProjectExcluding(GUEST, "proj-only"), + ).resolves.toBe(false); + }); + + it("is true through a project they CREATED (arm 1)", async () => { + seedOrgWithMember(GUEST); + seedProject("proj-a", GUEST); + seedProject("proj-b", GUEST); + + await expect(hasResolvableProjectExcluding(GUEST, "proj-a")).resolves.toBe( + true, + ); + }); + + it("is true through a DIRECT binding on another project (arm 2)", async () => { + seedOrgWithMember(HOST, "owner"); + store.members.push({ + organizationId: ORG, + userId: GUEST, + userEmail: "guest@example.com", + role: "member", + }); + seedProject("proj-a", HOST); + seedProject("proj-b", HOST); + store.bindings.push({ projectId: "proj-a", userId: GUEST, role: "member" }); + store.bindings.push({ projectId: "proj-b", userId: GUEST, role: "member" }); + + await expect(hasResolvableProjectExcluding(GUEST, "proj-a")).resolves.toBe( + true, + ); + }); + + it("is true through a GROUP binding on another project (arm 2)", async () => { + seedOrgWithMember(HOST, "owner"); + store.members.push({ + organizationId: ORG, + userId: GUEST, + userEmail: "guest@example.com", + role: "member", + }); + seedProject("proj-a", HOST); + seedProject("proj-b", HOST); + store.bindings.push({ projectId: "proj-a", userId: GUEST, role: "member" }); + store.bindings.push({ + projectId: "proj-b", + groupId: "g-1", + role: "member", + }); + store.groupMembers.push({ groupId: "g-1", userId: GUEST }); + + await expect(hasResolvableProjectExcluding(GUEST, "proj-a")).resolves.toBe( + true, + ); + // ...and the group path is the ONLY one left, so removing it flips it. + store.groupMembers = []; + await expect(hasResolvableProjectExcluding(GUEST, "proj-a")).resolves.toBe( + false, + ); + }); + + it("ignores a project in an org the user is only SUSPENDED in", async () => { + seedOrgWithMember(GUEST); + const suspended = store.members.find((m) => m.userId === GUEST); + if (suspended) suspended.status = "suspended"; + seedProject("proj-a", GUEST); + seedProject("proj-b", GUEST); + + await expect(hasResolvableProjectExcluding(GUEST, "proj-a")).resolves.toBe( + false, + ); + }); + + it("ignores projects of an org the user does not belong to", async () => { + seedOrgWithMember(GUEST); + seedProject("proj-a", GUEST); + store.orgs.push({ id: "org-other", slug: "other", name: "Other" }); + seedProject("proj-foreign", GUEST, "org-other"); + + await expect(hasResolvableProjectExcluding(GUEST, "proj-a")).resolves.toBe( + false, + ); + }); +}); diff --git a/packages/api/src/services/organization-service.ts b/packages/api/src/services/organization-service.ts index ce43d0df..5df079c8 100644 --- a/packages/api/src/services/organization-service.ts +++ b/packages/api/src/services/organization-service.ts @@ -86,6 +86,50 @@ export const findUserDefaultProject = async ( }); }; +/** + * Whether `userId` would still resolve SOME project if `excludeProjectId` + * disappeared — the delete guard's lockout oracle + * (`deleteProject`, project-service). + * + * THESE TWO MUST AGREE: this is exactly `findUserDefaultProject`'s disjunction + * (created-by-them, OR bound directly / through a group), fenced to orgs the + * user is an ACTIVE member of, minus the project about to be deleted. It lives + * here rather than in project-service precisely so the two predicates stay in + * sync — drift between them is a lockout: a user whose last project is deleted + * resolves no project at all, and session auth then 401s them everywhere. + * + * Both arms are folded into one `OR` (unlike the ordered two-query fallback + * above) because only existence matters here, never which project wins. + */ +export const hasResolvableProjectExcluding = async ( + userId: string, + excludeProjectId: string, +): Promise => { + const inActiveMemberOrg = { + organization: { members: { some: { userId, ...activeMembershipWhere } } }, + }; + + const row = await db.project.findFirst({ + where: { + ...inActiveMemberOrg, + id: { not: excludeProjectId }, + OR: [ + { createdByUserId: userId }, + { + accessBindings: { + some: { + OR: [{ userId }, { group: { members: { some: { userId } } } }], + }, + }, + }, + ], + }, + select: { id: true }, + }); + + return row !== null; +}; + /** * The nested-write seeds every user-facing project is born with: one API * key + the default agent. The single definition all provision sites diff --git a/packages/api/src/services/project-access-service.ts b/packages/api/src/services/project-access-service.ts new file mode 100644 index 00000000..d96d2a74 --- /dev/null +++ b/packages/api/src/services/project-access-service.ts @@ -0,0 +1,385 @@ +import { db } from "@onecli/db"; +import { ServiceError } from "./errors"; +import { requireProject } from "./project-service"; +import { hasResolvableProjectExcluding } from "./organization-service"; +import { + MAX_PROJECT_ACCESS_GROUPS, + MAX_PROJECT_ACCESS_USERS, + type SetProjectAccessInput, +} from "../validations/project"; + +// The project's human sharing surface: read the bindings, replace the set. +// +// `ProjectAccess` rows are LIVE authorization data read by three independent +// enforcement points — `middleware/auth/resolve.ts` (`hasProjectBinding`), the +// API-key auth path, and the Rust gateway's `load_principal_set`. None of them +// reads `role`: it is a MANAGEMENT discriminator only (13c), consulted solely +// by `canManageProject`. Every row is a use grant regardless of its role. +// +// Same three rules as `project-service.ts`: org-scoped `findFirst` (never +// `findUnique({ id })`), the org id always from `auth.organizationId`, and +// conditional writes. + +/** One user binding, in the client's `ProjectAccessUserRow` shape. */ +export interface ProjectAccessUserBinding { + id: string; + userId: string; + name: string | null; + email: string; + role: "owner" | "member"; + isOwner: boolean; + createdAt: string; +} + +/** One group binding, in the client's `ProjectAccessGroupRow` shape. */ +export interface ProjectAccessGroupBinding { + id: string; + groupId: string; + name: string; + memberCount: number; + createdAt: string; +} + +export interface ProjectAccessBindings { + users: ProjectAccessUserBinding[]; + groups: ProjectAccessGroupBinding[]; +} + +/** + * The delta a replace-set applied. Counts are AGGREGATED across users AND + * groups — the dialog shows a single toast, so a split would be noise. + */ +export interface SetProjectAccessResult { + added: number; + removed: number; + roleChanged: number; +} + +/** `role` is a free-form DB column: normalize, NEVER cast (the ossRoleResolver + * precedent). Anything that is not exactly "owner" is a plain use grant. */ +const normalizeRole = (raw: string): "owner" | "member" => + raw === "owner" ? "owner" : "member"; + +/** + * Read the project's bindings. + * + * User rows are filtered down to users who hold an `OrganizationMember` row in + * this org (ANY status). A row for a non-member is inert anyway + * (`canAccessProjectAsUser` demands an active membership) — and returning it + * would make the UI's "open dialog, save without edits" round-trip 400 on + * `setProjectAccess`'s org-membership assertion. SUSPENDED members ARE + * returned: suspension is an auth-time gate, not a binding change. + * + * If this filter changes, `setProjectAccess`'s validation must change with it. + */ +export const listProjectAccess = async ( + organizationId: string, + projectId: string, +): Promise => { + const project = await requireProject(organizationId, projectId); + + const [userRows, groupRows] = await Promise.all([ + db.projectAccess.findMany({ + where: { + projectId, + userId: { not: null }, + user: { organizationMemberships: { some: { organizationId } } }, + }, + select: { + id: true, + userId: true, + role: true, + createdAt: true, + user: { select: { email: true, name: true } }, + }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + // Bounded even against a hand-seeded database. + take: MAX_PROJECT_ACCESS_USERS, + }), + db.projectAccess.findMany({ + where: { + projectId, + groupId: { not: null }, + // Org-fences the join exactly like the gateway's + // `JOIN groups g ON … g.organization_id = $org`. + group: { organizationId }, + }, + select: { + id: true, + groupId: true, + createdAt: true, + group: { + select: { name: true, _count: { select: { members: true } } }, + }, + }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + take: MAX_PROJECT_ACCESS_GROUPS, + }), + ]); + + return { + users: userRows.flatMap((row) => + row.userId + ? [ + { + id: row.id, + userId: row.userId, + name: row.user?.name ?? null, + email: row.user?.email ?? "", + role: normalizeRole(row.role), + // Provenance ONLY, deliberately independent of the (transferable) + // management role: the creator keeps the badge after a demotion. + isOwner: row.userId === project.createdByUserId, + createdAt: row.createdAt.toISOString(), + }, + ] + : [], + ), + groups: groupRows.flatMap((row) => + row.groupId + ? [ + { + id: row.id, + groupId: row.groupId, + name: row.group?.name ?? "", + memberCount: row.group?._count.members ?? 0, + createdAt: row.createdAt.toISOString(), + }, + ] + : [], + ), + }; +}; + +/** + * THE security invariant of every binding write: `ProjectAccess.userId` FKs the + * GLOBAL `User` table, so the organization scope exists ONLY in this check. + * Every id must resolve to a member of the caller's org — one foreign id and + * the whole write is rejected, or a project could capture users from another + * organization. (A clone of `assertOrgMembers` in `org-group-service.ts`: the + * org services keep their own copies by convention, which keeps this comment + * next to the code it protects.) + * + * Suspended members are deliberately allowed: suspension is an AUTH-time gate, + * and stripping bindings on suspend would silently rewrite the member's access + * shape on reinstate. + */ +const assertOrgMembers = async (organizationId: string, userIds: string[]) => { + if (userIds.length === 0) return; + const rows = await db.organizationMember.findMany({ + where: { organizationId, userId: { in: userIds } }, + select: { userId: true }, + }); + const known = new Set(rows.map((row) => row.userId)); + if (userIds.some((id) => !known.has(id))) { + throw new ServiceError( + "BAD_REQUEST", + "One or more users are not members of this organization.", + ); + } +}; + +/** The same fence for group grantees. Unlike group MEMBERSHIP (IdP-owned, so + * `requireManualGroup` applies), a project grant TO a group is OneCLI-owned + * config: `source: "scim"` groups are valid grantees. */ +const assertOrgGroups = async (organizationId: string, groupIds: string[]) => { + if (groupIds.length === 0) return; + const rows = await db.group.findMany({ + where: { organizationId, id: { in: groupIds } }, + select: { id: true }, + }); + const known = new Set(rows.map((row) => row.id)); + if (groupIds.some((id) => !known.has(id))) { + throw new ServiceError( + "BAD_REQUEST", + "One or more groups do not belong to this organization.", + ); + } +}; + +/** + * Replace the project's binding set (users + groups) in one write. + * + * `actorIsOrgAdmin` is computed by the ROUTE from the role resolver (it already + * resolved the role for `canManageProject`) and passed in: this service must + * never re-resolve it, and must never accept it from the request body. + */ +export const setProjectAccess = async ( + organizationId: string, + actorUserId: string, + actorIsOrgAdmin: boolean, + projectId: string, + input: SetProjectAccessInput, +): Promise => { + // Resolve FIRST, before any payload validation: a cross-org project id must + // 404 without ever becoming an existence oracle for user/group ids. + const project = await requireProject(organizationId, projectId); + + const users = input.users; + // Groups carry no role, so a repeat is unambiguous — dedupe silently + // (matching `setOrgGroupMembers`). Duplicate USERS are a 422 in the schema. + const groupIds = [...new Set(input.groupIds)]; + + await assertOrgMembers( + organizationId, + users.map((u) => u.userId), + ); + await assertOrgGroups(organizationId, groupIds); + + // ── Guard G: the set must keep an owner ────────────────────────────────── + // Covers three failure modes at once: demoting every owner, clearing all + // users, and the legacy zero-binding project (the admin is forced to name an + // owner rather than saving an empty set over an already-orphaned project). + if (!users.some((u) => u.role === "owner")) { + throw new ServiceError( + "BAD_REQUEST", + "A project must keep at least one owner.", + ); + } + + const currentRows = await db.projectAccess.findMany({ + where: { projectId }, + select: { id: true, userId: true, groupId: true, role: true }, + }); + + const currentUsers = new Map(); + const currentGroups = new Set(); + for (const row of currentRows) { + if (row.userId) currentUsers.set(row.userId, normalizeRole(row.role)); + else if (row.groupId) currentGroups.add(row.groupId); + } + + // ── Guard H: the actor may not strand themselves ───────────────────────── + // A NON-ADMIN may neither drop nor demote themselves: the binding IS their + // authority here. (Mirrors `updateOrgMemberStatus`'s "You cannot suspend + // yourself.") + // + // An org admin keeps the DEMOTION exemption — their authority comes from the + // org role, so an ownership hand-off must stay possible — but NOT a free + // self-REMOVAL. `findUserDefaultProject` has exactly two arms (created the + // project, or holds a binding), so an admin whose only path to any project + // was this binding resolves NO project once it is gone: `authenticateSession` + // then falls back to an `X-Organization-Id` header OSS web never sends and + // 401s every request — including the PUT they would need to re-grant. That is + // the same lockout `deleteProject` spends three guards preventing, so it is + // checked with the same oracle. Excluding THIS project is correct: the + // binding on it is exactly what is going away, while the created-by arm + // survives the write and is checked separately. + // + // Deliberately conservative: a group binding on THIS project that would keep + // the admin resolving is not counted, so the worst case is a refusal to + // perform a safe removal — never a lockout. + if (currentUsers.has(actorUserId)) { + const mine = users.find((u) => u.userId === actorUserId); + if (!mine) { + if (!actorIsOrgAdmin) { + throw new ServiceError( + "BAD_REQUEST", + "You cannot remove your own access to this project.", + ); + } + const staysResolvable = + project.createdByUserId === actorUserId || + (await hasResolvableProjectExcluding(actorUserId, projectId)); + if (!staysResolvable) { + throw new ServiceError( + "BAD_REQUEST", + "Removing your own access would leave you with no project.", + ); + } + } else if ( + !actorIsOrgAdmin && + mine.role !== "owner" && + currentUsers.get(actorUserId) === "owner" + ) { + throw new ServiceError( + "BAD_REQUEST", + "You cannot remove your own management access to this project.", + ); + } + } + + const targetUsers = new Map(users.map((u) => [u.userId, u.role])); + const targetGroups = new Set(groupIds); + + const userAdds = users.filter((u) => !currentUsers.has(u.userId)); + const userRemoves = [...currentUsers.keys()].filter( + (id) => !targetUsers.has(id), + ); + const roleChanges = [...targetUsers.entries()].filter( + ([id, role]) => currentUsers.has(id) && currentUsers.get(id) !== role, + ); + const groupAdds = groupIds.filter((id) => !currentGroups.has(id)); + const groupRemoves = [...currentGroups].filter((id) => !targetGroups.has(id)); + + // Early no-op: nothing to write, so no transaction is opened + // (`setOrgGroupMembers` precedent). The route's audit + gateway flush still + // run — cheap, and simpler than making withAudit conditional. + if ( + userAdds.length === 0 && + userRemoves.length === 0 && + roleChanges.length === 0 && + groupAdds.length === 0 && + groupRemoves.length === 0 + ) { + return { added: 0, removed: 0, roleChanged: 0 }; + } + + const toOwner = roleChanges + .filter(([, role]) => role === "owner") + .map(([id]) => id); + const toMember = roleChanges + .filter(([, role]) => role === "member") + .map(([id]) => id); + + await db.$transaction([ + // Deletes BEFORE creates: `@@unique([projectId, userId])` and + // `@@unique([projectId, groupId])` are partial (Postgres treats NULLs as + // distinct), so a create racing a delete on the same key would P2002. + db.projectAccess.deleteMany({ + where: { projectId, userId: { in: userRemoves } }, + }), + db.projectAccess.deleteMany({ + where: { projectId, groupId: { in: groupRemoves } }, + }), + db.projectAccess.updateMany({ + where: { projectId, userId: { in: toOwner } }, + data: { role: "owner" }, + }), + db.projectAccess.updateMany({ + where: { projectId, userId: { in: toMember } }, + data: { role: "member" }, + }), + // The DB CHECK is `num_nonnulls(user_id, group_id) = 1`. Each row below is + // built literally, with EXACTLY ONE principal column — never from a shared + // spread object that could carry both. `skipDuplicates` makes a concurrent + // double-add idempotent instead of a P2002. + db.projectAccess.createMany({ + data: userAdds.map((u) => ({ + projectId, + userId: u.userId, + role: u.role, + createdByUserId: actorUserId, + })), + skipDuplicates: true, + }), + db.projectAccess.createMany({ + // Group bindings are ALWAYS "member": the client payload has no group + // role and the gateway ignores `role` entirely — this must never gain a + // silent management path. + data: groupAdds.map((groupId) => ({ + projectId, + groupId, + role: "member", + createdByUserId: actorUserId, + })), + skipDuplicates: true, + }), + ]); + + return { + added: userAdds.length + groupAdds.length, + removed: userRemoves.length + groupRemoves.length, + roleChanged: roleChanges.length, + }; +}; diff --git a/packages/api/src/services/project-service.test.ts b/packages/api/src/services/project-service.test.ts new file mode 100644 index 00000000..d6406731 --- /dev/null +++ b/packages/api/src/services/project-service.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// `canManageProject` in isolation, for the one case the route suite cannot +// reach: an edition with NO RoleResolver registered. `initRoleResolver` is a +// module singleton set by `createApiApp`, so it can only be observed unset in +// a file that mocks the providers module outright. +// +// The invariant: MANAGEMENT must fail closed. Unlike `canAccessProjectAsUser` +// (a USAGE check, which no-ops to `true` for editions without roles), a +// management check that allowed everyone when roles are unavailable would let +// any member rename or delete any project. + +const store = vi.hoisted(() => ({ + /** An `owner` binding that exists — and must still not be consulted. */ + ownerBinding: true, + resolverRole: null as string | null, +})); + +vi.mock("@onecli/db", () => ({ + db: { + projectAccess: { + findFirst: async () => (store.ownerBinding ? { id: "pa-1" } : null), + }, + }, +})); + +vi.mock("../providers", () => ({ + ROLE_HIERARCHY: { member: 1, admin: 2, owner: 3 }, + // No resolver registered — the edition never called `initRoleResolver`. + getRoleResolver: () => + store.resolverRole === null + ? null + : { getUserRole: async () => store.resolverRole }, + getNewOrgPolicySeeder: () => ({ seed: async () => {} }), +})); + +import { canManageProject } from "./project-service"; + +beforeEach(() => { + store.ownerBinding = true; + store.resolverRole = null; +}); + +describe("canManageProject without a RoleResolver", () => { + it("denies, even when an owner binding exists", async () => { + await expect(canManageProject("u-1", "org-1", "proj-1")).resolves.toBe( + false, + ); + }); +}); + +describe("canManageProject with a resolver", () => { + it("denies a user the resolver reports no role for (suspended / non-member)", async () => { + store.resolverRole = null; + await expect(canManageProject("u-1", "org-1", "proj-1")).resolves.toBe( + false, + ); + }); + + it("allows an org admin with no binding at all", async () => { + store.resolverRole = "admin"; + store.ownerBinding = false; + await expect(canManageProject("u-1", "org-1", "proj-1")).resolves.toBe( + true, + ); + }); + + it("allows a plain member holding an owner binding, and denies them without one", async () => { + store.resolverRole = "member"; + await expect(canManageProject("u-1", "org-1", "proj-1")).resolves.toBe( + true, + ); + store.ownerBinding = false; + await expect(canManageProject("u-1", "org-1", "proj-1")).resolves.toBe( + false, + ); + }); +}); diff --git a/packages/api/src/services/project-service.ts b/packages/api/src/services/project-service.ts new file mode 100644 index 00000000..71f90861 --- /dev/null +++ b/packages/api/src/services/project-service.ts @@ -0,0 +1,387 @@ +import { db } from "@onecli/db"; +import { ServiceError } from "./errors"; +import { getRoleResolver, ROLE_HIERARCHY } from "../providers"; +import { + activeMembershipWhere, + hasResolvableProjectExcluding, +} from "./organization-service"; +import { invalidateGatewayCacheForKeys } from "../lib/gateway-invalidate"; + +// Project administration: read, rename, delete. Three rules, same as +// `org-group-service.ts`: +// 1. every resolve is `findFirst({ id, organizationId })`, NEVER +// `findUnique({ where: { id } })` — a cross-org id must read as absent +// (404), not leak another org's row; +// 2. the organization id ALWAYS comes from `auth.organizationId`, never from +// a body or query parameter; +// 3. writes are conditional `updateMany`/`deleteMany` so a lost race is a +// 404, not the P2025 500 a bare `update()`/`delete()` would surface. + +/** A project row in the client's `Project` shape (`createdAt` as ISO string). */ +export interface ProjectRow { + id: string; + name: string | null; + slug: string | null; + createdAt: string; +} + +/** What a delete actually removed. */ +export interface ProjectDeleteResult { + id: string; + name: string | null; + removed: { + agents: number; + apiKeys: number; + secrets: number; + policyRules: number; + policyRulesV2: number; + appConnections: number; + appConfigs: number; + vaultConnections: number; + budgets: number; + accessBindings: number; + onboardingSurvey: number; + }; +} + +const projectSelect = { + id: true, + name: true, + slug: true, + createdAt: true, + createdByUserId: true, +} as const; + +const toProjectRow = (row: { + id: string; + name: string | null; + slug: string | null; + createdAt: Date; +}): ProjectRow => ({ + id: row.id, + name: row.name, + slug: row.slug, + createdAt: row.createdAt.toISOString(), +}); + +/** + * Resolve a project WITHIN the caller's org. A cross-org (or unknown) id reads + * as absent — 404, never 403: a forbidden response would turn the route into an + * existence oracle for another organization's project ids. + */ +export const requireProject = async ( + organizationId: string, + projectId: string, +) => { + const project = await db.project.findFirst({ + where: { id: projectId, organizationId }, + select: projectSelect, + }); + if (!project) throw new ServiceError("NOT_FOUND", "Project not found."); + return project; +}; + +export interface ProjectAuthority { + /** Org admin/owner — Guard H's exemption in `setProjectAccess`. */ + isOrgAdmin: boolean; + canManage: boolean; +} + +/** + * MANAGEMENT authority over a project (step 13c): an org admin/owner, or the + * holder of a USER binding with `role: "owner"`. GROUP bindings never confer + * management in v1 — the gateway and the usage gate both ignore `role`, so a + * group grant is a USE grant only. + * + * Resolves the org role ONCE and derives both signals from it, so a route never + * pays for (or risks disagreeing across) two resolver calls. + * + * Two invariants, both deliberate: + * + * - The role is resolved FIRST and a null role denies (the suspension + * invariant, copied from `canAccessProjectAsUser`): the binding check lives + * INSIDE the active-member gate, so a suspended user's stale owner binding + * can never rescue them. + * - Unlike `canAccessProjectAsUser`, this is NOT gated on `CAPS.rbac`. A usage + * check must no-op (allow) for editions without roles; a MANAGEMENT check + * that allowed everyone there would let any member delete any project. With + * no resolver registered the role reads null and we deny — fail closed. + */ +const resolveAuthority = async ( + userId: string, + organizationId: string, + projectId: string, +): Promise => { + const resolver = getRoleResolver(); + const role = resolver + ? await resolver.getUserRole(userId, organizationId) + : null; + if (!role) return { isOrgAdmin: false, canManage: false }; + if (ROLE_HIERARCHY[role] >= ROLE_HIERARCHY.admin) { + return { isOrgAdmin: true, canManage: true }; + } + + const owner = await db.projectAccess.findFirst({ + where: { projectId, userId, role: "owner" }, + select: { id: true }, + }); + return { isOrgAdmin: false, canManage: owner !== null }; +}; + +export const canManageProject = async ( + userId: string, + organizationId: string, + projectId: string, +): Promise => + (await resolveAuthority(userId, organizationId, projectId)).canManage; + +/** Route helper: resolve (404) THEN authorize (403), never the other way — + * a cross-org project id must never distinguish "exists but forbidden". */ +export const requireManageableProject = async ( + organizationId: string, + userId: string, + projectId: string, +) => { + const project = await requireProject(organizationId, projectId); + const authority = await resolveAuthority(userId, organizationId, projectId); + if (!authority.canManage) { + throw new ServiceError( + "FORBIDDEN", + "You do not have permission to manage this project.", + ); + } + return { project, isOrgAdmin: authority.isOrgAdmin }; +}; + +export const getProject = async ( + organizationId: string, + projectId: string, +): Promise => + toProjectRow(await requireProject(organizationId, projectId)); + +/** + * Rename. `name` ONLY — `slug` is immutable (it is write-only provenance, + * never read by api/web/gateway, and it is `@@unique([organizationId, slug])`, + * so rewriting it could collide). Names are NOT unique per org (see + * `projectNameSchema`), so a rename-to-self and a rename onto a sibling's name + * are both permitted 200s. + */ +export const renameProject = async ( + organizationId: string, + projectId: string, + name: string, +): Promise => { + await requireProject(organizationId, projectId); + + // Org-scoped conditional write: count 0 means the row vanished (or never + // belonged to this org) between the read and the write — 404, not a 500. + const { count } = await db.project.updateMany({ + where: { id: projectId, organizationId }, + data: { name }, + }); + if (count === 0) throw new ServiceError("NOT_FOUND", "Project not found."); + + const row = await db.project.findFirst({ + where: { id: projectId, organizationId }, + select: projectSelect, + }); + if (!row) throw new ServiceError("NOT_FOUND", "Project not found."); + return toProjectRow(row); +}; + +/** + * Delete a project, with an explicit pinned cascade. + * + * A bare `db.project.delete()` is NOT viable: `agents`, `vault_connections` and + * `onboarding_surveys` are `ON DELETE RESTRICT` (and every project is born with + * a default agent, so the P2003 would be universal), while `api_keys`, + * `secrets`, `policy_rules`, `app_connections`, `app_configs` and `budgets` are + * `ON DELETE SET NULL` — they would SURVIVE the project as orphaned + * `scope: "project"` rows with `project_id = NULL`. Both hazards are handled by + * deleting the children explicitly, in FK order, inside ONE transaction. + * + * Three refusals guard the lockout cases (a user with no resolvable project + * gets a 401 on every request — a bricked dashboard, not a degraded one). + * Refusing outright ("empty the project first") is not an option: the default + * agent + API key mean a project can never be emptied through the product. + */ +export const deleteProject = async ( + organizationId: string, + actorUserId: string, + projectId: string, +): Promise => { + const project = await requireProject(organizationId, projectId); + + // ── Guard 1: the org's last project ────────────────────────────────────── + // Deleting it makes EVERY session in the org unresolvable — a total instance + // lockout in OSS, where there is no project switcher to recover through. + const projectCount = await db.project.count({ where: { organizationId } }); + if (projectCount <= 1) { + throw new ServiceError( + "CONFLICT", + "An organization must keep at least one project.", + ); + } + + // ── Guards 2 & 3: stranded users ───────────────────────────────────────── + // Candidates are every human who could be relying on this project: direct + // user bindings ∪ members of groups bound to it ∪ the creator. Restricted to + // ACTIVE members of the org — a suspended or foreign user cannot be stranded + // by definition (they resolve no project either way). + const [userBindings, groupBindings] = await Promise.all([ + db.projectAccess.findMany({ + where: { projectId, userId: { not: null } }, + select: { userId: true }, + }), + db.projectAccess.findMany({ + where: { projectId, groupId: { not: null } }, + select: { group: { select: { members: { select: { userId: true } } } } }, + }), + ]); + + const candidates = new Set(); + for (const row of userBindings) if (row.userId) candidates.add(row.userId); + for (const row of groupBindings) { + for (const m of row.group?.members ?? []) candidates.add(m.userId); + } + if (project.createdByUserId) candidates.add(project.createdByUserId); + + const activeMembers = await db.organizationMember.findMany({ + where: { + organizationId, + userId: { in: [...candidates] }, + // The shared "active member" filter, so a future change to what counts + // as active lands here too instead of silently narrowing this guard. + ...activeMembershipWhere, + }, + select: { userId: true }, + }); + const atRisk = new Set(activeMembers.map((row) => row.userId)); + + // Guard 3 (self) is checked FIRST so the actor's own case yields the sharper + // message rather than being folded into the anonymous count below. + if ( + atRisk.has(actorUserId) && + !(await hasResolvableProjectExcluding(actorUserId, projectId)) + ) { + throw new ServiceError( + "CONFLICT", + "Deleting this project would leave you with no project.", + ); + } + + // Guard 2: a serial loop, deliberately. The candidate set is bounded by the + // access-PUT caps and this is a rare destructive action — per-user + // correctness matters more than collapsing it into one clever query. + let stranded = 0; + for (const userId of atRisk) { + if (userId === actorUserId) continue; // handled above + if (!(await hasResolvableProjectExcluding(userId, projectId))) stranded++; + } + if (stranded > 0) { + throw new ServiceError( + "CONFLICT", + `Deleting this project would leave ${stranded} member(s) with no project. Give them access to another project first.`, + ); + } + + // Flush the gateway BEFORE the cascade, never after: `/v1/cache/invalidate` + // authenticates the bearer through an UNCACHED `find_api_key` lookup + // (apps/gateway/src/auth.rs), so a key deleted a moment ago cannot + // authenticate its own flush — a post-delete call would silently 401 and + // flush nothing. Flushing here is safe in both directions: if the + // transaction below rolls back the gateway simply re-reads the config it + // just dropped. + const keyRows = await db.apiKey.findMany({ + where: { projectId }, + select: { key: true }, + }); + invalidateGatewayCacheForKeys(keyRows.map((row) => row.key)); + + const [ + agents, + apiKeys, + secrets, + policyRules, + policyRulesV2, + appConnections, + appConfigs, + vaultConnections, + budgets, + accessBindings, + onboardingSurvey, + ] = await Promise.all([ + db.agent.count({ where: { projectId } }), + db.apiKey.count({ where: { projectId } }), + db.secret.count({ where: { projectId } }), + db.policyRule.count({ where: { projectId } }), + db.policyRuleV2.count({ where: { projectId } }), + db.appConnection.count({ where: { projectId } }), + db.appConfig.count({ where: { projectId } }), + db.vaultConnection.count({ where: { projectId } }), + db.budget.count({ where: { projectId } }), + db.projectAccess.count({ where: { projectId } }), + db.onboardingSurvey.count({ where: { projectId } }), + ]); + + // One transaction, children first, in FK order. Each line carries its FK + // action so a future schema change is caught in review: a new RESTRICT child + // without a line here is a P2003, a new SET NULL child is a silent orphan. + // + // Interactive (callback) form, not the array form, precisely so the final + // `count === 0` check below can ROLL THE CASCADE BACK by throwing. + await db.$transaction(async (tx) => { + // RESTRICT — must precede the project. Cascades agent_secrets, + // agent_app_connections, grant_rules, policy_rule_identities(agent). + await tx.agent.deleteMany({ where: { projectId } }); + // SET NULL — explicit, else orphaned scope:"project" rows survive. + // Cascades secret_access, budgets, policy_rule_targets(secret). + await tx.secret.deleteMany({ where: { projectId } }); + // SET NULL — cascades connection_access, policy_rule_targets(connection). + await tx.appConnection.deleteMany({ where: { projectId } }); + // SET NULL + await tx.appConfig.deleteMany({ where: { projectId } }); + // SET NULL — an orphaned PROJECT api key must never outlive its project. + await tx.apiKey.deleteMany({ where: { projectId } }); + // SET NULL (legacy rule model) + await tx.policyRule.deleteMany({ where: { projectId } }); + // SET NULL (cloud-only budgets; inert in OSS) + await tx.budget.deleteMany({ where: { projectId } }); + // RESTRICT + await tx.vaultConnection.deleteMany({ where: { projectId } }); + // RESTRICT + await tx.onboardingSurvey.deleteMany({ where: { projectId } }); + + // Org-scoped conditional delete. Deliberately NOT deleted by hand: + // · policy_rules_v2 + project_access — DB CASCADE, removed with the row; + // · audit_logs — SET NULL by design: history SURVIVES and stays + // attributable through organization_id. Never delete audit rows. + // · request_logs — no FK at all: telemetry keeps a dangling project_id and + // becomes unreachable. Deleting it could be millions of rows in one + // transaction; out of scope here. + const { count } = await tx.project.deleteMany({ + where: { id: projectId, organizationId }, + }); + // A 0 here means the project vanished (or was never ours) between the + // resolve and the write — throwing rolls the whole cascade back. + if (count === 0) throw new ServiceError("NOT_FOUND", "Project not found."); + }); + + return { + id: project.id, + name: project.name, + removed: { + agents, + apiKeys, + secrets, + policyRules, + policyRulesV2, + appConnections, + appConfigs, + vaultConnections, + budgets, + accessBindings, + onboardingSurvey, + }, + }; +}; diff --git a/packages/api/src/validations/org.ts b/packages/api/src/validations/org.ts index fe4fbaed..260c6359 100644 --- a/packages/api/src/validations/org.ts +++ b/packages/api/src/validations/org.ts @@ -98,6 +98,84 @@ export const setGroupMembersSchema = z.object({ userIds: z.array(z.string().min(1)).max(MAX_GROUP_MEMBERS), }); +// ── Role mappings ──────────────────────────────────────────────────────── + +/** + * Ceiling on the org's mapping set — enforced on BOTH ends, and it has to be: + * `PUT /order` takes the FULL ordered id set, so an org that can hold more + * mappings than this body allows would have an unreachable reorder endpoint + * (every honest body 422s on `.max()`, every shorter one 409s on the + * names-every-mapping-once check) and no way to resolve shadowing. The chosen + * invariant is therefore `mappings ≤ MAX_ROLE_MAPPINGS`: `createOrgRoleMapping` + * 409s at the cap, so the reorder body can always name the whole set. + */ +export const MAX_ROLE_MAPPINGS = 500; + +/** + * Mappings assign exactly the roles the members surface can — never `owner`. + * Owner is bootstrap-only, and `updateOrgMemberRole` already refuses to assign + * or overwrite it, so a body carrying `role: "owner"` is a 422 here rather + * than a privilege the mapping engine could mint. + */ +export const roleMappingRoleSchema = orgMemberRoleSchema; + +/** + * `priority` is an ASCENDING rank: 0 = highest precedence (evaluated first / + * wins). Omitted on create means "append after the current last mapping". + * + * NOT coerced, unlike `limit`: this field only ever arrives in a JSON body, so + * it is already a number when it is one. Coercion would turn `null`, `""`, + * `false` and `[]` into `0` — the HIGHEST-precedence slot, the single most + * consequential value the field can take — instead of the 422 a body that + * meant "no priority" deserves. `.optional()` still short-circuits `undefined`, + * so the omitted-priority append path is unaffected. + */ +export const roleMappingPrioritySchema = z.number().int().min(0).max(100_000); + +export const createRoleMappingSchema = z.object({ + groupId: z.string().min(1), + role: roleMappingRoleSchema, + priority: roleMappingPrioritySchema.optional(), +}); + +export type CreateRoleMappingInput = z.infer; + +export const updateRoleMappingSchema = z.object({ + role: roleMappingRoleSchema, + priority: roleMappingPrioritySchema.optional(), +}); + +export type UpdateRoleMappingInput = z.infer; + +/** + * The FULL ordered id set, index 0 = highest priority. A body that does not + * name every current mapping exactly once is STALE → 409 in the service (the + * `reorderPolicyRules` precedent); duplicates are malformed → 422 here. + * + * Deliberately NOT `.min(1)` (unlike `reorderPolicyRulesSchema`): an org with + * zero mappings legitimately reorders to `[]`, and a minimum would 422 that + * honest no-op. + */ +export const reorderRoleMappingsSchema = z.object({ + orderedIds: z + .array(z.string().min(1)) + .max(MAX_ROLE_MAPPINGS) + .refine((ids) => new Set(ids).size === ids.length, { + message: "orderedIds must not contain duplicates.", + }), +}); + +/** + * Dry run. No `priority`: an existing mapping is previewed at its current + * slot, a new one as if appended last — exactly what the create button does. + */ +export const previewRoleMappingSchema = z.object({ + groupId: z.string().min(1), + role: roleMappingRoleSchema, +}); + +export type PreviewRoleMappingInput = z.infer; + /** * `PATCH /v1/org/members/:userId` accepts EXACTLY ONE change per request — * either a lifecycle change (`status`) or a role change (`role`). A body diff --git a/packages/api/src/validations/project.ts b/packages/api/src/validations/project.ts new file mode 100644 index 00000000..d3eceb53 --- /dev/null +++ b/packages/api/src/validations/project.ts @@ -0,0 +1,68 @@ +import { z } from "zod"; + +// Validation for the `/v1/projects/*` surface (rename + the access replace-set). +// Mirrors `validations/org.ts` in shape; kept separate because projects are a +// project-scoped resource, not part of the org directory. + +/** + * Project display name. Bounded like a group name, but deliberately NOT unique + * per organization: `ensureMemberDefaultProject` names EVERY invited member's + * project "Default", so a uniqueness rule would 409 the most common state in + * the product. + */ +export const projectNameSchema = z.string().trim().min(1).max(100); + +export const renameProjectSchema = z.object({ name: projectNameSchema }); + +/** + * The management role on a USER binding (step 13c): "owner" may + * rename/share/delete the project, "member" is a plain use grant. GROUP + * bindings carry no role in v1 — they are always written as "member". + */ +export const projectAccessRoleSchema = z.enum(["owner", "member"]); + +/** + * Replace-set ceilings. Deliberately not `DIRECTORY_LIMIT_MAX` (a page size): + * the sharing dialog drains every page of the org directory and PUTs the full + * set back, so the write cap must comfortably exceed one page while still + * bounding the request body. + */ +export const MAX_PROJECT_ACCESS_USERS = 1000; +export const MAX_PROJECT_ACCESS_GROUPS = 200; + +/** + * `PUT /v1/projects/:projectId/access` body. Both keys are REQUIRED (no + * `.default([])`): a client bug that omits one must be a 422, never a silent + * half-wipe of the project's bindings. + */ +export const setProjectAccessSchema = z + .object({ + users: z + .array( + z.object({ + userId: z.string().min(1), + role: projectAccessRoleSchema, + }), + ) + .max(MAX_PROJECT_ACCESS_USERS), + groupIds: z.array(z.string().min(1)).max(MAX_PROJECT_ACCESS_GROUPS), + }) + .superRefine((body, ctx) => { + // A duplicate userId is REJECTED rather than resolved "last wins": each + // entry carries a role, so a repeat with a conflicting role is genuinely + // ambiguous. `groupIds` carry no role and are deduped silently in the + // service (the setOrgGroupMembers precedent). + const seen = new Set(); + for (const user of body.users) { + if (seen.has(user.userId)) { + ctx.addIssue({ + code: "custom", + message: "Duplicate user in the access set.", + }); + return; + } + seen.add(user.userId); + } + }); + +export type SetProjectAccessInput = z.infer; From 1b5a1fb3fdd35080c3f3e0663e407dae6e785dcf Mon Sep 17 00:00:00 2001 From: marcorivm Date: Wed, 29 Jul 2026 19:59:16 -0600 Subject: [PATCH 04/10] feat(gateway): org-scope enforcement with user/group principals on 1.44.0 Reconciliation Stage F. The OSS gateway now populates the org rule set and the user/group PrincipalSet that upstream shipped but never filled: a new loaders.rs adds the org published-rule loader and a principal CTE mirroring the API's resolvePrincipalSet (users direct and via granted groups, active members only; groups direct and inherited; fully org-fenced, agent-groups dropped). Two-level evaluation mirrors upstream's own evaluator including the hard-floor rule (a lone allow at one level cannot open the other level's default block), so an org guardrail can't be bypassed by a project allow. Fail-closed via upstream's anyhow refuse-CONNECT; our old org_degraded/Fallback/kill-switch scaffolding is deleted. Empty org fails OPEN. +21 tests, no agent-group, no signature changes to the call sites. --- apps/gateway/src/policy_engine.rs | 18 +- apps/gateway/src/policy_engine/assemble.rs | 150 ++++- apps/gateway/src/policy_engine/enforce.rs | 218 +++++++- apps/gateway/src/policy_engine/evaluate.rs | 619 +++++++++++++++++---- apps/gateway/src/policy_engine/loaders.rs | 200 +++++++ apps/gateway/src/policy_engine/types.rs | 51 +- 6 files changed, 1080 insertions(+), 176 deletions(-) create mode 100644 apps/gateway/src/policy_engine/loaders.rs diff --git a/apps/gateway/src/policy_engine.rs b/apps/gateway/src/policy_engine.rs index c23d92da..54eb2e9c 100644 --- a/apps/gateway/src/policy_engine.rs +++ b/apps/gateway/src/policy_engine.rs @@ -4,19 +4,23 @@ //! builds, so the shared call sites in `connect.rs`, `gateway/forward.rs`, and //! `gateway/websocket.rs` never change. //! -//! The OSS scope (the §2.9 locked matrix — exactly today's capabilities, -//! restructured): project rules only, agent/any identities, all four target -//! kinds, allow/block with the approval + rate-limit modifiers, the project -//! Default Rule terminal under the `enforce_deny` carve, and the explicit-agent -//! injection selection its equipment migration requires. Org scope, directory -//! identities, granular session policies, availability, and the shadow -//! comparator are OneCLI Cloud capabilities and have no code here. +//! The OSS scope: org + project rules composed two-level (each level reduced +//! first-match, combined under the hard-floor law mirroring +//! `policy-translation/evaluator.ts`), agent/user/group/any identities (the +//! directory kinds matched against the connection's resolved `PrincipalSet`), +//! all four target kinds, allow/block with the approval + rate-limit modifiers, +//! each level's Default Rule terminal under the `enforce_deny` carve, and the +//! explicit-agent injection selection its equipment migration requires. There +//! is no agent-group concept (deleted). Granular session-policy conditions, +//! app availability, and the shadow comparator remain OneCLI Cloud +//! capabilities and have no code here. mod assemble; mod catalog; mod enforce; mod evaluate; mod inject_select; +mod loaders; mod types; // The corpus parity test lives in the PRIVATE tree (`src/ee/policy_engine/`) diff --git a/apps/gateway/src/policy_engine/assemble.rs b/apps/gateway/src/policy_engine/assemble.rs index bc5d19aa..b8ee3e9d 100644 --- a/apps/gateway/src/policy_engine/assemble.rs +++ b/apps/gateway/src/policy_engine/assemble.rs @@ -1,29 +1,39 @@ -//! Decode the loaded published project rows into the evaluator's `Rule` list. -//! The rows are already new-model; this maps shapes and resolves -//! connection/secret targets through the fenced connect-time maps. +//! Decode the loaded published rows of ONE scope (org or project) into the +//! evaluator's `Rule` list. The rows are already new-model; this maps shapes +//! and resolves connection/secret targets through the fenced connect-time maps. use crate::db::{ ConnectionProviders, PolicyIdentityRow, PolicyRuleV2Row, PolicyTargetRow, SecretHosts, }; -use super::types::{Action, Identity, RateWindow, Rule, Target}; +use super::types::{Action, Identity, RateWindow, Rule, RuleScope, Target}; -/// Agent identities match by id; every other principal kind is a OneCLI Cloud -/// capability and decodes to `Other`, which never matches — a stored directory -/// identity narrows its rule to nothing rather than widening it (fail-closed). +/// Decode each identity row to its principal kind (the DB `one_principal` +/// CHECK guarantees at most one column is set). `agent_id`/`user_id`/`group_id` +/// decode to the matching directory kind; a row naming NO principal the OSS +/// engine understands decodes to `Other`, which never matches — it narrows its +/// rule to nothing rather than widening it (fail-closed). There is no +/// agent-group column, so no agent-group case exists. fn decode_identities(rows: &[PolicyIdentityRow]) -> Vec { rows.iter() - .map(|r| match &r.agent_id { - Some(id) => Identity::Agent(id.clone()), - None => Identity::Other, + .map(|r| { + if let Some(id) = &r.agent_id { + Identity::Agent(id.clone()) + } else if let Some(id) = &r.user_id { + Identity::User(id.clone()) + } else if let Some(id) = &r.group_id { + Identity::Group(id.clone()) + } else { + Identity::Other + } }) .collect() } /// Resolve a `secret` target to the host pattern(s) it gates: a specific /// `secret_id` via the fenced by-id map (absent/deleted → none → never -/// matches), or a `secret_scope` level union. The maps are project-fenced at -/// load, so a forged/foreign id resolves to nothing. +/// matches), or a `secret_scope` level union. The maps are org+project-fenced +/// at load, so a forged/foreign id resolves to nothing. fn secret_target_hosts(r: &PolicyTargetRow, secret_hosts: &SecretHosts) -> Vec { if let Some(id) = &r.secret_id { secret_hosts.by_id.get(id).cloned().unwrap_or_default() @@ -93,11 +103,13 @@ fn rate_window(name: Option<&str>) -> Option { fn decode_row( row: &PolicyRuleV2Row, + scope: RuleScope, secret_hosts: &SecretHosts, connection_providers: &ConnectionProviders, ) -> Rule { Rule { id: row.id.clone(), + scope, logical_id: row.logical_id.clone(), name: row.name.clone(), priority: usize::try_from(row.priority).unwrap_or(0), @@ -121,20 +133,23 @@ fn decode_row( } } -/// Assemble the loaded project rows for the evaluator. `source="equipment"` -/// rows are injection-only — their connection/secret target names a credential -/// to inject at connect, not a policy grant — and are DROPPED here. That drop -/// is load-bearing: a `secret` target PERMITS its host, so an undropped -/// equipment rule would silently grant network access alongside its injection. +/// Assemble one scope's loaded rows for the evaluator, tagging each with the +/// scope it came from. `source="equipment"` rows are injection-only — their +/// connection/secret target names a credential to inject at connect, not a +/// policy grant — and are DROPPED here. That drop is load-bearing: a `secret` +/// target PERMITS its host, so an undropped equipment rule would silently +/// grant network access alongside its injection. Org secret/connection targets +/// resolve through the SAME fenced maps as project ones (`find_secret_hosts` / +/// `find_connection_providers` already fetch org+project). pub(super) fn assemble( - project_rows: &[PolicyRuleV2Row], + rows: &[PolicyRuleV2Row], + scope: RuleScope, secret_hosts: &SecretHosts, connection_providers: &ConnectionProviders, ) -> Vec { - project_rows - .iter() + rows.iter() .filter(|row| row.source != "equipment") - .map(|row| decode_row(row, secret_hosts, connection_providers)) + .map(|row| decode_row(row, scope, secret_hosts, connection_providers)) .collect() } @@ -176,6 +191,7 @@ mod tests { ]; let rules = assemble( &rows, + RuleScope::Project, &SecretHosts::default(), &ConnectionProviders::default(), ); @@ -183,20 +199,78 @@ mod tests { assert_eq!(rules[0].id, "keep"); } + /// Test #12: agent-group is provably absent — every directory identity kind + /// the DB carries (agent/user/group) decodes to a live variant, a group id + /// decodes to `Group` (never a swallowed agent-group), and a principal-less + /// row is `Other`. There is no agent-group column or variant to decode. #[test] - fn directory_identities_decode_to_other_never_agent() { + fn agent_user_and_group_identities_decode_and_a_no_principal_row_is_other() { let rows = vec![row(|r| { - r.identities = Json(vec![serde_json::from_value( - json!({"agentId": null, "userId": null, "groupId": "g1"}), - ) - .expect("identity row")]); + r.identities = Json( + serde_json::from_value(json!([ + {"agentId": "a1", "userId": null, "groupId": null}, + {"agentId": null, "userId": "u1", "groupId": null}, + {"agentId": null, "userId": null, "groupId": "g1"}, + {"agentId": null, "userId": null, "groupId": null}, + ])) + .expect("identity rows"), + ); })]; let rules = assemble( &rows, + RuleScope::Project, + &SecretHosts::default(), + &ConnectionProviders::default(), + ); + assert!(matches!(&rules[0].identities[0], Identity::Agent(id) if id == "a1")); + assert!(matches!(&rules[0].identities[1], Identity::User(id) if id == "u1")); + assert!(matches!(&rules[0].identities[2], Identity::Group(id) if id == "g1")); + assert!(matches!(rules[0].identities[3], Identity::Other)); + } + + #[test] + fn rules_are_tagged_with_the_scope_they_were_assembled_for() { + let rows = vec![row(|_| {})]; + let org = assemble( + &rows, + RuleScope::Organization, + &SecretHosts::default(), + &ConnectionProviders::default(), + ); + let project = assemble( + &rows, + RuleScope::Project, &SecretHosts::default(), &ConnectionProviders::default(), ); - assert!(matches!(rules[0].identities[0], Identity::Other)); + assert_eq!(org[0].scope, RuleScope::Organization); + assert_eq!(project[0].scope, RuleScope::Project); + } + + #[test] + fn org_scope_targets_resolve_through_the_same_fenced_maps() { + let mut hosts = SecretHosts::default(); + hosts + .by_id + .insert("s1".to_string(), vec!["api.example.com".to_string()]); + let mut providers = ConnectionProviders::default(); + providers + .by_id + .insert("c1".to_string(), "github".to_string()); + let rows = vec![row(|r| { + r.targets = Json(vec![ + target(json!({"kind": "secret", "secretId": "s1"})), + target(json!({"kind": "connection", "appConnectionId": "c1", "appTools": []})), + ]); + })]; + let rules = assemble(&rows, RuleScope::Organization, &hosts, &providers); + assert!( + matches!(&rules[0].targets[0], Target::Secret { host_patterns } if host_patterns == &["api.example.com".to_string()]) + ); + assert!(matches!( + &rules[0].targets[1], + Target::Connection { id, provider, .. } if id == "c1" && provider == "github" + )); } #[test] @@ -211,7 +285,12 @@ mod tests { target(json!({"kind": "connection", "appConnectionId": "missing", "appTools": []})), ]); })]; - let rules = assemble(&rows, &SecretHosts::default(), &providers); + let rules = assemble( + &rows, + RuleScope::Project, + &SecretHosts::default(), + &providers, + ); assert!(matches!( &rules[0].targets[0], Target::Connection { id, provider, .. } if id == "c1" && provider == "github" @@ -233,7 +312,12 @@ mod tests { target(json!({"kind": "secret", "secretId": "deleted"})), ]); })]; - let rules = assemble(&rows, &hosts, &ConnectionProviders::default()); + let rules = assemble( + &rows, + RuleScope::Project, + &hosts, + &ConnectionProviders::default(), + ); assert!( matches!(&rules[0].targets[0], Target::Secret { host_patterns } if host_patterns == &["api.example.com".to_string()]) ); @@ -259,7 +343,12 @@ mod tests { let rows = vec![row(|r| { r.targets = Json(vec![target(json!({"kind": "secret", "secretId": "s1"}))]); })]; - let rules = assemble(&rows, &hosts, &ConnectionProviders::default()); + let rules = assemble( + &rows, + RuleScope::Project, + &hosts, + &ConnectionProviders::default(), + ); let Target::Secret { host_patterns } = &rules[0].targets[0] else { panic!("expected a secret target"); }; @@ -292,6 +381,7 @@ mod tests { ]; let rules = assemble( &rows, + RuleScope::Project, &SecretHosts::default(), &ConnectionProviders::default(), ); diff --git a/apps/gateway/src/policy_engine/enforce.rs b/apps/gateway/src/policy_engine/enforce.rs index 1e8d0f13..0af4c190 100644 --- a/apps/gateway/src/policy_engine/enforce.rs +++ b/apps/gateway/src/policy_engine/enforce.rs @@ -1,8 +1,13 @@ -//! The OSS enforce seam: load the published project rules at connection -//! resolution and decide requests with the first-match core, producing the -//! `policy::PolicyDecision` the forward/websocket act-path understands. The engine -//! is authoritative — an empty rule set (a load error, or an unmigrated project -//! with no Default Rule) decides `Allow`; there is no fallback. +//! The OSS enforce seam: load the published org + project rules (and, when a +//! rule targets a directory identity, the connection's principal set) at +//! connection resolution and decide requests with the two-level first-match +//! core, producing the `policy::PolicyDecision` the forward/websocket act-path +//! understands. The engine is authoritative — there is no legacy fallback. +//! +//! Fail-closed: every resolution query PROPAGATES its error (anyhow) so the +//! caller (`connect.rs`, via `.map_err(db_err)?`) REFUSES the CONNECT rather +//! than caching a policy-free (allow-everything, inject-nothing) state for the +//! ~60s cache cycle. The agent simply retries. //! //! HIGH PERFORMANCE: rules load ONCE at connection resolution (cached ~60s //! with the rest of the connect state); the per-request decision path never @@ -14,14 +19,15 @@ use sqlx::PgPool; use crate::cache::CacheStore; use crate::db::{ find_connection_providers, find_published_policy_rules_v2_by_project, find_secret_hosts, - AvailableApps, ConnectionProviders, PolicyRuleV2Row, PolicyV2Rules, SecretHosts, + AvailableApps, ConnectionProviders, PolicyRuleV2Row, PolicyV2Rules, PrincipalSet, SecretHosts, }; use crate::gateway::{strip_port, ProxyContext}; use crate::policy::{check_rate_limit, MatchedRule, PolicyDecision}; use super::assemble::assemble; use super::evaluate::evaluate_outcome; -use super::types::{Action, Outcome, Request, Rule}; +use super::loaders; +use super::types::{Action, Outcome, Request, Rule, RuleScope}; /// `false` always: OSS's `condition_match` arm cannot buffer bodies and never /// evaluates conditions (they match vacuously), so there is nothing to buffer for. @@ -29,37 +35,54 @@ pub(crate) fn needs_body_buffer(_v2: &PolicyV2Rules) -> bool { false } -/// Equipment rows are excluded: they are injection-only (dropped by the -/// assembler), so their secret/connection targets never need host/provider -/// resolution — mirroring the EE loader's lazy skip, which keeps the common -/// selective-agent connect resolution free of the two extra queries. -fn has_target_kind(rows: &[PolicyRuleV2Row], kind: &str) -> bool { - rows.iter() +/// True when any loaded rule (org or project) has a target of `kind`, skipping +/// equipment rows (injection-only — dropped by the assembler, so their +/// secret/connection targets never need host/provider resolution). The lazy +/// gate that keeps the common connect resolution free of the two extra queries. +fn has_target_kind(levels: &[&[PolicyRuleV2Row]], kind: &str) -> bool { + levels + .iter() + .flat_map(|rows| rows.iter()) .filter(|r| r.source != "equipment") .any(|r| r.targets.0.iter().any(|t| t.kind == kind)) } -/// Load the published project rules at resolution time — cached with -/// `ConnectResponse`, off the per-request hot path. Secret hosts and connection -/// providers resolve lazily, only when some loaded rule needs them. Any load error -/// PROPAGATES: the caller refuses the CONNECT rather than caching a policy-free -/// (allow-everything, inject-nothing) state for the ~60s cache cycle. +/// Load the published org + project rules (and lazily the principal set) at +/// resolution time — cached with `ConnectResponse`, off the per-request hot +/// path. Principals, secret hosts, and connection providers resolve lazily, +/// only when some loaded rule needs them. Any load error PROPAGATES: the caller +/// refuses the CONNECT rather than caching a policy-free state for the ~60s +/// cache cycle. pub(crate) async fn load_connect_v2( pool: &PgPool, org_id: &str, project_id: &str, ) -> anyhow::Result { + let org = loaders::find_published_policy_rules_v2_by_org(pool, org_id) + .await + .context("policy v2: org load failed at resolution")?; let project = find_published_policy_rules_v2_by_project(pool, project_id) .await .context("policy v2: project load failed at resolution")?; - let secret_hosts = if has_target_kind(&project, "secret") { + // Principals resolve lazily: only when some loaded rule (org or project, + // equipment included — inject-selection matches against them too) carries a + // directory identity. The set is agent-independent, so `agent_id` is not a + // parameter. The common agent-only connect stays at zero extra queries. + let principals = if loaders::has_directory_identity(&[&org, &project]) { + loaders::load_principal_set(pool, org_id, project_id) + .await + .context("policy v2: principal resolution failed at resolution")? + } else { + PrincipalSet::default() + }; + let secret_hosts = if has_target_kind(&[&org, &project], "secret") { find_secret_hosts(pool, org_id, project_id) .await .context("policy v2: secret-host resolution failed at resolution")? } else { SecretHosts::default() }; - let connection_providers = if has_target_kind(&project, "connection") { + let connection_providers = if has_target_kind(&[&org, &project], "connection") { find_connection_providers(pool, org_id, project_id) .await .context("policy v2: connection-provider resolution failed at resolution")? @@ -67,10 +90,11 @@ pub(crate) async fn load_connect_v2( ConnectionProviders::default() }; Ok(PolicyV2Rules { + org, project, + principals, secret_hosts, connection_providers, - ..PolicyV2Rules::default() }) } @@ -122,10 +146,9 @@ async fn decision_for_rule( PolicyDecision::Allow } -/// Decide via the OSS core over the already-resolved project rules. No DB access. -/// If the identity is somehow incomplete, or the rule set is empty (a load error, -/// or a project with no published policy), the decision is `Allow` — the engine is -/// authoritative, so there is no fallback. +/// Decide via the OSS two-level core over the already-resolved org + project +/// rules. No DB access. If the identity is somehow incomplete, the decision is +/// `Allow` — the engine is authoritative, so there is no fallback. #[allow(clippy::too_many_arguments)] pub(crate) async fn evaluate( proxy_ctx: &ProxyContext, @@ -148,7 +171,18 @@ pub(crate) async fn evaluate( }; let agent_token = proxy_ctx.agent_token.as_deref().unwrap_or(""); - let rules = assemble(&v2.project, &v2.secret_hosts, &v2.connection_providers); + let org_rules = assemble( + &v2.org, + RuleScope::Organization, + &v2.secret_hosts, + &v2.connection_providers, + ); + let project_rules = assemble( + &v2.project, + RuleScope::Project, + &v2.secret_hosts, + &v2.connection_providers, + ); let request = Request { host: strip_port(host).to_string(), path: path.to_string(), @@ -162,9 +196,9 @@ pub(crate) async fn evaluate( let matched_of = |rule: &Rule| MatchedRule { logical_id: rule.logical_id.clone(), name: rule.name.clone(), - scope: "project".to_string(), + scope: rule.scope.as_str().to_string(), }; - match evaluate_outcome(&rules, &request, body) { + match evaluate_outcome(&org_rules, &project_rules, &request, &v2.principals, body) { Outcome::Rule(rule) => ( decision_for_rule(rule, org_id, project_id, agent_token, cache).await, Some(matched_of(rule)), @@ -176,3 +210,131 @@ pub(crate) async fn evaluate( Outcome::Allow => (PolicyDecision::Allow, None), } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use sqlx::types::Json; + + fn row(over: impl FnOnce(&mut PolicyRuleV2Row)) -> PolicyRuleV2Row { + let mut r = PolicyRuleV2Row { + id: "r1".to_string(), + logical_id: "l1".to_string(), + name: "rule".to_string(), + source: "custom".to_string(), + priority: 0, + is_default: false, + action: "allow".to_string(), + rate_limit: None, + rate_limit_window: None, + require_approval: false, + conditions: None, + identities: Json(Vec::new()), + targets: Json(Vec::new()), + }; + over(&mut r); + r + } + + fn proxy_ctx() -> ProxyContext { + ProxyContext { + project_id: Some("p1".to_string()), + organization_id: Some("o1".to_string()), + agent_id: Some("a1".to_string()), + agent_name: None, + agent_identifier: None, + agent_token: Some("t".to_string()), + } + } + + fn network_target() -> serde_json::Value { + json!({"kind": "network", "hostPattern": "api.example.com"}) + } + + /// An org rule scoped to a directory GROUP, plus a project Default Rule. + fn org_group_block_bundle(principals: PrincipalSet) -> PolicyV2Rules { + PolicyV2Rules { + org: vec![row(|r| { + r.action = "block".to_string(); + r.identities = Json( + serde_json::from_value(json!([ + {"agentId": null, "userId": null, "groupId": "g1"} + ])) + .expect("identity rows"), + ); + r.targets = Json(vec![ + serde_json::from_value(network_target()).expect("target row") + ]); + })], + project: vec![row(|r| r.is_default = true)], + principals, + ..PolicyV2Rules::default() + } + } + + async fn run_seam(v2: &PolicyV2Rules) -> (PolicyDecision, Option) { + let store = crate::cache::create_store().await.expect("store"); + evaluate( + &proxy_ctx(), + "api.example.com", + "GET", + "/", + None, + false, + false, + None, + store.as_ref(), + v2, + ) + .await + } + + /// Test #1/#3: the seam wires `&v2.org` → the org assemble, `&v2.principals` + /// → the evaluator (a g1 membership matches), and `matched_of` attributes + /// the org scope end-to-end. + #[tokio::test] + async fn evaluate_enforces_an_org_group_rule_through_the_seam() { + let v2 = org_group_block_bundle(PrincipalSet { + group_ids: vec!["g1".to_string()], + ..PrincipalSet::default() + }); + let (decision, matched) = run_seam(&v2).await; + assert!(matches!(decision, PolicyDecision::Blocked { .. })); + let m = matched.expect("the winning org rule must be attributed"); + assert_eq!(m.scope, "organization"); + } + + /// The companion regression: an EMPTY principal set narrows the same org + /// group rule to nothing → Allow. A `PrincipalSet::default()` wired into the + /// seam would flip the test above, never this one. + #[tokio::test] + async fn empty_principals_narrow_the_org_group_rule_to_nothing() { + let v2 = org_group_block_bundle(PrincipalSet::default()); + let (decision, matched) = run_seam(&v2).await; + assert!(matches!(decision, PolicyDecision::Allow)); + assert!(matched.is_none()); + } + + #[test] + fn has_target_kind_scans_org_and_project_and_skips_equipment() { + let org = vec![row(|r| { + r.targets = Json(vec![serde_json::from_value( + json!({"kind": "secret", "secretId": "s1"}), + ) + .expect("target row")]); + })]; + let project: Vec = Vec::new(); + assert!(has_target_kind(&[&org, &project], "secret")); + assert!(!has_target_kind(&[&org, &project], "connection")); + // Equipment rows stay excluded — they are injection-only. + let equipment = vec![row(|r| { + r.source = "equipment".to_string(); + r.targets = Json(vec![serde_json::from_value( + json!({"kind": "secret", "secretId": "s1"}), + ) + .expect("target row")]); + })]; + assert!(!has_target_kind(&[&equipment, &project], "secret")); + } +} diff --git a/apps/gateway/src/policy_engine/evaluate.rs b/apps/gateway/src/policy_engine/evaluate.rs index 46511ebd..a2b897ff 100644 --- a/apps/gateway/src/policy_engine/evaluate.rs +++ b/apps/gateway/src/policy_engine/evaluate.rs @@ -1,23 +1,35 @@ -//! The OSS first-match evaluator: ONE level (project), the single-level -//! reduction of the uniform per-level law — the first matching rule decides, -//! else the project Default Rule is the terminal (its Block gated by the -//! `enforce_deny` carve), else allow. +//! The OSS two-level first-match evaluator, mirroring the canonical +//! `policy-translation/evaluator.ts` (`evaluatePolicyOutcome`): per-scope +//! first-match (org, then project), combined by STRICTEST (block strictest … +//! allow loosest), with each level's Default Rule as its fallback verdict +//! (deny wins), PLUS the HARD-FLOOR rule — a lone ALLOW at one level cannot +//! open the OTHER level's default-Block. Org-first tie-break. +//! +//! Why two levels rather than one merged list: a project rule may shadow a +//! project sibling, but must NEVER override an org guardrail. A single merged +//! first-match can honor at most one of "identity beats strictness" and "org is +//! un-overridable"; splitting org/project and combining by strictest honors both. //! //! Matching routes through the gateway's own `connect::host_matches` + //! `policy::matches_request`, so path globs, methods, the git-receive-pack //! bridge, and the (no-op in OSS) condition arm are byte-identical to the //! legacy path. +use crate::db::PrincipalSet; use crate::policy::{matches_request, PolicyAction, PolicyRule}; -use super::types::{Identity, Outcome, Request, Rule, Target}; +use super::types::{Action, Identity, Outcome, Request, Rule, Target}; -/// Empty identities = "any agent"; an `Agent` identity matches by id; `Other` -/// (a stored directory identity) never matches. -fn identity_matches(rule: &Rule, request: &Request) -> bool { +/// Empty identities = "any"; an `Agent` identity matches the acting agent by +/// id; the directory kinds (`User`/`Group`) match against the connection's +/// resolved principal set; `Other` (a row naming no principal the OSS engine +/// understands) never matches. Linear scans are fine — principal sets are small. +fn identity_matches(rule: &Rule, request: &Request, principals: &PrincipalSet) -> bool { rule.identities.is_empty() || rule.identities.iter().any(|i| match i { Identity::Agent(id) => *id == request.agent_id, + Identity::User(id) => principals.user_ids.contains(id), + Identity::Group(id) => principals.group_ids.contains(id), Identity::Other => false, }) } @@ -94,8 +106,13 @@ fn target_matches(target: &Target, rule: &Rule, request: &Request, body: Option< /// them matches. Empty targets = matches NOTHING: "match everything" is the /// Default Rule's job, never an empty list — which also neutralizes a rule /// orphaned to zero targets by an FK cascade (fail-closed). -fn rule_matches(rule: &Rule, request: &Request, body: Option<&[u8]>) -> bool { - identity_matches(rule, request) +fn rule_matches( + rule: &Rule, + request: &Request, + principals: &PrincipalSet, + body: Option<&[u8]>, +) -> bool { + identity_matches(rule, request, principals) && !rule.targets.is_empty() && rule .targets @@ -103,35 +120,128 @@ fn rule_matches(rule: &Rule, request: &Request, body: Option<&[u8]>) -> bool { .any(|t| target_matches(t, rule, request, body)) } -/// First matching non-default rule in `(priority, id)` order. The id tie-break -/// makes equal priorities total and deterministic, agreeing with the DB's -/// `ORDER BY r.priority, r.id` (ids are lowercase-hex UUIDs, so Rust byte order -/// equals the Postgres collation). -fn first_match<'a>(rules: &'a [Rule], request: &Request, body: Option<&[u8]>) -> Option<&'a Rule> { +/// Strictness rank, mirroring `strictness.ts::strictnessRank`: block strictest +/// (0) … allow loosest (3). LOWER is stricter, so the reduce below keeps the +/// smaller rank. (A rate-limit modifier ranks by its presence alone, exactly +/// as the TS does — `rateLimit !== null`.) +fn strictness_rank(rule: &Rule) -> u8 { + if rule.action == Action::Block { + 0 + } else if rule.require_approval { + 1 + } else if rule.rate_limit.is_some() { + 2 + } else { + 3 + } +} + +/// A level's first matching non-default rule, carrying its strictness rank. +#[derive(Clone, Copy)] +struct LevelMatch<'a> { + rank: u8, + rule: &'a Rule, +} + +/// First matching non-default rule of one level in `(priority, id)` order. The +/// id tie-break makes equal priorities total and deterministic, agreeing with +/// the DB's `ORDER BY r.priority, r.id` (ids are lowercase-hex UUIDs, so Rust +/// byte order equals the Postgres collation). +fn first_match<'a>( + rules: &'a [Rule], + request: &Request, + principals: &PrincipalSet, + body: Option<&[u8]>, +) -> Option> { let mut ordered: Vec<&'a Rule> = rules.iter().filter(|r| !r.is_default).collect(); ordered.sort_by(|a, b| a.priority.cmp(&b.priority).then_with(|| a.id.cmp(&b.id))); ordered .into_iter() - .find(|rule| rule_matches(rule, request, body)) + .find(|rule| rule_matches(rule, request, principals, body)) + .map(|rule| LevelMatch { + rank: strictness_rank(rule), + rule, + }) } -/// Decide the request: the first matching rule wins (allow or block — an -/// explicit project allow opens its own Default-Block, allowlist-style); -/// otherwise the project Default Rule is the terminal, its Block enforced only -/// under the `enforce_deny` carve (credentialed, non-LLM traffic); otherwise -/// allow. This is exactly the EE evaluator's project arm with no org level -/// contributing a verdict. +/// Decide the request under the two-level hard-floor law, a faithful port of +/// `evaluator.ts::evaluatePolicyOutcome`: +/// +/// - each level's verdict is its first matching explicit rule (else nothing); +/// - a Default-Block is a HARD FLOOR at its level (gated by the `enforce_deny` +/// carve): a lone ALLOW at the OTHER level is DROPPED so it can't open it — +/// an org allow can't punch through a project allowlist floor, and a project +/// allow can't punch through an org default-Block; a BLOCK still applies (it +/// only tightens); +/// - surviving matches combine by STRICTEST (lower rank wins), org-first on a +/// tie (the org rate/approval modifier wins); +/// - with no surviving match the level defaults decide, deny-wins, org-first. +/// +/// Only ONE rule ever decides — modifiers never stack across levels. pub(super) fn evaluate_outcome<'a>( - rules: &'a [Rule], + org_rules: &'a [Rule], + project_rules: &'a [Rule], request: &Request, + principals: &PrincipalSet, body: Option<&[u8]>, ) -> Outcome<'a> { - if let Some(rule) = first_match(rules, request, body) { - return Outcome::Rule(rule); + let org_default = org_rules.iter().find(|r| r.is_default); + let project_default = project_rules.iter().find(|r| r.is_default); + + let org_match = first_match(org_rules, request, principals, body); + let project_match = first_match(project_rules, request, principals, body); + + // A Default-Block is enforced only under the carve (credentialed, non-LLM), + // at EVERY level. + let enforce_deny = request.enforce_deny(); + let org_default_blocks = org_default.is_some_and(|d| d.action == Action::Block) && enforce_deny; + let project_default_blocks = + project_default.is_some_and(|d| d.action == Action::Block) && enforce_deny; + + // A lone org ALLOW can't punch through the project default-Block (allowlist + // mode) — drop it so it falls through to the deny-default. An org BLOCK + // still applies (it only tightens). Approval/rate rules are action "allow", + // so they defer too — symmetric with the org floor below. + let effective_org = if project_match.is_none() + && matches!(org_match, Some(m) if m.rule.action == Action::Allow) + && project_default_blocks + { + None + } else { + org_match + }; + + // A lone project ALLOW can't punch through the org default-Block — drop it + // so it falls through to the deny-default. A project BLOCK still applies (it + // only tightens); an allow-posture org lets the project allow win. + let effective_project = if org_match.is_none() + && matches!(project_match, Some(m) if m.rule.action == Action::Allow) + && org_default_blocks + { + None + } else { + project_match + }; + + // Combine by strictest (lower rank = stricter); on a tie keep the org match + // (left bias) so the org modifier wins, matching the oracle's org-first pass. + let best = [effective_org, effective_project] + .into_iter() + .flatten() + .reduce(|a, b| if b.rank < a.rank { b } else { a }); + if let Some(best) = best { + return Outcome::Rule(best.rule); } - let default = rules.iter().find(|r| r.is_default); - if let Some(d) = default { - if d.action == super::types::Action::Block && request.enforce_deny() { + + // No explicit rule survived → the level defaults decide; deny wins, + // attributed org-first (the org default is checked first at the gateway). + if org_default_blocks { + if let Some(d) = org_default { + return Outcome::DenyDefault(d); + } + } + if project_default_blocks { + if let Some(d) = project_default { return Outcome::DenyDefault(d); } } @@ -140,12 +250,13 @@ pub(super) fn evaluate_outcome<'a>( #[cfg(test)] mod tests { - use super::super::types::{Action, RateWindow}; + use super::super::types::{Action, RateWindow, RuleScope}; use super::*; fn rule(id: &str, priority: usize, action: Action) -> Rule { Rule { id: id.to_string(), + scope: RuleScope::Project, logical_id: format!("l-{id}"), name: id.to_string(), priority, @@ -164,6 +275,26 @@ mod tests { } } + fn org_rule(id: &str, priority: usize, action: Action) -> Rule { + Rule { + scope: RuleScope::Organization, + ..rule(id, priority, action) + } + } + + fn approval_rule(id: &str, priority: usize) -> Rule { + let mut r = rule(id, priority, Action::Allow); + r.require_approval = true; + r + } + + fn rate_rule(id: &str, priority: usize) -> Rule { + let mut r = rule(id, priority, Action::Allow); + r.rate_limit = Some(5); + r.rate_limit_window = Some(RateWindow::Minute); + r + } + fn default_rule(action: Action) -> Rule { let mut r = rule("default", 99, action); r.is_default = true; @@ -171,6 +302,24 @@ mod tests { r } + fn org_default(action: Action) -> Rule { + Rule { + scope: RuleScope::Organization, + ..default_rule(action) + } + } + + fn no_principals() -> PrincipalSet { + PrincipalSet::default() + } + + fn principals() -> PrincipalSet { + PrincipalSet { + user_ids: vec!["u-1".to_string()], + group_ids: vec!["g-1".to_string()], + } + } + fn request() -> Request { Request { host: "api.example.com".to_string(), @@ -190,62 +339,12 @@ mod tests { } } - /// The per-account law, all four directions: a `Connection` target matches - /// iff (the request's winning injected connection == its id) AND the - /// provider catalog fan-out hits. Lockstep twin of the EE corpus arms - /// 6b/6c/11/12 and the TS `connection target binds to the winner` block. - #[test] - fn connection_target_binds_to_the_winning_connection() { - let conn_block = |id: &str| { - let mut r = rule("c-rule", 1, Action::Block); - r.targets = vec![Target::Connection { - id: id.to_string(), - provider: "gmail".to_string(), - tools: Vec::new(), - }]; - r - }; - let req_via = |winner: Option<&str>| Request { - host: "gmail.googleapis.com".to_string(), - path: "/gmail/v1/users/me/messages".to_string(), - method: "GET".to_string(), - agent_id: "agent-1".to_string(), - has_injections: true, - is_llm_host: false, - winning_connection_id: winner.map(str::to_string), - }; - let rules = vec![conn_block("c1")]; - - // Matching winner on the provider's catalog host → the block binds. - assert!(matches!( - evaluate_outcome(&rules, &req_via(Some("c1")), None), - Outcome::Rule(r) if r.action == Action::Block - )); - // A same-provider sibling account → no match (the deliberate change - // from the provider-wide decode). - assert!(matches!( - evaluate_outcome(&rules, &req_via(Some("c2")), None), - Outcome::Allow - )); - // No winner (secret-served / uncredentialed) → no match (fail-closed). - assert!(matches!( - evaluate_outcome(&rules, &req_via(None), None), - Outcome::Allow - )); - // Winner equality alone is not enough: a host outside the provider's - // catalog fails the fan-out gate. - let mut off_host = req_via(Some("c1")); - off_host.host = "api.github.com".to_string(); - assert!(matches!( - evaluate_outcome(&rules, &off_host, None), - Outcome::Allow - )); - } + // ── Single-level (project) reductions — the org slice is empty ────── #[test] fn first_match_wins_by_priority() { let rules = vec![rule("b", 1, Action::Block), rule("a", 0, Action::Allow)]; - match evaluate_outcome(&rules, &request(), None) { + match evaluate_outcome(&[], &rules, &request(), &no_principals(), None) { Outcome::Rule(r) => assert_eq!(r.id, "a"), _ => panic!("expected a rule match"), } @@ -257,13 +356,14 @@ mod tests { vec![rule("a", 5, Action::Allow), rule("b", 5, Action::Block)], vec![rule("b", 5, Action::Block), rule("a", 5, Action::Allow)], ] { - match evaluate_outcome(&rules, &request(), None) { + match evaluate_outcome(&[], &rules, &request(), &no_principals(), None) { Outcome::Rule(r) => assert_eq!(r.id, "a", "lower id wins the tie"), _ => panic!("expected a rule match"), } } } + /// Test #11 (part): `Other` never matches; empty identities = any. #[test] fn agent_identity_scopes_and_other_never_matches() { let mut agent_scoped = rule("scoped", 0, Action::Block); @@ -273,40 +373,43 @@ mod tests { let allow = rule("any", 2, Action::Allow); let rules = vec![agent_scoped, other, allow]; - match evaluate_outcome(&rules, &request(), None) { + match evaluate_outcome(&[], &rules, &request(), &no_principals(), None) { Outcome::Rule(r) => assert_eq!(r.id, "scoped"), _ => panic!("expected the agent-scoped match"), } let mut foreign = request(); foreign.agent_id = "agent-2".to_string(); - match evaluate_outcome(&rules, &foreign, None) { + match evaluate_outcome(&[], &rules, &foreign, &no_principals(), None) { // The directory identity must NOT match — the any-agent allow wins. Outcome::Rule(r) => assert_eq!(r.id, "any"), _ => panic!("expected the any-agent match"), } } + /// Test #11 (part): an empty-target rule is inert. #[test] fn empty_target_rule_is_inert() { let mut orphan = rule("orphan", 0, Action::Block); orphan.targets = Vec::new(); let control = rule("control", 1, Action::Allow); - match evaluate_outcome(&[orphan, control], &request(), None) { + match evaluate_outcome(&[], &[orphan, control], &request(), &no_principals(), None) { Outcome::Rule(r) => assert_eq!(r.id, "control"), _ => panic!("expected the control match"), } } + /// Test #10 (project level): the Default Rule Block enforces only under the + /// `enforce_deny` carve. #[test] fn default_block_enforces_only_under_the_carve() { let rules = vec![default_rule(Action::Block)]; // Uncredentialed → the carve spares it. assert!(matches!( - evaluate_outcome(&rules, &request(), None), + evaluate_outcome(&[], &rules, &request(), &no_principals(), None), Outcome::Allow )); // Credentialed non-LLM → blocked, attributed to the Default Rule. - match evaluate_outcome(&rules, &injected_request(), None) { + match evaluate_outcome(&[], &rules, &injected_request(), &no_principals(), None) { Outcome::DenyDefault(d) => assert!(d.is_default), _ => panic!("expected the deny-default"), } @@ -314,17 +417,17 @@ mod tests { let mut llm = injected_request(); llm.is_llm_host = true; assert!(matches!( - evaluate_outcome(&rules, &llm, None), + evaluate_outcome(&[], &rules, &llm, &no_principals(), None), Outcome::Allow )); } #[test] - fn explicit_allow_opens_the_default_block() { + fn explicit_allow_opens_the_same_level_default_block() { let rules = vec![rule("open", 0, Action::Allow), default_rule(Action::Block)]; - match evaluate_outcome(&rules, &injected_request(), None) { + match evaluate_outcome(&[], &rules, &injected_request(), &no_principals(), None) { Outcome::Rule(r) => assert_eq!(r.id, "open"), - _ => panic!("expected the allow rule to win over the default block"), + _ => panic!("expected the allow rule to win over its own default block"), } } @@ -332,28 +435,350 @@ mod tests { fn default_allow_is_neutral() { let rules = vec![default_rule(Action::Allow)]; assert!(matches!( - evaluate_outcome(&rules, &injected_request(), None), + evaluate_outcome(&[], &rules, &injected_request(), &no_principals(), None), Outcome::Allow )); } + /// Test #11 (part): the OSS condition arm is the no-op — a conditioned block + /// matches vacuously. This pins the Stage-G seam; if OSS ships real + /// condition matching this test must flip with it. #[test] fn conditioned_rule_matches_with_no_body_in_oss() { - // OSS's condition arm is the no-op (vacuously true) — a conditioned - // block matches exactly like the legacy OSS gateway treated it. This - // pins the posture; if OSS ever ships real condition matching, this - // test must flip with it. let mut conditioned = rule("cond", 0, Action::Block); conditioned.conditions = serde_json::from_str( r#"[{"target":"body","operator":"contains","value":"never-present"}]"#, ) .ok(); - match evaluate_outcome(&[conditioned], &request(), None) { + match evaluate_outcome(&[], &[conditioned], &request(), &no_principals(), None) { Outcome::Rule(r) => assert_eq!(r.id, "cond"), _ => panic!("expected the conditioned rule to match vacuously"), } } + // ── Test #7: connection winner-binding, fail-closed both ways ─────── + + /// A `Connection` target matches iff (winner == its id) AND the catalog + /// fan-out hits — for an ALLOW and a BLOCK alike; no winner → never matches. + #[test] + fn connection_target_binds_to_the_winning_connection() { + let conn_rule = |id: &str, action: Action| { + let mut r = rule("c-rule", 1, action); + r.targets = vec![Target::Connection { + id: id.to_string(), + provider: "gmail".to_string(), + tools: Vec::new(), + }]; + r + }; + let req_via = |winner: Option<&str>| Request { + host: "gmail.googleapis.com".to_string(), + path: "/gmail/v1/users/me/messages".to_string(), + method: "GET".to_string(), + agent_id: "agent-1".to_string(), + has_injections: true, + is_llm_host: false, + winning_connection_id: winner.map(str::to_string), + }; + + // BLOCK: matching winner binds; no winner → no match (fail-closed). + let blk = vec![conn_rule("c1", Action::Block)]; + assert!(matches!( + evaluate_outcome(&[], &blk, &req_via(Some("c1")), &no_principals(), None), + Outcome::Rule(r) if r.action == Action::Block + )); + assert!(matches!( + evaluate_outcome(&[], &blk, &req_via(None), &no_principals(), None), + Outcome::Allow + )); + // A same-provider sibling account → no match. + assert!(matches!( + evaluate_outcome(&[], &blk, &req_via(Some("c2")), &no_principals(), None), + Outcome::Allow + )); + + // ALLOW: an allow-connection rule over a project default-Block only + // opens the door for its OWN winner; no winner → the default-Block + // stands (fail-closed for allow too). + let allow_over_block = vec![conn_rule("c1", Action::Allow), default_rule(Action::Block)]; + match evaluate_outcome( + &[], + &allow_over_block, + &req_via(Some("c1")), + &no_principals(), + None, + ) { + Outcome::Rule(r) => assert_eq!(r.action, Action::Allow), + _ => panic!("winner should open its own connection allow"), + } + assert!(matches!( + evaluate_outcome( + &[], + &allow_over_block, + &req_via(None), + &no_principals(), + None + ), + Outcome::DenyDefault(_) + )); + } + + // ── Test #1: an org-scope rule is enforced and attributed ─────────── + + #[test] + fn org_rule_is_enforced_and_carries_org_scope() { + let org = vec![org_rule("org-block", 0, Action::Block)]; + match evaluate_outcome(&org, &[], &request(), &no_principals(), None) { + Outcome::Rule(r) => { + assert_eq!(r.id, "org-block"); + assert_eq!(r.scope, RuleScope::Organization); + } + _ => panic!("expected the org block"), + } + } + + // ── Tests #2/#3/#4: directory identities via the principal set ────── + + #[test] + fn user_and_group_identities_match_via_the_principal_set() { + for (id, identity) in [ + ("by-user", Identity::User("u-1".to_string())), + ("by-group", Identity::Group("g-1".to_string())), + ] { + let mut scoped = org_rule(id, 0, Action::Block); + scoped.identities = vec![identity]; + let org = vec![scoped]; + // Present in the principal set → the rule matches. + match evaluate_outcome(&org, &[], &request(), &principals(), None) { + Outcome::Rule(r) => assert_eq!(r.id, id), + _ => panic!("expected {id} to match via principals"), + } + // Absent (empty/stale set) → the rule narrows to nothing. + assert!(matches!( + evaluate_outcome(&org, &[], &request(), &no_principals(), None), + Outcome::Allow + )); + } + } + + /// Test #4: cross-org isolation at the match boundary — a rule naming a + /// principal absent from THIS connection's set (it belongs to another org's + /// directory, so the org-fenced loader never put it here) never matches. + #[test] + fn a_principal_outside_the_resolved_set_never_matches() { + let mut foreign_user = org_rule("foreign-user", 0, Action::Block); + foreign_user.identities = vec![Identity::User("u-other".to_string())]; + let mut foreign_group = org_rule("foreign-group", 1, Action::Block); + foreign_group.identities = vec![Identity::Group("g-other".to_string())]; + let org = vec![foreign_user, foreign_group]; + assert!(matches!( + evaluate_outcome(&org, &[], &request(), &principals(), None), + Outcome::Allow + )); + } + + // ── Test #5: EMPTY-ORG FAIL-OPEN ──────────────────────────────────── + + /// An empty org slice must contribute NO verdict — never a phantom block. + /// Most orgs have zero org rules (the boot converter writes project-scope + /// only), so this is the load-bearing safety property. + #[test] + fn empty_org_fails_open_not_closed() { + // No project rules either → plain allow, even credentialed. + assert!(matches!( + evaluate_outcome(&[], &[], &injected_request(), &no_principals(), None), + Outcome::Allow + )); + // An empty org slice changes nothing vs the project-only walk. + let project = vec![rule("open", 0, Action::Allow), default_rule(Action::Block)]; + match evaluate_outcome(&[], &project, &injected_request(), &no_principals(), None) { + Outcome::Rule(r) => assert_eq!(r.id, "open"), + _ => panic!("expected the project allow, not a phantom org block"), + } + } + + // ── Test #6: empty project → the org level decides ────────────────── + + #[test] + fn empty_project_lets_the_org_level_decide() { + let org = vec![org_rule("org-allow", 0, Action::Allow)]; + match evaluate_outcome(&org, &[], &request(), &no_principals(), None) { + Outcome::Rule(r) => { + assert_eq!(r.id, "org-allow"); + assert_eq!(r.scope, RuleScope::Organization); + } + _ => panic!("expected the org allow to decide"), + } + // An org default-Block over an empty project blocks under the carve. + let org = vec![org_default(Action::Block)]; + match evaluate_outcome(&org, &[], &injected_request(), &no_principals(), None) { + Outcome::DenyDefault(d) => assert_eq!(d.scope, RuleScope::Organization), + _ => panic!("expected the org deny-default"), + } + } + + // ── Test #8: two-level stricter-wins ──────────────────────────────── + + #[test] + fn org_block_overrides_project_allow_and_vice_versa() { + // Org guardrail Block beats a project allow… + let org = vec![org_rule("org-block", 0, Action::Block)]; + let project = vec![rule("proj-allow", 0, Action::Allow)]; + match evaluate_outcome(&org, &project, &request(), &no_principals(), None) { + Outcome::Rule(r) => assert_eq!(r.id, "org-block"), + _ => panic!("expected the org block"), + } + // …and symmetrically a project Block survives an org allow. + let org = vec![org_rule("org-allow", 0, Action::Allow)]; + let project = vec![rule("proj-block", 0, Action::Block)]; + match evaluate_outcome(&org, &project, &request(), &no_principals(), None) { + Outcome::Rule(r) => assert_eq!(r.id, "proj-block"), + _ => panic!("expected the project block"), + } + } + + #[test] + fn org_approval_beats_project_rate_limit() { + let org = vec![{ + let mut r = approval_rule("org-approval", 0); + r.scope = RuleScope::Organization; + r + }]; + let project = vec![rate_rule("proj-rate", 0)]; + match evaluate_outcome(&org, &project, &request(), &no_principals(), None) { + Outcome::Rule(r) => assert_eq!(r.id, "org-approval"), + _ => panic!("expected the approval to outrank the rate limit"), + } + } + + #[test] + fn equal_rank_rate_limits_attribute_to_the_org_rule() { + // Two rate verdicts: only the winner acts, and the equal-rank tie goes + // to org (left bias). + let org = vec![{ + let mut r = rate_rule("org-rate", 0); + r.scope = RuleScope::Organization; + r + }]; + let project = vec![rate_rule("proj-rate", 0)]; + match evaluate_outcome(&org, &project, &request(), &no_principals(), None) { + Outcome::Rule(r) => { + assert_eq!(r.id, "org-rate"); + assert_eq!(r.scope, RuleScope::Organization); + } + _ => panic!("expected the org rate rule"), + } + } + + /// Test #11 (part): a level's `Other`-only rule is inert, the empty-identity + /// rule at that level still fires, and both levels honor "any". + #[test] + fn empty_identities_match_any_at_both_levels_and_other_never_does() { + let mut malformed = org_rule("malformed", 0, Action::Block); + malformed.identities = vec![Identity::Other]; + let org = vec![malformed, org_rule("org-any", 1, Action::Block)]; + let project = vec![rule("proj-any", 0, Action::Allow)]; + match evaluate_outcome(&org, &project, &request(), &principals(), None) { + Outcome::Rule(r) => assert_eq!(r.id, "org-any"), + _ => panic!("expected the any-identity org block"), + } + } + + // ── Test #9: the HARD FLOOR, both directions ──────────────────────── + + /// A lone project ALLOW cannot open the org default-Block; a lone org ALLOW + /// cannot open the project allowlist default-Block. Under the carve both + /// fall through to the respective deny-default. + #[test] + fn a_lone_allow_cannot_open_the_other_levels_default_block() { + // Direction 1: org default-Block + lone project allow → org deny-default. + let org = vec![org_default(Action::Block)]; + let project = vec![rule("proj-allow", 0, Action::Allow)]; + match evaluate_outcome(&org, &project, &injected_request(), &no_principals(), None) { + Outcome::DenyDefault(d) => { + assert!(d.is_default); + assert_eq!(d.scope, RuleScope::Organization); + } + _ => panic!("the project allow must not punch the org floor"), + } + // Without the carve the org level allows — the project allow wins. + match evaluate_outcome(&org, &project, &request(), &no_principals(), None) { + Outcome::Rule(r) => assert_eq!(r.id, "proj-allow"), + _ => panic!("expected the project allow off the carve"), + } + + // Direction 2: project default-Block (allowlist) + lone org allow → + // project deny-default. + let org = vec![org_rule("org-allow", 0, Action::Allow)]; + let project = vec![default_rule(Action::Block)]; + match evaluate_outcome(&org, &project, &injected_request(), &no_principals(), None) { + Outcome::DenyDefault(d) => { + assert!(d.is_default); + assert_eq!(d.scope, RuleScope::Project); + } + _ => panic!("the org allow must not punch the project allowlist floor"), + } + // Without the carve the project level allows — the org allow wins. + match evaluate_outcome(&org, &project, &request(), &no_principals(), None) { + Outcome::Rule(r) => assert_eq!(r.id, "org-allow"), + _ => panic!("expected the org allow off the carve"), + } + } + + /// The counter-case: a BLOCK is never dropped by the floor logic (it only + /// tightens), and an allow-posture opposite level lets the allow through. + #[test] + fn a_block_survives_the_floor_and_an_allow_posture_lets_an_allow_win() { + // A project BLOCK applies even against an org default-Block… + let org = vec![org_default(Action::Block)]; + let project = vec![rule("proj-block", 0, Action::Block)]; + match evaluate_outcome(&org, &project, &injected_request(), &no_principals(), None) { + Outcome::Rule(r) => assert_eq!(r.id, "proj-block"), + _ => panic!("a block must survive the org floor"), + } + // …and with no org default-Block a lone org allow just wins. + let org = vec![org_rule("org-allow", 0, Action::Allow)]; + match evaluate_outcome(&org, &[], &injected_request(), &no_principals(), None) { + Outcome::Rule(r) => assert_eq!(r.id, "org-allow"), + _ => panic!("expected the org allow"), + } + } + + // ── Test #10: deny-default carve per level ────────────────────────── + + #[test] + fn org_default_block_carve_gates_each_level_independently() { + // Org default-Block: spared off the carve, blocks under it. + let org = vec![org_default(Action::Block)]; + assert!(matches!( + evaluate_outcome(&org, &[], &request(), &no_principals(), None), + Outcome::Allow + )); + match evaluate_outcome(&org, &[], &injected_request(), &no_principals(), None) { + Outcome::DenyDefault(d) => assert_eq!(d.scope, RuleScope::Organization), + _ => panic!("expected the org deny-default under the carve"), + } + // Project default-Block: same carve, independently. + let project = vec![default_rule(Action::Block)]; + assert!(matches!( + evaluate_outcome(&[], &project, &request(), &no_principals(), None), + Outcome::Allow + )); + match evaluate_outcome(&[], &project, &injected_request(), &no_principals(), None) { + Outcome::DenyDefault(d) => assert_eq!(d.scope, RuleScope::Project), + _ => panic!("expected the project deny-default under the carve"), + } + } + + #[test] + fn absent_project_default_with_org_default_allow_is_allow() { + let org = vec![org_default(Action::Allow)]; + assert!(matches!( + evaluate_outcome(&org, &[], &injected_request(), &no_principals(), None), + Outcome::Allow + )); + } + #[test] fn rate_window_secs_mapping() { assert_eq!(RateWindow::Minute.secs(), 60); diff --git a/apps/gateway/src/policy_engine/loaders.rs b/apps/gateway/src/policy_engine/loaders.rs new file mode 100644 index 00000000..2dfcc4be --- /dev/null +++ b/apps/gateway/src/policy_engine/loaders.rs @@ -0,0 +1,200 @@ +//! The OSS analogs of the EE overlay loaders: the org-scope published-rule +//! query and the connection's principal-set resolution. OSS-only by +//! construction — the EE builds swap the whole `policy_engine` tree (with +//! their own loaders) via `#[path]` in `main.rs`, so nothing here can collide +//! with the enterprise overlay on merge. +//! +//! Both run ONCE at connection resolution (cached with `ConnectResponse`); +//! the per-request decision path never touches the DB. + +use anyhow::{Context, Result}; +use sqlx::PgPool; + +use crate::db::{PolicyRuleV2Row, PrincipalSet, POLICY_V2_SELECT}; + +/// Active published ORG-scope rules (max published generation), first-match +/// ordered. Mirrors `db::find_published_policy_rules_v2_by_project` exactly — +/// same SELECT, same generation law, same `ORDER BY priority, id` — with the +/// org arm's fence (`organization_id` + `scope = 'organization'`). +pub(super) async fn find_published_policy_rules_v2_by_org( + pool: &PgPool, + organization_id: &str, +) -> Result> { + sqlx::query_as::<_, PolicyRuleV2Row>(&format!( + r#"{POLICY_V2_SELECT} + WHERE r.organization_id = $1 AND r.scope = 'organization' + AND r.status = 'published' AND r.enabled = true + AND r.generation = ( + SELECT max(generation) FROM policy_rules_v2 + WHERE organization_id = $1 AND scope = 'organization' AND status = 'published') + ORDER BY r.priority, r.id"# + )) + .bind(organization_id) + .fetch_all(pool) + .await + .context("querying org policy_rules_v2 by organization_id") +} + +/// One resolved principal set: the two text[] columns of the CTE below. +#[derive(sqlx::FromRow)] +struct PrincipalRow { + user_ids: Vec, + group_ids: Vec, +} + +/// Resolve the connection's principal set — the humans a proxied request is +/// matched against, and the directory groups they carry. Proxied traffic bears +/// no connecting-user identity (`ProxyContext` is agent-only), so the set is +/// AGENT-INDEPENDENT: one resolution covers every agent of the project. A pure +/// mirror of `resolvePrincipalSet` +/// (packages/api/src/services/policy-simulate/principal-set.ts): +/// +/// - `direct_users` = ProjectAccess rows naming a user; +/// - `direct_groups` = ProjectAccess rows naming a group, ORG-FENCED FIRST +/// (a granted group must belong to this org); +/// - `candidate_users` = direct_users ∪ members of the (org-fenced) direct_groups; +/// - `user_ids` = candidate_users ∩ ACTIVE org members (status <> 'suspended', +/// mirroring the people-gate `user_can_manage_project`); +/// - `group_ids` = direct_groups ∪ every group the resolved user_ids belong to, +/// the latter ORG-FENCED (a user can belong to OTHER orgs' groups). +/// +/// Every arm is org-fenced, so a foreign group grant or a user's membership in +/// another org's groups can never leak in. Role-agnostic (presence-only). Run +/// as ONE indexed CTE round-trip (off the hot path — the gateway resolves this +/// at connect, cached with `ConnectResponse`). Keep in lockstep with the TS. +pub(super) async fn load_principal_set( + pool: &PgPool, + organization_id: &str, + project_id: &str, +) -> Result { + let row: PrincipalRow = sqlx::query_as::<_, PrincipalRow>( + r#" + WITH access AS ( + SELECT user_id, group_id FROM project_access WHERE project_id = $1 + ), + direct_users AS ( + SELECT user_id FROM access WHERE user_id IS NOT NULL + ), + direct_groups AS ( + SELECT g.id FROM groups g + WHERE g.id IN (SELECT group_id FROM access WHERE group_id IS NOT NULL) + AND g.organization_id = $2 + ), + candidate_users AS ( + SELECT user_id FROM direct_users + UNION + SELECT gm.user_id FROM group_members gm + WHERE gm.group_id IN (SELECT id FROM direct_groups) + ), + active_users AS ( + SELECT om.user_id FROM organization_members om + WHERE om.user_id IN (SELECT user_id FROM candidate_users) + AND om.organization_id = $2 + AND om.status <> 'suspended' + ), + user_groups AS ( + SELECT gm.group_id FROM group_members gm + JOIN groups g ON g.id = gm.group_id AND g.organization_id = $2 + WHERE gm.user_id IN (SELECT user_id FROM active_users) + ) + SELECT + COALESCE((SELECT array_agg(DISTINCT user_id) FROM active_users), '{}') AS user_ids, + COALESCE(( + SELECT array_agg(DISTINCT gid) FROM ( + SELECT id AS gid FROM direct_groups + UNION + SELECT group_id AS gid FROM user_groups + ) g + ), '{}') AS group_ids + "#, + ) + .bind(project_id) + .bind(organization_id) + .fetch_one(pool) + .await + .context("resolving the connection principal set")?; + + Ok(PrincipalSet { + user_ids: row.user_ids, + group_ids: row.group_ids, + }) +} + +/// True when any loaded rule (org or project, every source — equipment rows +/// matter for inject-selection) carries a directory identity row (user or +/// group). The lazy gate on principal resolution: agent-only configs skip the +/// resolution query entirely. There is no agent-group column, so only user/group +/// rows trigger it. +pub(super) fn has_directory_identity(levels: &[&[PolicyRuleV2Row]]) -> bool { + levels.iter().flat_map(|rows| rows.iter()).any(|r| { + r.identities + .0 + .iter() + .any(|i| i.user_id.is_some() || i.group_id.is_some()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use sqlx::types::Json; + + fn row(identities: serde_json::Value, source: &str) -> PolicyRuleV2Row { + PolicyRuleV2Row { + id: "r1".to_string(), + logical_id: "l1".to_string(), + name: "rule".to_string(), + source: source.to_string(), + priority: 0, + is_default: false, + action: "allow".to_string(), + rate_limit: None, + rate_limit_window: None, + require_approval: false, + conditions: None, + identities: Json(serde_json::from_value(identities).expect("identities")), + targets: Json(Vec::new()), + } + } + + fn identity(v: serde_json::Value) -> serde_json::Value { + json!([v]) + } + + #[test] + fn agent_only_rows_do_not_trigger_principal_resolution() { + let rows = vec![ + row(json!([]), "custom"), + row( + identity(json!({"agentId": "a1", "userId": null, "groupId": null})), + "custom", + ), + ]; + assert!(!has_directory_identity(&[&rows, &[]])); + } + + #[test] + fn each_directory_kind_triggers_principal_resolution() { + for principal in [ + json!({"agentId": null, "userId": "u1", "groupId": null}), + json!({"agentId": null, "userId": null, "groupId": "g1"}), + ] { + let rows = vec![row(identity(principal), "custom")]; + assert!(has_directory_identity(&[&rows, &[]])); + } + } + + #[test] + fn scans_both_levels_and_counts_equipment_rows() { + let org: Vec = Vec::new(); + // An equipment row's directory identity matters (inject-selection reads + // equipment rows), so it must trigger resolution too. + let project = vec![row( + identity(json!({"agentId": null, "userId": null, "groupId": "g1"})), + "equipment", + )]; + assert!(has_directory_identity(&[&org, &project])); + assert!(!has_directory_identity(&[&org, &[]])); + } +} diff --git a/apps/gateway/src/policy_engine/types.rs b/apps/gateway/src/policy_engine/types.rs index 56f32ba5..85470964 100644 --- a/apps/gateway/src/policy_engine/types.rs +++ b/apps/gateway/src/policy_engine/types.rs @@ -1,7 +1,9 @@ -//! Shapes for the OSS project-level policy core: the decoded rule, the request -//! context, and the evaluation outcome. Project scope only — OSS has no org -//! layer, no directory identities, and no granular conditions; those live in -//! the EE engine this module replaces under `edition_oss`. +//! Shapes for the OSS policy core: the decoded rule, the request context, and +//! the evaluation outcome. Org + project scopes with agent and directory +//! (user/group) identities — granular conditions stay vacuous here; those live +//! in the EE engine this module replaces under `edition_oss`. There is no +//! agent-group concept: it was deleted, so no identity kind, principal column, +//! or loader references one. /// The rule verdict: the v2 binary. Approval and rate limits are modifiers on /// `Allow` (see `Rule`). @@ -29,14 +31,34 @@ impl RateWindow { } } -/// A rule identity. OSS rules target a specific agent or all agents (empty -/// identity list = "any"). `Other` covers every non-agent identity row a -/// permissive API client might have stored (user/group are OneCLI Cloud -/// capabilities) — it NEVER matches, so such a row narrows to nothing -/// instead of silently widening to "any" (fail-closed). +/// Which scope a decoded rule came from. Drives `MatchedRule.scope` (telemetry +/// attribution) and the org-first tie-break in the two-level evaluator. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum RuleScope { + Organization, + Project, +} + +impl RuleScope { + pub(super) fn as_str(self) -> &'static str { + match self { + RuleScope::Organization => "organization", + RuleScope::Project => "project", + } + } +} + +/// A rule identity (empty identity list = "any"). `Agent` matches the acting +/// agent by id; the directory kinds (`User`/`Group`) match against the +/// connection's resolved `PrincipalSet`. `Other` covers a row naming NO +/// principal the OSS engine understands (malformed, or a future kind) — it +/// NEVER matches, so such a row narrows to nothing instead of silently +/// widening to "any" (fail-closed). #[derive(Debug, Clone)] pub(super) enum Identity { Agent(String), + User(String), + Group(String), Other, } @@ -73,11 +95,12 @@ pub(super) enum Target { Unresolved, } -/// A decoded project rule the evaluator walks. No `scope` field — everything -/// here is project scope (`MatchedRule.scope` is the constant "project"). +/// A decoded rule the evaluator walks, tagged with the scope it came from. #[derive(Debug, Clone)] pub(super) struct Rule { pub id: String, + /// The level (org guardrail vs project) this rule decides for. + pub scope: RuleScope, /// Generation-stable identity — the shared rate counter keys on it, so the /// count survives republishes. pub logical_id: String, @@ -115,14 +138,14 @@ pub(super) struct Request { impl Request { /// The deny-default carve: only credentialed, non-LLM traffic can be - /// blocked by the Default Rule. Mirrors `forward.rs`'s `enforce_deny`. + /// blocked by a Default Rule. Mirrors `forward.rs`'s `enforce_deny`. pub(super) fn enforce_deny(&self) -> bool { self.has_injections && !self.is_llm_host } } -/// The winning outcome of an evaluation: an explicit matching rule, the -/// project Default Rule's enforced Block (carrying THAT rule, so telemetry can +/// The winning outcome of an evaluation: an explicit matching rule, a level's +/// Default Rule's enforced Block (carrying THAT rule, so telemetry can /// attribute it — always concrete, never anonymous), or a plain allow. pub(super) enum Outcome<'a> { Rule(&'a Rule), From 21c248cc950c8d34d1c47390b985859dfb670d6b Mon Sep 17 00:00:00 2001 From: marcorivm Date: Wed, 29 Jul 2026 20:56:01 -0600 Subject: [PATCH 05/10] feat(gateway): re-land body/header condition matching onto 1.44.0 Reconciliation Stage G. condition_match.rs swaps its OSS no-op for the real matcher (memchr body contains/equals/regex, size-limited regex cache, case-insensitive header ops, 256 KiB cap); MatchInput/BodyCapture and the body buffer re-thread onto upstream's rewritten forward.rs, and Stage F's two-level evaluation carries conditions at both org and project scope for free. Conditions are honored on connection, whole-app, secret, and network targets (the two ignored-target bugs from the original Tier 3a are fixed here too). Fail-closed by action at both scopes: any unevaluable condition makes a Block match and every other rule fall through. Streaming stays the default; buffered bodies reach upstream byte-identical. +22 tests, no agent-group, no migration. --- apps/gateway/Cargo.lock | 1 + apps/gateway/Cargo.toml | 4 + apps/gateway/src/condition_match.rs | 696 +++++++++++++++++- apps/gateway/src/gateway/forward.rs | 70 +- apps/gateway/src/gateway/websocket.rs | 13 +- apps/gateway/src/main.rs | 9 +- apps/gateway/src/policy.rs | 173 ++++- apps/gateway/src/policy_engine/catalog.rs | 157 +++- apps/gateway/src/policy_engine/enforce.rs | 112 ++- apps/gateway/src/policy_engine/evaluate.rs | 627 ++++++++++++++-- apps/gateway/src/policy_engine/types.rs | 7 +- apps/web/src/lib/api/types.ts | 7 +- .../src/lib/components/condition-builder.tsx | 17 - .../condition-builder/condition-builder.tsx | 86 +++ .../condition-builder/condition-row.tsx | 148 ++++ .../lib/components/condition-builder/index.ts | 3 + .../components/condition-builder/validate.ts | 38 + .../lib/policy-editor/how-rules-evaluated.tsx | 5 + .../lib/policy-editor/policy-rule-form.tsx | 39 +- .../api/src/validations/condition-syntax.ts | 38 + .../api/src/validations/policy-rule.test.ts | 166 +++++ packages/api/src/validations/policy-rule.ts | 63 +- packages/api/src/validations/policy.test.ts | 24 + 23 files changed, 2292 insertions(+), 211 deletions(-) delete mode 100644 apps/web/src/lib/components/condition-builder.tsx create mode 100644 apps/web/src/lib/components/condition-builder/condition-builder.tsx create mode 100644 apps/web/src/lib/components/condition-builder/condition-row.tsx create mode 100644 apps/web/src/lib/components/condition-builder/index.ts create mode 100644 apps/web/src/lib/components/condition-builder/validate.ts create mode 100644 packages/api/src/validations/condition-syntax.ts create mode 100644 packages/api/src/validations/policy-rule.test.ts diff --git a/apps/gateway/Cargo.lock b/apps/gateway/Cargo.lock index 6e62c693..ea04a10f 100644 --- a/apps/gateway/Cargo.lock +++ b/apps/gateway/Cargo.lock @@ -2355,6 +2355,7 @@ dependencies = [ "hyper 1.8.1", "hyper-util", "jsonwebtoken", + "memchr", "percent-encoding", "rcgen", "redis", diff --git a/apps/gateway/Cargo.toml b/apps/gateway/Cargo.toml index ba91eeb1..14fdcf0d 100644 --- a/apps/gateway/Cargo.toml +++ b/apps/gateway/Cargo.toml @@ -59,6 +59,10 @@ dashmap = "6" # catastrophic backtracking (no ReDoS). Compiled patterns are cached in a DashMap. regex = "1" +# Linear-time byte-substring search for body condition matching (attacker- +# controlled request bodies). Already in the tree via `regex`. +memchr = "2" + # Base64 (for gateway auth) base64 = "0.22" diff --git a/apps/gateway/src/condition_match.rs b/apps/gateway/src/condition_match.rs index 7b91098d..eea3b98d 100644 --- a/apps/gateway/src/condition_match.rs +++ b/apps/gateway/src/condition_match.rs @@ -1,14 +1,698 @@ -use crate::policy::PolicyRule; +//! OSS body/header condition matching (Tier 3a). +//! +//! A rule's `conditions` JSON (validated server-side as `RuleCondition[]`) +//! further narrows when the rule applies: every condition must hold (AND — +//! exactly like `method` + `path_pattern` already AND together). Two targets: +//! +//! - `body`: a raw byte-level match over the fully buffered request body +//! (`contains` / `equals` / `regex` via `regex::bytes` — linear-time, no +//! ReDoS, no lossy UTF-8 conversion so binary bodies can't dodge a needle). +//! - `header`: matched against the request headers. Header NAMES are +//! case-insensitive (RFC 9110, free with `HeaderMap`); header VALUES are +//! compared case-sensitively on raw bytes (`(?i)` regex serves the +//! case-insensitive cases); any value of a multi-value header satisfies the +//! condition. `exists` (header-only) needs at least one value present. +//! +//! ## Failure law (SECURITY) +//! +//! A condition that cannot be evaluated — malformed JSON, unknown +//! target/operator, missing required value/key, an uncompilable regex, or a +//! body that exceeded the buffer cap — must never weaken enforcement: +//! the rule MATCHES if it is a Block rule (over-block, fail-closed) and does +//! NOT match otherwise (an Allow-family rule falls through to the next rule / +//! the Default Rule instead of silently widening). The v2 engine routes its +//! rules through here via pseudo-rules that carry the owning rule's Block +//! action for exactly this reason (see `policy_engine/evaluate.rs`). +//! +//! A rule's `conditions` may also be a JSON OBJECT — a connection target's +//! granular session policy (`{repositories: […]}` / `{folders: […]}`), not a +//! behavioral condition. Those are vacuous here (Tier 3b's `granular_access` +//! concern), matching the server-side `isSessionPolicy` discriminator. -/// OSS stub: conditions match vacuously (no body inspection). -pub(crate) fn matches(_rule: &PolicyRule, _body: Option<&[u8]>) -> bool { - true +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; + +use hyper::body::Bytes; +use hyper::header::{HeaderName, HeaderValue}; +use tracing::{debug, warn}; + +use crate::policy::{BodyCapture, MatchInput, PolicyAction, PolicyRule}; + +/// Maximum request body buffered for condition matching. A larger body is +/// forwarded intact but becomes unevaluable for body conditions (→ the +/// failure law: Block rules over-block, Allow rules fall through). No +/// prefix-only matching — an attacker could push the needle past any prefix. +pub(crate) const CONDITION_BODY_CAP: usize = 256 * 1024; + +/// One decoded behavioral condition (the server-validated `RuleCondition` +/// shape). Unknown FIELDS fail to decode (`deny_unknown_fields`) and unknown +/// target/operator VALUES decode but evaluate to `Invalid` — both route +/// through the fail-closed law, so a NEWER authoring surface (say, a future +/// `negate` flag) can never silently widen an older gateway. +#[derive(Debug, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct RuleCondition { + target: String, + operator: String, + #[serde(default)] + value: Option, + #[serde(default)] + key: Option, +} + +/// Three-state condition evaluation. `Invalid` = unevaluable, routed through +/// the failure law. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CondEval { + Match, + NoMatch, + Invalid, +} + +/// The decoded shape of a rule's `conditions` JSON. +enum DecodedConditions { + /// None / session-policy object / empty array → no behavioral conditions. + Vacuous, + /// A behavioral array; each element decoded independently so one malformed + /// element poisons only itself (→ `Invalid`), not its siblings. + Behavioral(Vec>), +} + +fn decode_conditions(raw: &Option) -> DecodedConditions { + match raw { + None => DecodedConditions::Vacuous, + // An object is a connection target's granular session policy + // (`repositories`/`folders`) — scoping, not a behavioral condition. + Some(serde_json::Value::Object(_)) => DecodedConditions::Vacuous, + Some(serde_json::Value::Array(items)) if items.is_empty() => DecodedConditions::Vacuous, + Some(serde_json::Value::Array(items)) => DecodedConditions::Behavioral( + items + .iter() + .map(|item| serde_json::from_value::(item.clone()).map_err(|_| ())) + .collect(), + ), + // Any other JSON shape is malformed → one unevaluable condition. + Some(_) => DecodedConditions::Behavioral(vec![Err(())]), + } +} + +/// Whether a rule's `conditions` JSON contains at least one BODY condition — +/// the buffering predicate's core. Header-only conditions never buffer +/// (headers are always available). Elements that fail to decode do NOT count: +/// they evaluate to `Invalid` regardless of body content, so the body is +/// never needed to decide them. +pub(crate) fn has_body_condition(raw: &Option) -> bool { + match decode_conditions(raw) { + DecodedConditions::Vacuous => false, + DecodedConditions::Behavioral(conds) => conds + .iter() + .any(|c| matches!(c, Ok(cond) if cond.target == "body")), + } +} + +/// True iff any rule carries a body condition. Header conditions do not trigger +/// buffering. Kept for API symmetry and unit tests; the v2 forward path uses +/// the host-scoped `policy_engine::needs_body_buffer` instead. +#[allow(dead_code)] +pub(crate) fn needs_body_buffer(rules: &[PolicyRule]) -> bool { + rules.iter().any(|r| has_body_condition(&r.conditions_raw)) +} + +// ── Evaluation ────────────────────────────────────────────────────────── + +/// Byte-substring search (an empty needle matches anything). Linear-time +/// (`memchr::memmem`) — the haystack is an attacker-controlled request body, +/// so a naive O(haystack × needle) scan would be a cheap CPU-DoS amplifier. +fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool { + memchr::memmem::find(haystack, needle).is_some() +} + +/// Compiled-program cap per pattern (1 MiB — ample for the API's 1000-char +/// patterns). The crate default is 10 MiB, which would let a rule author pin +/// gigabytes of compiled programs in the process-wide cache via nested +/// repetitions; an over-limit pattern fails to compile and routes through the +/// existing `Invalid` fail-closed path. +const REGEX_SIZE_LIMIT: usize = 1 << 20; + +fn compile_regex(pattern: &str) -> Option { + regex::bytes::RegexBuilder::new(pattern) + .size_limit(REGEX_SIZE_LIMIT) + .build() + .ok() +} + +/// Compile (or fetch) a `regex::bytes` pattern through a bounded process-wide +/// cache; `None` caches a compile failure so a broken pattern doesn't +/// recompile per request. On cache overflow, compile uncached (correctness +/// identical, just slower). +fn compiled_regex(pattern: &str) -> Option { + static CACHE: OnceLock>>> = OnceLock::new(); + const CACHE_CAP: usize = 256; + let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new())); + if let Ok(mut map) = cache.lock() { + if let Some(cached) = map.get(pattern) { + return cached.clone(); + } + let compiled = compile_regex(pattern); + if map.len() < CACHE_CAP { + map.insert(pattern.to_string(), compiled.clone()); + } + return compiled; + } + compile_regex(pattern) +} + +/// Apply a value operator (`contains`/`equals`/`regex`) over raw bytes. +fn eval_operator(operator: &str, haystack: &[u8], value: &str) -> CondEval { + match operator { + "contains" => { + if contains_bytes(haystack, value.as_bytes()) { + CondEval::Match + } else { + CondEval::NoMatch + } + } + "equals" => { + if haystack == value.as_bytes() { + CondEval::Match + } else { + CondEval::NoMatch + } + } + "regex" => match compiled_regex(value) { + Some(re) if re.is_match(haystack) => CondEval::Match, + Some(_) => CondEval::NoMatch, + None => CondEval::Invalid, + }, + _ => CondEval::Invalid, + } +} + +fn eval_body_condition(cond: &RuleCondition, input: &MatchInput<'_>) -> CondEval { + // `exists` is header-only ("has a body" is not a meaningful policy). + if cond.operator == "exists" { + return CondEval::Invalid; + } + // Over-cap body: unevaluable (never a prefix match — see the module doc). + if input.body_truncated { + return CondEval::Invalid; + } + let Some(value) = cond.value.as_deref() else { + return CondEval::Invalid; + }; + // Absent body is a FACT, not a failure: `needs_body_buffer` is a superset + // of "a body condition could be consulted", so `None` here genuinely means + // the request had no body (GETs, WS upgrades) → match against empty. + let body = input.body.unwrap_or(&[]); + eval_operator(&cond.operator, body, value) +} + +fn eval_header_condition(cond: &RuleCondition, input: &MatchInput<'_>) -> CondEval { + let Some(key) = cond.key.as_deref().filter(|k| !k.trim().is_empty()) else { + return CondEval::Invalid; + }; + // Header-name lookup is case-insensitive via HeaderMap; a name that isn't + // a valid header name can never have been sent → unevaluable. + let Ok(name) = HeaderName::from_bytes(key.as_bytes()) else { + return CondEval::Invalid; + }; + let values: Vec<&HeaderValue> = match input.headers { + Some(headers) => headers.get_all(&name).iter().collect(), + None => Vec::new(), + }; + if cond.operator == "exists" { + return if values.is_empty() { + CondEval::NoMatch + } else { + CondEval::Match + }; + } + let Some(value) = cond.value.as_deref() else { + return CondEval::Invalid; + }; + // Any value of a multi-value header satisfies the condition; values are + // compared case-sensitively on raw bytes (`(?i)` regex for insensitive). + let mut result = CondEval::NoMatch; + for v in values { + match eval_operator(&cond.operator, v.as_bytes(), value) { + CondEval::Match => return CondEval::Match, + CondEval::Invalid => return CondEval::Invalid, + CondEval::NoMatch => result = CondEval::NoMatch, + } + } + result +} + +fn eval_condition(cond: &RuleCondition, input: &MatchInput<'_>) -> CondEval { + match cond.target.as_str() { + // `key` on a body condition is accepted-but-ignored (reserved; a + // JSON-path narrowing could use it later without breaking anything). + "body" => eval_body_condition(cond, input), + "header" => eval_header_condition(cond, input), + _ => CondEval::Invalid, + } +} + +/// Warn ONCE per rule name that a condition is unevaluable (a stored broken +/// rule would otherwise log per request — per pseudo-rule variant on tool +/// fan-outs — and flood a busy host); repeats land at `debug!`. The seen-set +/// is bounded: past the cap, new names also log at debug (never unbounded +/// memory for log bookkeeping). +fn log_unevaluable(rule_name: &str, is_block: bool) { + use std::collections::HashSet; + static SEEN: OnceLock>> = OnceLock::new(); + const SEEN_CAP: usize = 1024; + let first = SEEN + .get_or_init(|| Mutex::new(HashSet::new())) + .lock() + .map(|mut seen| { + !seen.contains(rule_name) && seen.len() < SEEN_CAP && seen.insert(rule_name.to_string()) + }) + .unwrap_or(true); + let outcome = if is_block { + "failing closed (rule matches)" + } else { + "rule falls through" + }; + if first { + warn!(rule = %rule_name, is_block, "policy: unevaluable rule condition — {outcome}"); + } else { + debug!(rule = %rule_name, is_block, "policy: unevaluable rule condition — {outcome}"); + } +} + +/// Does the rule's condition set hold for this request? Vacuously true without +/// behavioral conditions; else ALL conditions must match (AND). Any +/// unevaluable condition applies the failure law: the rule matches iff it is +/// a Block rule (see the module doc). +pub(crate) fn matches(rule: &PolicyRule, input: &MatchInput<'_>) -> bool { + let conds = match decode_conditions(&rule.conditions_raw) { + DecodedConditions::Vacuous => return true, + DecodedConditions::Behavioral(conds) => conds, + }; + let mut all_match = true; + for cond in &conds { + let eval = match cond { + Ok(cond) => eval_condition(cond, input), + Err(()) => CondEval::Invalid, + }; + match eval { + CondEval::Match => {} + CondEval::NoMatch => all_match = false, + CondEval::Invalid => { + let is_block = matches!(rule.action, PolicyAction::Block); + log_unevaluable(&rule.name, is_block); + return is_block; + } + } + } + all_match +} + +// ── Body buffering ────────────────────────────────────────────────────── + +/// What `buffer_up_to` produced: either the complete body, or the buffered +/// prefix plus the UNREAD remainder of the stream. +enum BufferOutcome { + /// The body ended within the cap — these are ALL its bytes. + Complete(Vec), + /// The cap was exceeded: the prefix read so far (cap+ε — frame-granular) + /// and the rest of the body, still unread. + Exceeded(Vec, B), +} + +/// Accumulate DATA frames until the body ends or the cap is exceeded. +/// Trailers are dropped when the body completes within the cap — the same +/// pre-existing posture as the fully-buffered default-interception branch in +/// forward.rs (HTTP/1 chunked trailers are vanishingly rare on API traffic). +async fn buffer_up_to(mut body: B, cap: usize) -> anyhow::Result> +where + B: hyper::body::Body + Unpin, + B::Error: std::error::Error + Send + Sync + 'static, +{ + use http_body_util::BodyExt; + let mut buffered: Vec = Vec::new(); + while let Some(frame) = body.frame().await { + let frame = frame.map_err(anyhow::Error::new)?; + if let Ok(data) = frame.into_data() { + buffered.extend_from_slice(&data); + if buffered.len() > cap { + return Ok(BufferOutcome::Exceeded(buffered, body)); + } + } + } + Ok(BufferOutcome::Complete(buffered)) +} + +/// The forward stream for an over-cap body: the buffered prefix first, then +/// the remaining frames relayed one by one (never collected — the tail may be +/// arbitrarily large), so the upstream receives EXACTLY the original bytes. +fn forward_stream( + prefix: Vec, + rest: B, +) -> impl futures_util::Stream> +where + B: hyper::body::Body + Unpin, + B::Error: std::error::Error + Send + Sync + 'static, +{ + use futures_util::{StreamExt, TryStreamExt}; + let head = futures_util::stream::iter(std::iter::once(Ok(Bytes::from(prefix)))); + let tail = + http_body_util::BodyDataStream::new(rest).map_err(|e| std::io::Error::other(e.to_string())); + head.chain(tail) } +/// Buffer the request body for condition matching and rebuild the forwarding +/// body. MITM correctness: the upstream always receives the original bytes — +/// a within-cap body forwards the exact buffered bytes; an over-cap body +/// forwards the buffered prefix chained with the untouched remaining stream +/// (and captures `Truncated`, which the matcher treats as unevaluable). pub(crate) async fn prepare_body( body: hyper::body::Incoming, _method: &str, _url: &str, -) -> anyhow::Result<(Option>, reqwest::Body)> { - Ok((None, reqwest::Body::wrap(body))) +) -> anyhow::Result<(BodyCapture, reqwest::Body)> { + match buffer_up_to(body, CONDITION_BODY_CAP).await? { + BufferOutcome::Complete(bytes) => { + let fwd = reqwest::Body::from(bytes.clone()); + Ok((BodyCapture::Full(bytes), fwd)) + } + BufferOutcome::Exceeded(prefix, rest) => { + let fwd = reqwest::Body::wrap_stream(forward_stream(prefix.clone(), rest)); + Ok((BodyCapture::Truncated(prefix), fwd)) + } + } +} + +// ── Tests ─────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn rule(action: PolicyAction, conditions: &str) -> PolicyRule { + PolicyRule { + name: "Conditioned rule".to_string(), + path_pattern: "*".to_string(), + method: None, + action, + conditions_raw: Some(serde_json::from_str(conditions).expect("conditions JSON")), + } + } + + fn block(conditions: &str) -> PolicyRule { + rule(PolicyAction::Block, conditions) + } + + fn allow(conditions: &str) -> PolicyRule { + rule(PolicyAction::Allow, conditions) + } + + fn with_body(body: &[u8]) -> MatchInput<'_> { + MatchInput { + body: Some(body), + body_truncated: false, + headers: None, + } + } + + fn headers(pairs: &[(&str, &str)]) -> hyper::HeaderMap { + let mut map = hyper::HeaderMap::new(); + for (name, value) in pairs { + map.append( + HeaderName::from_bytes(name.as_bytes()).expect("header name"), + HeaderValue::from_str(value).expect("header value"), + ); + } + map + } + + fn with_headers(map: &hyper::HeaderMap) -> MatchInput<'_> { + MatchInput { + body: None, + body_truncated: false, + headers: Some(map), + } + } + + // ── Decode + vacuous shapes ───────────────────────────────────────── + + #[test] + fn no_conditions_is_vacuous() { + let mut none = block(r#"[]"#); + none.conditions_raw = None; + let empty = block(r#"[]"#); + // A session-policy OBJECT is granular scoping, not behavioral — must + // stay vacuous or every granular allow rule would stop matching. + let session = block(r#"{"repositories":["owner/repo"]}"#); + for r in [&none, &empty, &session] { + assert!(matches(r, &MatchInput::empty())); + assert!(!needs_body_buffer(std::slice::from_ref(r))); + } + } + + // ── Body operators ────────────────────────────────────────────────── + + #[test] + fn body_contains_matches_and_falls_through() { + let r = block(r#"[{"target":"body","operator":"contains","value":"needle"}]"#); + assert!(matches(&r, &with_body(b"hay needle stack"))); + assert!(!matches(&r, &with_body(b"just hay"))); + } + + #[test] + fn body_equals_and_regex_match() { + let eq = block(r#"[{"target":"body","operator":"equals","value":"exact"}]"#); + assert!(matches(&eq, &with_body(b"exact"))); + assert!(!matches(&eq, &with_body(b"exact-not"))); + + let re = allow(r#"[{"target":"body","operator":"regex","value":"(?i)delete\\s+repo"}]"#); + assert!(matches(&re, &with_body(b"please DELETE repo now"))); + assert!(!matches(&re, &with_body(b"read repo"))); + + // Raw-byte matching: a needle inside a binary body still matches. + let bin = block(r#"[{"target":"body","operator":"contains","value":"secret"}]"#); + let mut body = vec![0xFF, 0xFE, 0x00]; + body.extend_from_slice(b"secret"); + body.push(0x80); + assert!(matches(&bin, &with_body(&body))); + } + + #[test] + fn conditions_are_anded() { + let r = block( + r#"[{"target":"body","operator":"contains","value":"alpha"}, + {"target":"body","operator":"contains","value":"beta"}]"#, + ); + assert!(matches(&r, &with_body(b"alpha and beta"))); + assert!(!matches(&r, &with_body(b"alpha only"))); + } + + // ── Header conditions ─────────────────────────────────────────────── + + #[test] + fn header_name_lookup_is_case_insensitive() { + let r = block(r#"[{"target":"header","operator":"equals","key":"X-Foo","value":"bar"}]"#); + let map = headers(&[("x-foo", "bar")]); + assert!(matches(&r, &with_headers(&map))); + } + + #[test] + fn header_operators() { + let map = headers(&[("x-multi", "first"), ("x-multi", "second-value")]); + + let eq = block( + r#"[{"target":"header","operator":"equals","key":"x-multi","value":"second-value"}]"#, + ); + assert!(matches(&eq, &with_headers(&map)), "any value satisfies"); + + let contains = + block(r#"[{"target":"header","operator":"contains","key":"x-multi","value":"econd"}]"#); + assert!(matches(&contains, &with_headers(&map))); + + let re = + block(r#"[{"target":"header","operator":"regex","key":"x-multi","value":"^SECOND"}]"#); + // Values are case-SENSITIVE: an uppercase anchor misses… + assert!(!matches(&re, &with_headers(&map))); + // …and `(?i)` opts in to case-insensitive. + let re_i = block( + r#"[{"target":"header","operator":"regex","key":"x-multi","value":"(?i)^SECOND"}]"#, + ); + assert!(matches(&re_i, &with_headers(&map))); + + let exists = block(r#"[{"target":"header","operator":"exists","key":"x-multi"}]"#); + assert!(matches(&exists, &with_headers(&map))); + + // Missing header → NoMatch for every operator, exists included (an + // ALLOW falls through AND a BLOCK falls through — absence is a fact). + let missing_eq = + block(r#"[{"target":"header","operator":"equals","key":"x-gone","value":"v"}]"#); + assert!(!matches(&missing_eq, &with_headers(&map))); + let missing_exists = block(r#"[{"target":"header","operator":"exists","key":"x-gone"}]"#); + assert!(!matches(&missing_exists, &with_headers(&map))); + // No headers at all behaves like the header being absent. + assert!(!matches(&missing_exists, &MatchInput::empty())); + } + + // ── Decision I: absent body is a fact, not a failure ──────────────── + + #[test] + fn absent_body_is_empty_not_invalid() { + let r = block(r#"[{"target":"body","operator":"contains","value":"needle"}]"#); + // Even a Block rule falls through: `needs_body_buffer` guarantees a + // body-conditioned rule only ever sees `None` when there WAS no body. + assert!(!matches(&r, &MatchInput::empty())); + // But an operator satisfied by the empty body still matches. + let empty_ok = block(r#"[{"target":"body","operator":"regex","value":"^$"}]"#); + assert!(matches(&empty_ok, &MatchInput::empty())); + } + + // ── Failure law (Decisions H + J) ─────────────────────────────────── + + #[test] + fn truncated_body_fails_closed_for_block_and_open_for_allow() { + let cond = r#"[{"target":"body","operator":"contains","value":"needle"}]"#; + let truncated = MatchInput { + body: None, + body_truncated: true, + headers: None, + }; + assert!(matches(&block(cond), &truncated), "Block must over-block"); + assert!( + !matches(&allow(cond), &truncated), + "Allow must fall through" + ); + let approval = rule( + PolicyAction::ManualApproval { + rule_id: "r1".to_string(), + }, + cond, + ); + assert!(!matches(&approval, &truncated)); + let rate = rule( + PolicyAction::RateLimit { + rule_id: "r1".to_string(), + max_requests: 5, + window_secs: 60, + }, + cond, + ); + assert!(!matches(&rate, &truncated)); + } + + #[test] + fn malformed_condition_json_fails_closed_by_action() { + for cond in [ + r#"[42]"#, // garbage element + r#"[{"target":"body","operator":"telepathy","value":"x"}]"#, // unknown operator + r#"[{"target":"cookies","operator":"contains","value":"x"}]"#, // unknown target + r#"[{"target":"body","operator":"contains"}]"#, // missing value + r#"[{"target":"header","operator":"equals","value":"x"}]"#, // header w/o key + r#"[{"target":"header","operator":"equals","key":"bad name","value":"x"}]"#, + r#"[{"target":"body","operator":"exists"}]"#, // exists on body + // Unknown field: a future narrowing/inverting flag (e.g. `negate`) + // must fail decode, not silently drop and widen matching. + r#"[{"target":"body","operator":"contains","value":"x","negate":true}]"#, + r#""nonsense""#, // non-array/object + ] { + assert!(matches(&block(cond), &with_body(b"body")), "{cond}"); + assert!(!matches(&allow(cond), &with_body(b"body")), "{cond}"); + } + } + + #[test] + fn uncompilable_regex_fails_closed_for_block() { + // The headline security case: a Block whose regex Rust rejects (JS + // lookbehind) must BLOCK, never silently fall through. + let cond = r#"[{"target":"body","operator":"regex","value":"(?<=x)y["}]"#; + assert!(matches(&block(cond), &with_body(b"anything"))); + assert!(!matches(&allow(cond), &with_body(b"anything"))); + } + + #[test] + fn oversized_regex_program_fails_closed() { + // Nested repetitions can approach the compiler's size limit; capping + // it at `REGEX_SIZE_LIMIT` (instead of the 10 MiB default) keeps a + // rule author from pinning gigabytes of compiled programs in the + // process-wide cache. Over-limit patterns fail to compile → the + // Invalid fail-closed path. + assert!(compile_regex("(?:x{1000}){1000}").is_none(), "over the cap"); + assert!(compile_regex("(?i)delete\\s+repo").is_some(), "normal"); + let cond = r#"[{"target":"body","operator":"regex","value":"(?:x{1000}){1000}"}]"#; + assert!(matches(&block(cond), &with_body(b"x"))); + assert!(!matches(&allow(cond), &with_body(b"x"))); + } + + // ── Buffering predicate ───────────────────────────────────────────── + + #[test] + fn needs_body_buffer_only_for_body_conditions() { + let header_only = block(r#"[{"target":"header","operator":"exists","key":"x-api-key"}]"#); + assert!(!needs_body_buffer(&[header_only])); + let body = block(r#"[{"target":"body","operator":"contains","value":"x"}]"#); + assert!(needs_body_buffer(&[body])); + let unconditioned = PolicyRule { + name: "plain".to_string(), + path_pattern: "*".to_string(), + method: None, + action: PolicyAction::Block, + conditions_raw: None, + }; + assert!(!needs_body_buffer(&[unconditioned])); + assert!(!needs_body_buffer(&[])); + } + + // ── prepare_body / buffer_up_to (MITM correctness) ────────────────── + + #[tokio::test] + async fn buffer_with_cap_returns_exact_bytes_and_forwards_them_intact() { + use http_body_util::Full; + let payload = b"{\"content\":\"hello world\"}".to_vec(); + let body = Full::new(Bytes::from(payload.clone())); + let BufferOutcome::Complete(captured) = buffer_up_to(body, 1024).await.expect("buffer") + else { + panic!("within-cap body must buffer completely"); + }; + assert_eq!(captured, payload, "capture must be byte-identical"); + // The forwarded body is rebuilt from the same bytes (prepare_body's + // Complete arm): byte-identical to the original. + let fwd = reqwest::Body::from(captured.clone()); + assert_eq!(fwd.as_bytes(), Some(payload.as_slice())); + } + + #[tokio::test] + async fn buffer_with_cap_truncates_over_cap_and_still_forwards_everything() { + use futures_util::TryStreamExt; + use http_body_util::StreamBody; + use hyper::body::Frame; + + // Three frames, 30 bytes total, cap 10 → the capture truncates after + // the frame that crosses the cap; the upstream must still receive all + // 30 original bytes in order. + let frames: Vec, std::convert::Infallible>> = vec![ + Ok(Frame::data(Bytes::from_static(b"0123456789"))), + Ok(Frame::data(Bytes::from_static(b"abcdefghij"))), + Ok(Frame::data(Bytes::from_static(b"ABCDEFGHIJ"))), + ]; + let body = StreamBody::new(futures_util::stream::iter(frames)); + let BufferOutcome::Exceeded(prefix, rest) = buffer_up_to(body, 10).await.expect("buffer") + else { + panic!("over-cap body must report Exceeded"); + }; + assert_eq!(prefix, b"0123456789abcdefghij".to_vec(), "cap+ε prefix"); + + // Draining the reconstructed forward stream yields the FULL original + // byte sequence — nothing lost, nothing reordered. + let forwarded: Vec = forward_stream(prefix.clone(), rest) + .try_collect::>() + .await + .expect("drain forward stream") + .concat(); + assert_eq!(forwarded, b"0123456789abcdefghijABCDEFGHIJ".to_vec()); + + // And the capture is opaque to matching (only peekable). + let capture = BodyCapture::Truncated(prefix.clone()); + assert_eq!(capture.bytes(), Some(prefix.as_slice())); + assert_eq!(capture.bytes_for_matching(), None); + } } diff --git a/apps/gateway/src/gateway/forward.rs b/apps/gateway/src/gateway/forward.rs index 9e6631ad..06f36961 100644 --- a/apps/gateway/src/gateway/forward.rs +++ b/apps/gateway/src/gateway/forward.rs @@ -21,7 +21,7 @@ use crate::apps; use crate::cache::CacheStore; use crate::default_interceptions; use crate::inject; -use crate::policy::{self, PolicyDecision}; +use crate::policy::{self, BodyCapture, MatchInput, PolicyDecision}; use crate::policy_engine; use super::hooks; @@ -162,36 +162,38 @@ pub(crate) async fn forward_request( default_interceptions::match_target(super::strip_port(host), &path, &method) .filter(|_| content_length_at_most(req.headers(), MAX_DEFAULT_INTERCEPT_BODY)); - // Buffer the request body for condition matching, when the request guard needs - // to inspect it (e.g. Dropbox folder scoping reads the JSON body), or for a - // matched default interception. In OSS, both predicates return false → zero - // overhead unless a default interception matched. - let (condition_buffer, req) = if crate::policy_engine::needs_body_buffer(&rules.policy_rules_v2) - || hooks::needs_request_body(rules, host, method.as_str(), &path) - { - let (parts, incoming) = req.into_parts(); - let (buf, fwd_body) = - crate::condition_match::prepare_body(incoming, method.as_str(), &url).await?; - (buf, hyper::Request::from_parts(parts, fwd_body)) - } else if default_target.is_some() { - // OSS-safe: fully buffer the known-small body, keeping the bytes for both - // the interception check and (if it declines) forwarding. - let (parts, incoming) = req.into_parts(); - let bytes = incoming - .collect() - .await - .context("buffering request body for default interception")? - .to_bytes(); - let req = hyper::Request::from_parts(parts, reqwest::Body::from(bytes.clone())); - (Some(bytes.to_vec()), req) - } else { - (None, req.map(reqwest::Body::wrap)) - }; + // Buffer the request body for condition matching, when a body-conditioned + // rule could govern this host (`needs_body_buffer` is host-scoped), when the + // request guard needs to inspect it (e.g. Dropbox folder scoping reads the + // JSON body), or for a matched default interception. Unconditioned traffic + // keeps streaming → zero overhead. + let (capture, req) = + if crate::policy_engine::needs_body_buffer(&rules.policy_rules_v2, policy_host) + || hooks::needs_request_body(rules, host, method.as_str(), &path) + { + let (parts, incoming) = req.into_parts(); + let (capture, fwd_body) = + crate::condition_match::prepare_body(incoming, method.as_str(), &url).await?; + (capture, hyper::Request::from_parts(parts, fwd_body)) + } else if default_target.is_some() { + // OSS-safe: fully buffer the known-small body, keeping the bytes for both + // the interception check and (if it declines) forwarding. + let (parts, incoming) = req.into_parts(); + let bytes = incoming + .collect() + .await + .context("buffering request body for default interception")? + .to_bytes(); + let req = hyper::Request::from_parts(parts, reqwest::Body::from(bytes.clone())); + (BodyCapture::Full(bytes.to_vec()), req) + } else { + (BodyCapture::None, req.map(reqwest::Body::wrap)) + }; // Answer a matched default interception before any forwarding. A handler that // declines (e.g. a real refresh token) falls through to normal forwarding. if let Some(target) = default_target { - if let Some(synth) = target.handle(condition_buffer.as_deref().unwrap_or(&[])) { + if let Some(synth) = target.handle(capture.bytes().unwrap_or(&[])) { info!(method = %method, url = %url, "default interception — serving synthetic response"); return Ok(response::json(synth.status, synth.body)); } @@ -218,6 +220,12 @@ pub(crate) async fn forward_request( )); } + // The per-request condition input: the captured body (only a FULL capture is + // matchable — a truncated one fails closed) plus the pre-injection request + // headers. Borrows `req`; its last use is the `evaluate` call below, which NLL + // releases before `req.into_parts()` later. + let match_input = MatchInput::from_capture(&capture, req.headers()); + // The first-match engine over the published `policy_rules_v2` is authoritative. // `policy_host` is the pre-rewrite rule-match host; `is_llm_host(host)` is the // effective host for the deny-default carve. @@ -226,7 +234,7 @@ pub(crate) async fn forward_request( policy_host, method.as_str(), &path, - condition_buffer.as_deref(), + &match_input, has_injections, policy::is_llm_host(host), rules.winning_connection_id.as_deref(), @@ -346,7 +354,7 @@ pub(crate) async fn forward_request( method.as_str(), &path, &headers, - condition_buffer.as_deref(), + capture.bytes(), ) .await { @@ -378,8 +386,8 @@ pub(crate) async fn forward_request( // Peek a bounded prefix of the body for the summary + preview, then // build the forwarding body. If condition buffering already captured // the body, reuse that buffer instead of peeking the stream again. - let (summary_bytes, fwd_body): (Cow<'_, [u8]>, reqwest::Body) = if let Some(ref buf) = - condition_buffer + let (summary_bytes, fwd_body): (Cow<'_, [u8]>, reqwest::Body) = if let Some(buf) = + capture.bytes() { // Body already buffered for condition matching — borrow its prefix // for the summary instead of copying it again. diff --git a/apps/gateway/src/gateway/websocket.rs b/apps/gateway/src/gateway/websocket.rs index 23d53cd9..f5add1c4 100644 --- a/apps/gateway/src/gateway/websocket.rs +++ b/apps/gateway/src/gateway/websocket.rs @@ -21,7 +21,7 @@ use tracing::{info, warn}; use crate::cache::CacheStore; use crate::inject; -use crate::policy::{self, PolicyDecision}; +use crate::policy::{self, MatchInput, PolicyDecision}; use super::hooks; use super::mitm::ResolvedRules; @@ -127,6 +127,15 @@ pub(super) async fn handle_websocket( )); } + // A WebSocket upgrade is a GET with no inspectable body: header conditions + // apply to the handshake, and the absent body is a FACT (a body condition + // matches against the empty body, never fails closed on missing bytes). + let match_input = MatchInput { + body: None, + body_truncated: false, + headers: Some(req.headers()), + }; + // The first-match engine over `policy_rules_v2` is authoritative. WebSocket // blocks emit no telemetry today, so the matched rule is not attributed here // (allow-attribution for ws is out of scope) — only the decision is consumed. @@ -135,7 +144,7 @@ pub(super) async fn handle_websocket( policy_host, "GET", &path, - None, + &match_input, has_injections, policy::is_llm_host(host), rules.winning_connection_id.as_deref(), diff --git a/apps/gateway/src/main.rs b/apps/gateway/src/main.rs index d66b9f19..ecb9d9b7 100644 --- a/apps/gateway/src/main.rs +++ b/apps/gateway/src/main.rs @@ -43,10 +43,11 @@ mod org_routes; mod connect; -// Body-condition matcher (step 9.5): the real matcher rides with the full EE -// engine — onprem included, else a v2 `body contains` block rule would never -// see a body there and fail OPEN. The OSS arm stays the no-op (conditions are -// carried but never evaluated in OSS, matching its legacy behavior). +// Body-condition matcher (Tier 3a): the OSS arm evaluates body/header +// conditions byte-level over the buffered request body and headers, at both +// org and project scopes, with the fail-closed-by-action failure law (an +// unevaluable condition over-blocks a Block rule and drops any other). The EE +// build swaps in the cloud overlay via the `#[path]` module below. #[cfg(edition_oss)] mod condition_match; diff --git a/apps/gateway/src/policy.rs b/apps/gateway/src/policy.rs index 5fbe1489..689c1a6c 100644 --- a/apps/gateway/src/policy.rs +++ b/apps/gateway/src/policy.rs @@ -38,6 +38,84 @@ pub(crate) struct PolicyRule { pub conditions_raw: Option, } +/// Everything a rule condition can look at for one request. Built once per +/// request (forward.rs / websocket.rs) and borrowed through the whole +/// evaluation — the shared matcher and the v2 two-level walk. +/// +/// Lives here (not in `condition_match`) because `condition_match` is +/// edition-swapped and the shared call sites need one stable shape. +#[derive(Debug, Clone, Copy)] +pub(crate) struct MatchInput<'a> { + /// The fully buffered request body, when condition buffering captured it. + /// `None` means the request genuinely has no (buffered) body — GETs, + /// WebSocket upgrades, or the streaming path (`needs_body_buffer` is a + /// superset of "some body condition could be consulted", so a + /// body-conditioned rule never sees `None` for a request that had a body). + pub body: Option<&'a [u8]>, + /// The body exceeded the buffer cap: body conditions become unevaluable + /// and fail closed per the condition failure law (`condition_match`). + pub body_truncated: bool, + /// The request headers at evaluation time (pre-injection). + pub headers: Option<&'a hyper::HeaderMap>, +} + +impl<'a> MatchInput<'a> { + /// No body, no headers — for call sites (and tests) with nothing to match + /// conditions against. + #[allow(dead_code)] // production paths build real inputs; tests + EE use this + pub(crate) const fn empty() -> Self { + MatchInput { + body: None, + body_truncated: false, + headers: None, + } + } + + /// The per-request input: what `prepare_body` captured plus the request + /// headers. + pub(crate) fn from_capture(capture: &'a BodyCapture, headers: &'a hyper::HeaderMap) -> Self { + MatchInput { + body: capture.bytes_for_matching(), + body_truncated: matches!(capture, BodyCapture::Truncated(_)), + headers: Some(headers), + } + } +} + +/// What `condition_match::prepare_body` captured of a request body. +#[derive(Debug)] +pub(crate) enum BodyCapture { + /// Nothing buffered (the streaming path). + None, + /// The complete body (within the cap). + Full(Vec), + /// The first cap(+ε) bytes; the rest streams to the upstream untouched. + /// Body conditions never match on a truncated capture — a prefix-only + /// match would let a needle pushed past the cap dodge a Block rule. + Truncated(Vec), +} + +impl BodyCapture { + /// The captured bytes, full or truncated — for consumers that only peek + /// (default interception, approval summaries). + pub(crate) fn bytes(&self) -> Option<&[u8]> { + match self { + BodyCapture::None => None, + BodyCapture::Full(b) | BodyCapture::Truncated(b) => Some(b), + } + } + + /// The bytes condition matching may consult: only a FULL capture. A + /// truncated capture exposes nothing here — matching a prefix is exactly + /// the bypass the failure law refuses. + pub(crate) fn bytes_for_matching(&self) -> Option<&[u8]> { + match self { + BodyCapture::Full(b) => Some(b), + _ => None, + } + } +} + /// The v2 rule that decided a request — recorded into telemetry so Activity /// can say "decided by rule X". `logical_id` is the generation-stable identity /// (row ids regenerate on every publish); the name is a display snapshot; @@ -139,14 +217,14 @@ pub(crate) fn matches_request( rule: &PolicyRule, method: &str, path: &str, - body: Option<&[u8]>, + input: &MatchInput<'_>, ) -> bool { let direct = path_matches(path, &rule.path_pattern) && rule .method .as_ref() .is_none_or(|m| m.eq_ignore_ascii_case(method)) - && crate::condition_match::matches(rule, body); + && crate::condition_match::matches(rule, input); if direct { return true; } @@ -157,7 +235,7 @@ pub(crate) fn matches_request( && method.eq_ignore_ascii_case("GET") && is_git_push_discovery(path) { - return crate::condition_match::matches(rule, body); + return crate::condition_match::matches(rule, input); } false } @@ -191,12 +269,12 @@ pub(crate) fn is_llm_host(host: &str) -> bool { pub(crate) fn is_blocked( request_method: &str, request_path: &str, - request_body: Option<&[u8]>, + input: &MatchInput<'_>, rules: &[PolicyRule], ) -> bool { rules.iter().any(|rule| { matches!(rule.action, PolicyAction::Block) - && matches_request(rule, request_method, request_path, request_body) + && matches_request(rule, request_method, request_path, input) }) } @@ -224,7 +302,7 @@ mod tests { assert!(is_blocked( "POST", "/gmail/v1/users/me/messages/send", - None, + &MatchInput::empty(), &rules )); } @@ -235,7 +313,7 @@ mod tests { assert!(!is_blocked( "GET", "/gmail/v1/users/me/messages/send", - None, + &MatchInput::empty(), &rules )); } @@ -246,7 +324,7 @@ mod tests { assert!(!is_blocked( "POST", "/gmail/v1/users/me/messages", - None, + &MatchInput::empty(), &rules )); } @@ -254,9 +332,24 @@ mod tests { #[test] fn blocks_all_methods_when_none() { let rules = vec![block_rule("/admin/*", None)]; - assert!(is_blocked("GET", "/admin/users", None, &rules)); - assert!(is_blocked("POST", "/admin/users", None, &rules)); - assert!(is_blocked("DELETE", "/admin/settings", None, &rules)); + assert!(is_blocked( + "GET", + "/admin/users", + &MatchInput::empty(), + &rules + )); + assert!(is_blocked( + "POST", + "/admin/users", + &MatchInput::empty(), + &rules + )); + assert!(is_blocked( + "DELETE", + "/admin/settings", + &MatchInput::empty(), + &rules + )); } #[test] @@ -265,36 +358,56 @@ mod tests { assert!(is_blocked( "POST", "/gmail/v1/users/me/messages/send", - None, + &MatchInput::empty(), + &rules + )); + assert!(!is_blocked( + "POST", + "/calendar/v1/events", + &MatchInput::empty(), &rules )); - assert!(!is_blocked("POST", "/calendar/v1/events", None, &rules)); } #[test] fn blocks_all_paths() { let rules = vec![block_rule("*", Some("DELETE"))]; - assert!(is_blocked("DELETE", "/anything", None, &rules)); - assert!(!is_blocked("GET", "/anything", None, &rules)); + assert!(is_blocked( + "DELETE", + "/anything", + &MatchInput::empty(), + &rules + )); + assert!(!is_blocked( + "GET", + "/anything", + &MatchInput::empty(), + &rules + )); } #[test] fn method_matching_is_case_insensitive() { let rules = vec![block_rule("*", Some("POST"))]; - assert!(is_blocked("post", "/path", None, &rules)); - assert!(is_blocked("Post", "/path", None, &rules)); + assert!(is_blocked("post", "/path", &MatchInput::empty(), &rules)); + assert!(is_blocked("Post", "/path", &MatchInput::empty(), &rules)); } #[test] fn no_rules_allows_everything() { - assert!(!is_blocked("POST", "/anything", None, &[])); + assert!(!is_blocked("POST", "/anything", &MatchInput::empty(), &[])); } #[test] fn blocks_with_default_wildcard_path() { let rules = vec![block_rule("*", Some("POST"))]; - assert!(is_blocked("POST", "/any/path/here", None, &rules)); - assert!(is_blocked("POST", "/", None, &rules)); + assert!(is_blocked( + "POST", + "/any/path/here", + &MatchInput::empty(), + &rules + )); + assert!(is_blocked("POST", "/", &MatchInput::empty(), &rules)); } #[test] @@ -303,8 +416,18 @@ mod tests { block_rule("/safe/*", Some("GET")), block_rule("/danger/*", Some("POST")), ]; - assert!(!is_blocked("POST", "/safe/path", None, &rules)); - assert!(is_blocked("POST", "/danger/path", None, &rules)); + assert!(!is_blocked( + "POST", + "/safe/path", + &MatchInput::empty(), + &rules + )); + assert!(is_blocked( + "POST", + "/danger/path", + &MatchInput::empty(), + &rules + )); } // ── Git push discovery tests ──────────────────────────────────── @@ -315,7 +438,7 @@ mod tests { assert!(is_blocked( "GET", "/owner/repo.git/info/refs?service=git-receive-pack", - None, + &MatchInput::empty(), &rules )); } @@ -326,7 +449,7 @@ mod tests { assert!(!is_blocked( "GET", "/owner/repo.git/info/refs?service=git-upload-pack", - None, + &MatchInput::empty(), &rules )); } @@ -337,7 +460,7 @@ mod tests { assert!(is_blocked( "POST", "/owner/repo.git/git-receive-pack", - None, + &MatchInput::empty(), &rules )); } diff --git a/apps/gateway/src/policy_engine/catalog.rs b/apps/gateway/src/policy_engine/catalog.rs index 40685243..0f23755a 100644 --- a/apps/gateway/src/policy_engine/catalog.rs +++ b/apps/gateway/src/policy_engine/catalog.rs @@ -16,7 +16,7 @@ use std::sync::OnceLock; use serde::Deserialize; use crate::connect::host_matches; -use crate::policy::{matches_request, PolicyAction, PolicyRule}; +use crate::policy::{matches_request, MatchInput, PolicyAction, PolicyRule}; /// One tool's endpoint fan-out (camelCase JSON keys). An empty `methods` list /// means "any method". @@ -52,19 +52,28 @@ fn single_host_family(provider_tools: &HashMap) -> bool { } /// A throwaway `policy::PolicyRule` so one path×method variant routes through -/// the gateway's exact `matches_request` (the action is irrelevant to -/// matching). Conditions ride from the owning rule — vacuous in OSS, where the -/// `condition_match` arm is the no-op. +/// the gateway's exact `matches_request`. Conditions ride from the owning +/// rule, and so does its BLOCK-ness: `condition_match`'s failure law is +/// action-aware (an unevaluable condition fails CLOSED only for a Block +/// rule), so hardcoding Allow here would fail a v2 Block open. The owning +/// rule's NAME rides along too, so the matcher's unevaluable-condition +/// warning identifies the broken rule. fn variant_rule( + name: &str, path_pattern: &str, method: Option, conditions: &Option, + is_block: bool, ) -> PolicyRule { PolicyRule { - name: String::new(), + name: name.to_string(), path_pattern: path_pattern.to_string(), method, - action: PolicyAction::Allow, + action: if is_block { + PolicyAction::Block + } else { + PolicyAction::Allow + }, conditions_raw: conditions.clone(), } } @@ -91,23 +100,40 @@ fn variant_rule( /// bleed across sibling services. A truly distinct endpoint host (github /// `raw.githubusercontent.com`, fly.io GraphQL) is a separate catalog tool of /// its own; whole-app rules also cover it. +#[allow(clippy::too_many_arguments)] pub(super) fn app_target_matches( + rule_name: &str, provider: &str, tools: &[String], request_host: &str, request_method: &str, request_path: &str, - body: Option<&[u8]>, + input: &MatchInput<'_>, conditions: &Option, + is_block: bool, ) -> bool { let Some(provider_tools) = catalog().get(provider) else { return false; }; if tools.is_empty() { - return provider_tools + // Behavioral conditions gate the whole-app match too (a wildcard-path + // variant carrying the owning rule's Block-ness, so an unevaluable + // condition fails closed by action) — otherwise a conditioned + // whole-app ALLOW would match on host alone and could shadow a later + // Block. A connection target's session-policy OBJECT stays vacuous in + // `condition_match::decode_conditions`, so granular connection rules + // are unaffected. + let host_hit = provider_tools .values() .any(|tool| host_matches(request_host, &tool.host_pattern)) || crate::apps::provider_matches_host_and_path(provider, request_host, request_path); + return host_hit + && matches_request( + &variant_rule(rule_name, "*", None, conditions, is_block), + request_method, + request_path, + input, + ); } // The host is the app's per-tool catalog host OR an injection MIRROR of the // app (tool-independent → computed once): a path-scoped mirror (Gmail's @@ -139,8 +165,14 @@ pub(super) fn app_target_matches( }; tool.paths.iter().any(|path| { methods.iter().any(|method| { - let rule = variant_rule(path, method.map(str::to_string), conditions); - matches_request(&rule, request_method, request_path, body) + let rule = variant_rule( + rule_name, + path, + method.map(str::to_string), + conditions, + is_block, + ); + matches_request(&rule, request_method, request_path, input) }) }) }) @@ -152,7 +184,98 @@ mod tests { fn matches(provider: &str, tools: &[&str], host: &str, method: &str, path: &str) -> bool { let tools: Vec = tools.iter().map(|s| s.to_string()).collect(); - app_target_matches(provider, &tools, host, method, path, None, &None) + app_target_matches( + "test rule", + provider, + &tools, + host, + method, + path, + &MatchInput::empty(), + &None, + false, + ) + } + + #[test] + fn conditions_gate_the_tool_fanout_and_fail_closed_for_block() { + // A tool-scoped target honors the owning rule's conditions through the + // variant fan-out, and the variant carries the rule's Block-ness so an + // unevaluable condition fails closed exactly like a network target. + let tools = vec!["create_issue".to_string()]; + let hit = |input: &MatchInput<'_>, conditions: &str, is_block: bool| { + app_target_matches( + "test rule", + "github", + &tools, + "api.github.com", + "POST", + "/repos/o/r/issues", + input, + &serde_json::from_str(conditions).ok(), + is_block, + ) + }; + let cond = r#"[{"target":"body","operator":"contains","value":"needle"}]"#; + let with_needle = MatchInput { + body: Some(b"has needle"), + body_truncated: false, + headers: None, + }; + let without_needle = MatchInput { + body: Some(b"nothing"), + body_truncated: false, + headers: None, + }; + assert!(hit(&with_needle, cond, false)); + assert!(!hit(&without_needle, cond, false)); + // Unevaluable (uncompilable regex): matches only when Block-owned. + let broken = r#"[{"target":"body","operator":"regex","value":"("}]"#; + assert!(hit(&without_needle, broken, true)); + assert!(!hit(&without_needle, broken, false)); + } + + #[test] + fn conditions_gate_the_whole_app_match_and_fail_closed_for_block() { + // The empty-tools branch mirrors the tool fan-out: behavioral + // conditions gate the host-wide match, and an unevaluable condition + // fails closed only when the owning rule is a Block. Without this a + // conditioned whole-app ALLOW would match on host alone and shadow a + // later Block. + let hit = |input: &MatchInput<'_>, conditions: &str, is_block: bool| { + app_target_matches( + "test rule", + "github", + &[], + "api.github.com", + "DELETE", + "/anything", + input, + &serde_json::from_str(conditions).ok(), + is_block, + ) + }; + let cond = r#"[{"target":"body","operator":"contains","value":"needle"}]"#; + let with_needle = MatchInput { + body: Some(b"has needle"), + body_truncated: false, + headers: None, + }; + let without_needle = MatchInput { + body: Some(b"nothing"), + body_truncated: false, + headers: None, + }; + assert!(hit(&with_needle, cond, false)); + assert!(!hit(&without_needle, cond, false)); + // Unevaluable (uncompilable regex): matches only when Block-owned. + let broken = r#"[{"target":"body","operator":"regex","value":"("}]"#; + assert!(hit(&without_needle, broken, true)); + assert!(!hit(&without_needle, broken, false)); + // A session-policy OBJECT (granular connection scope) stays vacuous — + // the whole-app match is unaffected by it. + let session = r#"{"repositories":["o/r"]}"#; + assert!(hit(&without_needle, session, false)); } #[test] @@ -344,7 +467,17 @@ mod tests { continue; } assert!( - app_target_matches(provider, &[], &host, "POST", &path, None, &None), + app_target_matches( + "test rule", + provider, + &[], + &host, + "POST", + &path, + &MatchInput::empty(), + &None, + false + ), "whole-app rule for `{provider}` must cover its injection host `{host}` (path `{path}`)" ); } diff --git a/apps/gateway/src/policy_engine/enforce.rs b/apps/gateway/src/policy_engine/enforce.rs index 0af4c190..6e37107c 100644 --- a/apps/gateway/src/policy_engine/enforce.rs +++ b/apps/gateway/src/policy_engine/enforce.rs @@ -22,17 +22,43 @@ use crate::db::{ AvailableApps, ConnectionProviders, PolicyRuleV2Row, PolicyV2Rules, PrincipalSet, SecretHosts, }; use crate::gateway::{strip_port, ProxyContext}; -use crate::policy::{check_rate_limit, MatchedRule, PolicyDecision}; +use crate::policy::{check_rate_limit, MatchInput, MatchedRule, PolicyDecision}; use super::assemble::assemble; use super::evaluate::evaluate_outcome; use super::loaders; use super::types::{Action, Outcome, Request, Rule, RuleScope}; -/// `false` always: OSS's `condition_match` arm cannot buffer bodies and never -/// evaluates conditions (they match vacuously), so there is nothing to buffer for. -pub(crate) fn needs_body_buffer(_v2: &PolicyV2Rules) -> bool { - false +/// True iff a loaded rule (org or project) has a BODY condition on a target +/// that could govern this `host` — the host-scoped superset that keeps the +/// buffering as narrow as correctness allows. A network target matches its +/// own `host_pattern`; app/connection/secret targets buffer unconditionally +/// (their host resolution lives in the catalog/fenced maps — not worth +/// duplicating here); unknown kinds never match anything. Header-only +/// conditions never buffer (headers are always available); equipment rows are +/// injection-only and skipped; empty slices never buffer. +/// +/// The superset law: `needs_body_buffer` must be TRUE whenever some body +/// condition could be consulted for this host, so the matcher only ever sees +/// `body: None` for a request that genuinely had no body — never for one whose +/// body was skipped by the streaming path. +pub(crate) fn needs_body_buffer(v2: &PolicyV2Rules, host: &str) -> bool { + let host = strip_port(host); + v2.org + .iter() + .chain(v2.project.iter()) + .filter(|r| r.source != "equipment") + .filter(|r| crate::condition_match::has_body_condition(&r.conditions)) + .any(|r| { + r.targets.0.iter().any(|t| match t.kind.as_str() { + "network" => t + .host_pattern + .as_deref() + .is_some_and(|p| crate::connect::host_matches(host, p)), + "app" | "connection" | "secret" => true, + _ => false, + }) + }) } /// True when any loaded rule (org or project) has a target of `kind`, skipping @@ -155,7 +181,7 @@ pub(crate) async fn evaluate( host: &str, method: &str, path: &str, - body: Option<&[u8]>, + input: &MatchInput<'_>, has_injections: bool, is_llm_host: bool, winning_connection_id: Option<&str>, @@ -198,7 +224,7 @@ pub(crate) async fn evaluate( name: rule.name.clone(), scope: rule.scope.as_str().to_string(), }; - match evaluate_outcome(&org_rules, &project_rules, &request, &v2.principals, body) { + match evaluate_outcome(&org_rules, &project_rules, &request, &v2.principals, input) { Outcome::Rule(rule) => ( decision_for_rule(rule, org_id, project_id, agent_token, cache).await, Some(matched_of(rule)), @@ -280,7 +306,7 @@ mod tests { "api.example.com", "GET", "/", - None, + &MatchInput::empty(), false, false, None, @@ -337,4 +363,74 @@ mod tests { })]; assert!(!has_target_kind(&[&equipment, &project], "secret")); } + + #[test] + fn needs_body_buffer_scopes_to_host_and_skips_equipment() { + let body_cond: Option = + serde_json::from_str(r#"[{"target":"body","operator":"contains","value":"x"}]"#).ok(); + let network_rule = |conditions: Option| { + row(|r| { + r.conditions = conditions; + r.targets = Json(vec![serde_json::from_value( + json!({"kind": "network", "hostPattern": "api.example.com"}), + ) + .expect("target row")]); + }) + }; + // Network-target rule with a body condition: only its host buffers + // (port-stripped), foreign hosts keep streaming. + let v2 = PolicyV2Rules { + project: vec![network_rule(body_cond.clone())], + ..PolicyV2Rules::default() + }; + assert!(needs_body_buffer(&v2, "api.example.com")); + assert!(needs_body_buffer(&v2, "api.example.com:443")); + assert!(!needs_body_buffer(&v2, "other.example.com")); + // Org-scope rules count too. + let v2 = PolicyV2Rules { + org: vec![network_rule(body_cond.clone())], + ..PolicyV2Rules::default() + }; + assert!(needs_body_buffer(&v2, "api.example.com")); + // App-target rule → conservatively buffer everywhere (superset law). + let app_rule = row(|r| { + r.conditions = body_cond.clone(); + r.targets = Json(vec![serde_json::from_value( + json!({"kind": "app", "appProvider": "github", "appTools": []}), + ) + .expect("target row")]); + }); + let v2 = PolicyV2Rules { + project: vec![app_rule], + ..PolicyV2Rules::default() + }; + assert!(needs_body_buffer(&v2, "anything.example.com")); + // Equipment rows are injection-only — never buffer. + let equipment = row(|r| { + r.source = "equipment".to_string(); + r.conditions = body_cond.clone(); + r.targets = Json(vec![serde_json::from_value( + json!({"kind": "network", "hostPattern": "api.example.com"}), + ) + .expect("target row")]); + }); + let v2 = PolicyV2Rules { + project: vec![equipment], + ..PolicyV2Rules::default() + }; + assert!(!needs_body_buffer(&v2, "api.example.com")); + // Header-only conditions never buffer (headers are always available). + let header_cond: Option = + serde_json::from_str(r#"[{"target":"header","operator":"exists","key":"x-k"}]"#).ok(); + let v2 = PolicyV2Rules { + project: vec![network_rule(header_cond)], + ..PolicyV2Rules::default() + }; + assert!(!needs_body_buffer(&v2, "api.example.com")); + // Empty bundle never buffers. + assert!(!needs_body_buffer( + &PolicyV2Rules::default(), + "api.example.com" + )); + } } diff --git a/apps/gateway/src/policy_engine/evaluate.rs b/apps/gateway/src/policy_engine/evaluate.rs index a2b897ff..36b0ccd7 100644 --- a/apps/gateway/src/policy_engine/evaluate.rs +++ b/apps/gateway/src/policy_engine/evaluate.rs @@ -12,11 +12,13 @@ //! //! Matching routes through the gateway's own `connect::host_matches` + //! `policy::matches_request`, so path globs, methods, the git-receive-pack -//! bridge, and the (no-op in OSS) condition arm are byte-identical to the -//! legacy path. +//! bridge, and the body/header condition arm are byte-identical to the shared +//! matcher. Conditions are evaluated at BOTH scopes: every rule's own +//! Block-ness rides through the pseudo-rule seam, so an unevaluable condition +//! fails CLOSED by action (a Block over-blocks, an Allow falls through). use crate::db::PrincipalSet; -use crate::policy::{matches_request, PolicyAction, PolicyRule}; +use crate::policy::{matches_request, MatchInput, PolicyAction, PolicyRule}; use super::types::{Action, Identity, Outcome, Request, Rule, Target}; @@ -35,22 +37,35 @@ fn identity_matches(rule: &Rule, request: &Request, principals: &PrincipalSet) - } /// A throwaway `policy::PolicyRule` so the network match runs the gateway's -/// exact `matches_request` (the action is irrelevant to matching). +/// exact `matches_request`. Conditions ride from the owning rule, and so does +/// its BLOCK-ness (`is_block`): `condition_match`'s failure law is +/// action-aware — an unevaluable condition fails CLOSED only for a Block rule +/// — so hardcoding Allow here would fail a v2 Block rule OPEN on a broken +/// regex/oversized body. The owning rule's NAME rides along too, so the +/// matcher's unevaluable-condition warning identifies the broken rule instead +/// of logging an empty name. fn pseudo_rule( + name: &str, path_pattern: Option<&str>, method: Option, conditions: &Option, + is_block: bool, ) -> PolicyRule { PolicyRule { - name: String::new(), + name: name.to_string(), path_pattern: path_pattern.unwrap_or("*").to_string(), method, - action: PolicyAction::Allow, + action: if is_block { + PolicyAction::Block + } else { + PolicyAction::Allow + }, conditions_raw: conditions.clone(), } } -fn target_matches(target: &Target, rule: &Rule, request: &Request, body: Option<&[u8]>) -> bool { +fn target_matches(target: &Target, rule: &Rule, request: &Request, input: &MatchInput<'_>) -> bool { + let is_block = rule.action == Action::Block; match target { Target::Network { host_pattern, @@ -59,24 +74,33 @@ fn target_matches(target: &Target, rule: &Rule, request: &Request, body: Option< } => { crate::connect::host_matches(&request.host, host_pattern) && matches_request( - &pseudo_rule(path_pattern.as_deref(), method.clone(), &rule.conditions), + &pseudo_rule( + &rule.name, + path_pattern.as_deref(), + method.clone(), + &rule.conditions, + is_block, + ), &request.method, &request.path, - body, + input, ) } Target::App { provider, tools } => super::catalog::app_target_matches( + &rule.name, provider, tools, &request.host, &request.method, &request.path, - body, + input, &rule.conditions, + is_block, ), // A connection target matches only when it is the request's winning // injected connection AND the provider/tools fan-out hits. No winner → - // never matches (fail-closed for allow AND block). + // never matches (fail-closed for allow AND block). Conditions ride + // through the fan-out carrying the owning rule's Block-ness. Target::Connection { id, provider, @@ -84,20 +108,34 @@ fn target_matches(target: &Target, rule: &Rule, request: &Request, body: Option< } => { request.winning_connection_id.as_deref() == Some(id.as_str()) && super::catalog::app_target_matches( + &rule.name, provider, tools, &request.host, &request.method, &request.path, - body, + input, &rule.conditions, + is_block, + ) + } + // A secret target gates its resolved host(s). Empty patterns + // (unresolved/deleted secret) never match — fail-closed. The owning + // rule's conditions still narrow the match (wildcard-path pseudo-rule + // carrying its Block-ness): without this gate a conditioned ALLOW on a + // secret would match unconditionally and could shadow a later Block — + // the widening the fail-closed law forbids. + Target::Secret { host_patterns } => { + host_patterns + .iter() + .any(|h| crate::connect::host_matches(&request.host, h)) + && matches_request( + &pseudo_rule(&rule.name, None, None, &rule.conditions, is_block), + &request.method, + &request.path, + input, ) } - // A secret target gates its resolved host(s), host-only. Empty patterns - // (unresolved/deleted secret) never match — fail-closed. - Target::Secret { host_patterns } => host_patterns - .iter() - .any(|h| crate::connect::host_matches(&request.host, h)), Target::Unresolved => false, } } @@ -110,14 +148,14 @@ fn rule_matches( rule: &Rule, request: &Request, principals: &PrincipalSet, - body: Option<&[u8]>, + input: &MatchInput<'_>, ) -> bool { identity_matches(rule, request, principals) && !rule.targets.is_empty() && rule .targets .iter() - .any(|t| target_matches(t, rule, request, body)) + .any(|t| target_matches(t, rule, request, input)) } /// Strictness rank, mirroring `strictness.ts::strictnessRank`: block strictest @@ -151,13 +189,13 @@ fn first_match<'a>( rules: &'a [Rule], request: &Request, principals: &PrincipalSet, - body: Option<&[u8]>, + input: &MatchInput<'_>, ) -> Option> { let mut ordered: Vec<&'a Rule> = rules.iter().filter(|r| !r.is_default).collect(); ordered.sort_by(|a, b| a.priority.cmp(&b.priority).then_with(|| a.id.cmp(&b.id))); ordered .into_iter() - .find(|rule| rule_matches(rule, request, principals, body)) + .find(|rule| rule_matches(rule, request, principals, input)) .map(|rule| LevelMatch { rank: strictness_rank(rule), rule, @@ -183,13 +221,13 @@ pub(super) fn evaluate_outcome<'a>( project_rules: &'a [Rule], request: &Request, principals: &PrincipalSet, - body: Option<&[u8]>, + input: &MatchInput<'_>, ) -> Outcome<'a> { let org_default = org_rules.iter().find(|r| r.is_default); let project_default = project_rules.iter().find(|r| r.is_default); - let org_match = first_match(org_rules, request, principals, body); - let project_match = first_match(project_rules, request, principals, body); + let org_match = first_match(org_rules, request, principals, input); + let project_match = first_match(project_rules, request, principals, input); // A Default-Block is enforced only under the carve (credentialed, non-LLM), // at EVERY level. @@ -344,7 +382,13 @@ mod tests { #[test] fn first_match_wins_by_priority() { let rules = vec![rule("b", 1, Action::Block), rule("a", 0, Action::Allow)]; - match evaluate_outcome(&[], &rules, &request(), &no_principals(), None) { + match evaluate_outcome( + &[], + &rules, + &request(), + &no_principals(), + &MatchInput::empty(), + ) { Outcome::Rule(r) => assert_eq!(r.id, "a"), _ => panic!("expected a rule match"), } @@ -356,7 +400,13 @@ mod tests { vec![rule("a", 5, Action::Allow), rule("b", 5, Action::Block)], vec![rule("b", 5, Action::Block), rule("a", 5, Action::Allow)], ] { - match evaluate_outcome(&[], &rules, &request(), &no_principals(), None) { + match evaluate_outcome( + &[], + &rules, + &request(), + &no_principals(), + &MatchInput::empty(), + ) { Outcome::Rule(r) => assert_eq!(r.id, "a", "lower id wins the tie"), _ => panic!("expected a rule match"), } @@ -373,13 +423,25 @@ mod tests { let allow = rule("any", 2, Action::Allow); let rules = vec![agent_scoped, other, allow]; - match evaluate_outcome(&[], &rules, &request(), &no_principals(), None) { + match evaluate_outcome( + &[], + &rules, + &request(), + &no_principals(), + &MatchInput::empty(), + ) { Outcome::Rule(r) => assert_eq!(r.id, "scoped"), _ => panic!("expected the agent-scoped match"), } let mut foreign = request(); foreign.agent_id = "agent-2".to_string(); - match evaluate_outcome(&[], &rules, &foreign, &no_principals(), None) { + match evaluate_outcome( + &[], + &rules, + &foreign, + &no_principals(), + &MatchInput::empty(), + ) { // The directory identity must NOT match — the any-agent allow wins. Outcome::Rule(r) => assert_eq!(r.id, "any"), _ => panic!("expected the any-agent match"), @@ -392,7 +454,13 @@ mod tests { let mut orphan = rule("orphan", 0, Action::Block); orphan.targets = Vec::new(); let control = rule("control", 1, Action::Allow); - match evaluate_outcome(&[], &[orphan, control], &request(), &no_principals(), None) { + match evaluate_outcome( + &[], + &[orphan, control], + &request(), + &no_principals(), + &MatchInput::empty(), + ) { Outcome::Rule(r) => assert_eq!(r.id, "control"), _ => panic!("expected the control match"), } @@ -405,11 +473,23 @@ mod tests { let rules = vec![default_rule(Action::Block)]; // Uncredentialed → the carve spares it. assert!(matches!( - evaluate_outcome(&[], &rules, &request(), &no_principals(), None), + evaluate_outcome( + &[], + &rules, + &request(), + &no_principals(), + &MatchInput::empty() + ), Outcome::Allow )); // Credentialed non-LLM → blocked, attributed to the Default Rule. - match evaluate_outcome(&[], &rules, &injected_request(), &no_principals(), None) { + match evaluate_outcome( + &[], + &rules, + &injected_request(), + &no_principals(), + &MatchInput::empty(), + ) { Outcome::DenyDefault(d) => assert!(d.is_default), _ => panic!("expected the deny-default"), } @@ -417,7 +497,7 @@ mod tests { let mut llm = injected_request(); llm.is_llm_host = true; assert!(matches!( - evaluate_outcome(&[], &rules, &llm, &no_principals(), None), + evaluate_outcome(&[], &rules, &llm, &no_principals(), &MatchInput::empty()), Outcome::Allow )); } @@ -425,7 +505,13 @@ mod tests { #[test] fn explicit_allow_opens_the_same_level_default_block() { let rules = vec![rule("open", 0, Action::Allow), default_rule(Action::Block)]; - match evaluate_outcome(&[], &rules, &injected_request(), &no_principals(), None) { + match evaluate_outcome( + &[], + &rules, + &injected_request(), + &no_principals(), + &MatchInput::empty(), + ) { Outcome::Rule(r) => assert_eq!(r.id, "open"), _ => panic!("expected the allow rule to win over its own default block"), } @@ -435,24 +521,273 @@ mod tests { fn default_allow_is_neutral() { let rules = vec![default_rule(Action::Allow)]; assert!(matches!( - evaluate_outcome(&[], &rules, &injected_request(), &no_principals(), None), + evaluate_outcome( + &[], + &rules, + &injected_request(), + &no_principals(), + &MatchInput::empty() + ), Outcome::Allow )); } - /// Test #11 (part): the OSS condition arm is the no-op — a conditioned block - /// matches vacuously. This pins the Stage-G seam; if OSS ships real - /// condition matching this test must flip with it. + // ── Stage-G: conditions are evaluated at both scopes ──────────────── + + fn conditioned(id: &str, priority: usize, action: Action, conditions: &str) -> Rule { + let mut r = rule(id, priority, action); + r.conditions = serde_json::from_str(conditions).ok(); + r + } + + fn body_input(body: &[u8]) -> MatchInput<'_> { + MatchInput { + body: Some(body), + body_truncated: false, + headers: None, + } + } + + #[test] + fn conditioned_block_falls_through_when_body_lacks_the_needle() { + // OSS evaluates conditions since Tier 3a (this test used to pin the + // opposite no-op posture): a body-conditioned block whose needle is + // absent falls through and the next rule wins. + let rules = vec![ + conditioned( + "cond", + 0, + Action::Block, + r#"[{"target":"body","operator":"contains","value":"needle"}]"#, + ), + rule("open", 1, Action::Allow), + ]; + match evaluate_outcome( + &[], + &rules, + &request(), + &no_principals(), + &body_input(b"no match here"), + ) { + Outcome::Rule(r) => assert_eq!(r.id, "open"), + _ => panic!("expected the conditioned block to fall through"), + } + } + #[test] - fn conditioned_rule_matches_with_no_body_in_oss() { - let mut conditioned = rule("cond", 0, Action::Block); - conditioned.conditions = serde_json::from_str( - r#"[{"target":"body","operator":"contains","value":"never-present"}]"#, - ) - .ok(); - match evaluate_outcome(&[], &[conditioned], &request(), &no_principals(), None) { + fn conditioned_block_matches_when_body_contains_the_needle() { + let rules = vec![ + conditioned( + "cond", + 0, + Action::Block, + r#"[{"target":"body","operator":"contains","value":"needle"}]"#, + ), + rule("open", 1, Action::Allow), + ]; + match evaluate_outcome( + &[], + &rules, + &request(), + &no_principals(), + &body_input(b"the needle is here"), + ) { Outcome::Rule(r) => assert_eq!(r.id, "cond"), - _ => panic!("expected the conditioned rule to match vacuously"), + _ => panic!("expected the conditioned block to match"), + } + } + + #[test] + fn invalid_condition_on_a_v2_block_still_blocks() { + // Pins the pseudo-rule action mapping: the failure law is action-aware, + // so a Block rule with an uncompilable regex must still BLOCK. This + // test fails if `pseudo_rule` hardcodes Allow. + let rules = vec![ + conditioned( + "broken", + 0, + Action::Block, + r#"[{"target":"body","operator":"regex","value":"(?<=x)["}]"#, + ), + rule("open", 1, Action::Allow), + ]; + match evaluate_outcome( + &[], + &rules, + &request(), + &no_principals(), + &body_input(b"anything"), + ) { + Outcome::Rule(r) => assert_eq!(r.id, "broken", "Block must fail CLOSED"), + _ => panic!("expected the broken-condition block to match"), + } + // The symmetric guard: the same broken condition on an ALLOW rule + // falls through (it must not shadow a later block). + let rules = vec![ + conditioned( + "broken-allow", + 0, + Action::Allow, + r#"[{"target":"body","operator":"regex","value":"(?<=x)["}]"#, + ), + rule("blocker", 1, Action::Block), + ]; + match evaluate_outcome( + &[], + &rules, + &request(), + &no_principals(), + &body_input(b"anything"), + ) { + Outcome::Rule(r) => assert_eq!(r.id, "blocker"), + _ => panic!("expected the broken-condition allow to fall through"), + } + } + + #[test] + fn conditions_are_enforced_at_the_org_scope_too() { + // The fail-closed-by-action law holds at BOTH scopes (F routes org and + // project through the same seam): an org-scope Block whose broken regex + // is unevaluable fails CLOSED, over-blocking even a project allow. + let org = vec![{ + let mut r = conditioned( + "org-broken", + 0, + Action::Block, + r#"[{"target":"body","operator":"regex","value":"("}]"#, + ); + r.scope = RuleScope::Organization; + r + }]; + let project = vec![rule("proj-allow", 0, Action::Allow)]; + match evaluate_outcome( + &org, + &project, + &request(), + &no_principals(), + &body_input(b"anything"), + ) { + Outcome::Rule(r) => { + assert_eq!(r.id, "org-broken", "org Block must fail closed"); + assert_eq!(r.scope, RuleScope::Organization); + } + _ => panic!("expected the org broken-condition block"), + } + // The same org rule as an ALLOW falls through — its broken condition + // cannot widen or shadow the project block. + let org = vec![{ + let mut r = conditioned( + "org-broken-allow", + 0, + Action::Allow, + r#"[{"target":"body","operator":"regex","value":"("}]"#, + ); + r.scope = RuleScope::Organization; + r + }]; + let project = vec![rule("proj-block", 0, Action::Block)]; + match evaluate_outcome( + &org, + &project, + &request(), + &no_principals(), + &body_input(b"anything"), + ) { + Outcome::Rule(r) => assert_eq!(r.id, "proj-block"), + _ => panic!("the broken org allow must not shadow the project block"), + } + } + + #[test] + fn secret_target_honors_conditions_and_fails_closed_by_action() { + let secret_target = || { + vec![Target::Secret { + host_patterns: vec!["api.example.com".to_string()], + }] + }; + let cond = r#"[{"target":"body","operator":"contains","value":"needle"}]"#; + // A conditioned ALLOW on a secret must NOT match unconditionally — it + // would shadow the later Block (the widening the fail-closed law + // forbids). + let mut cond_allow = conditioned("sec-allow", 0, Action::Allow, cond); + cond_allow.targets = secret_target(); + let mut blocker = rule("blocker", 1, Action::Block); + blocker.targets = secret_target(); + let rules = vec![cond_allow, blocker]; + match evaluate_outcome( + &[], + &rules, + &request(), + &no_principals(), + &body_input(b"no match here"), + ) { + Outcome::Rule(r) => assert_eq!(r.id, "blocker", "allow must fall through"), + _ => panic!("expected the block"), + } + // With the needle present the conditioned allow matches first. + match evaluate_outcome( + &[], + &rules, + &request(), + &no_principals(), + &body_input(b"has needle"), + ) { + Outcome::Rule(r) => assert_eq!(r.id, "sec-allow"), + _ => panic!("expected the conditioned allow"), + } + // An unevaluable condition on a secret-target Block fails CLOSED. + let mut broken_block = conditioned( + "broken", + 0, + Action::Block, + r#"[{"target":"body","operator":"regex","value":"("}]"#, + ); + broken_block.targets = secret_target(); + let rules = vec![broken_block, rule("open", 1, Action::Allow)]; + match evaluate_outcome( + &[], + &rules, + &request(), + &no_principals(), + &body_input(b"anything"), + ) { + Outcome::Rule(r) => assert_eq!(r.id, "broken", "Block must fail closed"), + _ => panic!("expected the broken-condition block"), + } + } + + #[test] + fn header_condition_narrows_a_v2_rule() { + let rules = vec![ + conditioned( + "hdr", + 0, + Action::Block, + r#"[{"target":"header","operator":"equals","key":"X-Env","value":"prod"}]"#, + ), + rule("open", 1, Action::Allow), + ]; + let mut headers = hyper::HeaderMap::new(); + headers.insert("x-env", hyper::header::HeaderValue::from_static("prod")); + let input = MatchInput { + body: None, + body_truncated: false, + headers: Some(&headers), + }; + match evaluate_outcome(&[], &rules, &request(), &no_principals(), &input) { + Outcome::Rule(r) => assert_eq!(r.id, "hdr", "matching header must block"), + _ => panic!("expected the header-conditioned block"), + } + let mut other = hyper::HeaderMap::new(); + other.insert("x-env", hyper::header::HeaderValue::from_static("dev")); + let input = MatchInput { + body: None, + body_truncated: false, + headers: Some(&other), + }; + match evaluate_outcome(&[], &rules, &request(), &no_principals(), &input) { + Outcome::Rule(r) => assert_eq!(r.id, "open", "non-matching header falls through"), + _ => panic!("expected the allow"), } } @@ -484,16 +819,28 @@ mod tests { // BLOCK: matching winner binds; no winner → no match (fail-closed). let blk = vec![conn_rule("c1", Action::Block)]; assert!(matches!( - evaluate_outcome(&[], &blk, &req_via(Some("c1")), &no_principals(), None), + evaluate_outcome(&[], &blk, &req_via(Some("c1")), &no_principals(), &MatchInput::empty()), Outcome::Rule(r) if r.action == Action::Block )); assert!(matches!( - evaluate_outcome(&[], &blk, &req_via(None), &no_principals(), None), + evaluate_outcome( + &[], + &blk, + &req_via(None), + &no_principals(), + &MatchInput::empty() + ), Outcome::Allow )); // A same-provider sibling account → no match. assert!(matches!( - evaluate_outcome(&[], &blk, &req_via(Some("c2")), &no_principals(), None), + evaluate_outcome( + &[], + &blk, + &req_via(Some("c2")), + &no_principals(), + &MatchInput::empty() + ), Outcome::Allow )); @@ -506,7 +853,7 @@ mod tests { &allow_over_block, &req_via(Some("c1")), &no_principals(), - None, + &MatchInput::empty(), ) { Outcome::Rule(r) => assert_eq!(r.action, Action::Allow), _ => panic!("winner should open its own connection allow"), @@ -517,7 +864,7 @@ mod tests { &allow_over_block, &req_via(None), &no_principals(), - None + &MatchInput::empty() ), Outcome::DenyDefault(_) )); @@ -528,7 +875,13 @@ mod tests { #[test] fn org_rule_is_enforced_and_carries_org_scope() { let org = vec![org_rule("org-block", 0, Action::Block)]; - match evaluate_outcome(&org, &[], &request(), &no_principals(), None) { + match evaluate_outcome( + &org, + &[], + &request(), + &no_principals(), + &MatchInput::empty(), + ) { Outcome::Rule(r) => { assert_eq!(r.id, "org-block"); assert_eq!(r.scope, RuleScope::Organization); @@ -549,13 +902,19 @@ mod tests { scoped.identities = vec![identity]; let org = vec![scoped]; // Present in the principal set → the rule matches. - match evaluate_outcome(&org, &[], &request(), &principals(), None) { + match evaluate_outcome(&org, &[], &request(), &principals(), &MatchInput::empty()) { Outcome::Rule(r) => assert_eq!(r.id, id), _ => panic!("expected {id} to match via principals"), } // Absent (empty/stale set) → the rule narrows to nothing. assert!(matches!( - evaluate_outcome(&org, &[], &request(), &no_principals(), None), + evaluate_outcome( + &org, + &[], + &request(), + &no_principals(), + &MatchInput::empty() + ), Outcome::Allow )); } @@ -572,7 +931,7 @@ mod tests { foreign_group.identities = vec![Identity::Group("g-other".to_string())]; let org = vec![foreign_user, foreign_group]; assert!(matches!( - evaluate_outcome(&org, &[], &request(), &principals(), None), + evaluate_outcome(&org, &[], &request(), &principals(), &MatchInput::empty()), Outcome::Allow )); } @@ -586,12 +945,24 @@ mod tests { fn empty_org_fails_open_not_closed() { // No project rules either → plain allow, even credentialed. assert!(matches!( - evaluate_outcome(&[], &[], &injected_request(), &no_principals(), None), + evaluate_outcome( + &[], + &[], + &injected_request(), + &no_principals(), + &MatchInput::empty() + ), Outcome::Allow )); // An empty org slice changes nothing vs the project-only walk. let project = vec![rule("open", 0, Action::Allow), default_rule(Action::Block)]; - match evaluate_outcome(&[], &project, &injected_request(), &no_principals(), None) { + match evaluate_outcome( + &[], + &project, + &injected_request(), + &no_principals(), + &MatchInput::empty(), + ) { Outcome::Rule(r) => assert_eq!(r.id, "open"), _ => panic!("expected the project allow, not a phantom org block"), } @@ -602,7 +973,13 @@ mod tests { #[test] fn empty_project_lets_the_org_level_decide() { let org = vec![org_rule("org-allow", 0, Action::Allow)]; - match evaluate_outcome(&org, &[], &request(), &no_principals(), None) { + match evaluate_outcome( + &org, + &[], + &request(), + &no_principals(), + &MatchInput::empty(), + ) { Outcome::Rule(r) => { assert_eq!(r.id, "org-allow"); assert_eq!(r.scope, RuleScope::Organization); @@ -611,7 +988,13 @@ mod tests { } // An org default-Block over an empty project blocks under the carve. let org = vec![org_default(Action::Block)]; - match evaluate_outcome(&org, &[], &injected_request(), &no_principals(), None) { + match evaluate_outcome( + &org, + &[], + &injected_request(), + &no_principals(), + &MatchInput::empty(), + ) { Outcome::DenyDefault(d) => assert_eq!(d.scope, RuleScope::Organization), _ => panic!("expected the org deny-default"), } @@ -624,14 +1007,26 @@ mod tests { // Org guardrail Block beats a project allow… let org = vec![org_rule("org-block", 0, Action::Block)]; let project = vec![rule("proj-allow", 0, Action::Allow)]; - match evaluate_outcome(&org, &project, &request(), &no_principals(), None) { + match evaluate_outcome( + &org, + &project, + &request(), + &no_principals(), + &MatchInput::empty(), + ) { Outcome::Rule(r) => assert_eq!(r.id, "org-block"), _ => panic!("expected the org block"), } // …and symmetrically a project Block survives an org allow. let org = vec![org_rule("org-allow", 0, Action::Allow)]; let project = vec![rule("proj-block", 0, Action::Block)]; - match evaluate_outcome(&org, &project, &request(), &no_principals(), None) { + match evaluate_outcome( + &org, + &project, + &request(), + &no_principals(), + &MatchInput::empty(), + ) { Outcome::Rule(r) => assert_eq!(r.id, "proj-block"), _ => panic!("expected the project block"), } @@ -645,7 +1040,13 @@ mod tests { r }]; let project = vec![rate_rule("proj-rate", 0)]; - match evaluate_outcome(&org, &project, &request(), &no_principals(), None) { + match evaluate_outcome( + &org, + &project, + &request(), + &no_principals(), + &MatchInput::empty(), + ) { Outcome::Rule(r) => assert_eq!(r.id, "org-approval"), _ => panic!("expected the approval to outrank the rate limit"), } @@ -661,7 +1062,13 @@ mod tests { r }]; let project = vec![rate_rule("proj-rate", 0)]; - match evaluate_outcome(&org, &project, &request(), &no_principals(), None) { + match evaluate_outcome( + &org, + &project, + &request(), + &no_principals(), + &MatchInput::empty(), + ) { Outcome::Rule(r) => { assert_eq!(r.id, "org-rate"); assert_eq!(r.scope, RuleScope::Organization); @@ -678,7 +1085,13 @@ mod tests { malformed.identities = vec![Identity::Other]; let org = vec![malformed, org_rule("org-any", 1, Action::Block)]; let project = vec![rule("proj-any", 0, Action::Allow)]; - match evaluate_outcome(&org, &project, &request(), &principals(), None) { + match evaluate_outcome( + &org, + &project, + &request(), + &principals(), + &MatchInput::empty(), + ) { Outcome::Rule(r) => assert_eq!(r.id, "org-any"), _ => panic!("expected the any-identity org block"), } @@ -694,7 +1107,13 @@ mod tests { // Direction 1: org default-Block + lone project allow → org deny-default. let org = vec![org_default(Action::Block)]; let project = vec![rule("proj-allow", 0, Action::Allow)]; - match evaluate_outcome(&org, &project, &injected_request(), &no_principals(), None) { + match evaluate_outcome( + &org, + &project, + &injected_request(), + &no_principals(), + &MatchInput::empty(), + ) { Outcome::DenyDefault(d) => { assert!(d.is_default); assert_eq!(d.scope, RuleScope::Organization); @@ -702,7 +1121,13 @@ mod tests { _ => panic!("the project allow must not punch the org floor"), } // Without the carve the org level allows — the project allow wins. - match evaluate_outcome(&org, &project, &request(), &no_principals(), None) { + match evaluate_outcome( + &org, + &project, + &request(), + &no_principals(), + &MatchInput::empty(), + ) { Outcome::Rule(r) => assert_eq!(r.id, "proj-allow"), _ => panic!("expected the project allow off the carve"), } @@ -711,7 +1136,13 @@ mod tests { // project deny-default. let org = vec![org_rule("org-allow", 0, Action::Allow)]; let project = vec![default_rule(Action::Block)]; - match evaluate_outcome(&org, &project, &injected_request(), &no_principals(), None) { + match evaluate_outcome( + &org, + &project, + &injected_request(), + &no_principals(), + &MatchInput::empty(), + ) { Outcome::DenyDefault(d) => { assert!(d.is_default); assert_eq!(d.scope, RuleScope::Project); @@ -719,7 +1150,13 @@ mod tests { _ => panic!("the org allow must not punch the project allowlist floor"), } // Without the carve the project level allows — the org allow wins. - match evaluate_outcome(&org, &project, &request(), &no_principals(), None) { + match evaluate_outcome( + &org, + &project, + &request(), + &no_principals(), + &MatchInput::empty(), + ) { Outcome::Rule(r) => assert_eq!(r.id, "org-allow"), _ => panic!("expected the org allow off the carve"), } @@ -732,13 +1169,25 @@ mod tests { // A project BLOCK applies even against an org default-Block… let org = vec![org_default(Action::Block)]; let project = vec![rule("proj-block", 0, Action::Block)]; - match evaluate_outcome(&org, &project, &injected_request(), &no_principals(), None) { + match evaluate_outcome( + &org, + &project, + &injected_request(), + &no_principals(), + &MatchInput::empty(), + ) { Outcome::Rule(r) => assert_eq!(r.id, "proj-block"), _ => panic!("a block must survive the org floor"), } // …and with no org default-Block a lone org allow just wins. let org = vec![org_rule("org-allow", 0, Action::Allow)]; - match evaluate_outcome(&org, &[], &injected_request(), &no_principals(), None) { + match evaluate_outcome( + &org, + &[], + &injected_request(), + &no_principals(), + &MatchInput::empty(), + ) { Outcome::Rule(r) => assert_eq!(r.id, "org-allow"), _ => panic!("expected the org allow"), } @@ -751,20 +1200,44 @@ mod tests { // Org default-Block: spared off the carve, blocks under it. let org = vec![org_default(Action::Block)]; assert!(matches!( - evaluate_outcome(&org, &[], &request(), &no_principals(), None), + evaluate_outcome( + &org, + &[], + &request(), + &no_principals(), + &MatchInput::empty() + ), Outcome::Allow )); - match evaluate_outcome(&org, &[], &injected_request(), &no_principals(), None) { + match evaluate_outcome( + &org, + &[], + &injected_request(), + &no_principals(), + &MatchInput::empty(), + ) { Outcome::DenyDefault(d) => assert_eq!(d.scope, RuleScope::Organization), _ => panic!("expected the org deny-default under the carve"), } // Project default-Block: same carve, independently. let project = vec![default_rule(Action::Block)]; assert!(matches!( - evaluate_outcome(&[], &project, &request(), &no_principals(), None), + evaluate_outcome( + &[], + &project, + &request(), + &no_principals(), + &MatchInput::empty() + ), Outcome::Allow )); - match evaluate_outcome(&[], &project, &injected_request(), &no_principals(), None) { + match evaluate_outcome( + &[], + &project, + &injected_request(), + &no_principals(), + &MatchInput::empty(), + ) { Outcome::DenyDefault(d) => assert_eq!(d.scope, RuleScope::Project), _ => panic!("expected the project deny-default under the carve"), } @@ -774,7 +1247,13 @@ mod tests { fn absent_project_default_with_org_default_allow_is_allow() { let org = vec![org_default(Action::Allow)]; assert!(matches!( - evaluate_outcome(&org, &[], &injected_request(), &no_principals(), None), + evaluate_outcome( + &org, + &[], + &injected_request(), + &no_principals(), + &MatchInput::empty() + ), Outcome::Allow )); } diff --git a/apps/gateway/src/policy_engine/types.rs b/apps/gateway/src/policy_engine/types.rs index 85470964..846a9f7f 100644 --- a/apps/gateway/src/policy_engine/types.rs +++ b/apps/gateway/src/policy_engine/types.rs @@ -113,9 +113,10 @@ pub(super) struct Rule { pub require_approval: bool, pub rate_limit: Option, pub rate_limit_window: Option, - /// Carried for structural fidelity and routed through the edition-swapped - /// `condition_match` — which is the no-op arm in OSS, so conditions are - /// never evaluated here (matching the legacy OSS gateway exactly). + /// The rule's behavioral conditions (body/header), routed through the + /// edition-swapped `condition_match`. In OSS (Tier 3a) they are EVALUATED + /// byte-level over the buffered body and request headers, carrying the + /// rule's Block-ness so an unevaluable condition fails closed by action. pub conditions: Option, } diff --git a/apps/web/src/lib/api/types.ts b/apps/web/src/lib/api/types.ts index 704d24b3..ee9e0f44 100644 --- a/apps/web/src/lib/api/types.ts +++ b/apps/web/src/lib/api/types.ts @@ -325,7 +325,9 @@ export interface CreateInvitationInput { // ── Shared policy identity/condition shapes ────────────────────────────────── // Used by the editor's PolicyRuleV2. Project rules target a specific agent or // "any" (empty); org rules target directory identities (user / user-group). -// Conditions are body-contains. +// Conditions are body OR header matches (contains/equals/regex/exists) — the +// wire shape mirrors the authoritative RuleCondition: `key` names the header +// (header target only) and `value` is absent for `exists`. export type ProjectionIdentity = | { type: "agent"; id: string } @@ -335,7 +337,8 @@ export type ProjectionIdentity = export interface ProjectionCondition { target: string; operator: string; - value: string; + value?: string; + key?: string; } // ── Editable policy rules (policy_rules_v2) ────────────────────────────────── diff --git a/apps/web/src/lib/components/condition-builder.tsx b/apps/web/src/lib/components/condition-builder.tsx deleted file mode 100644 index 1bb1d88f..00000000 --- a/apps/web/src/lib/components/condition-builder.tsx +++ /dev/null @@ -1,17 +0,0 @@ -"use client"; - -import type { RuleCondition } from "@onecli/api/validations/policy-rule"; - -export interface ConditionBuilderProps { - conditions: RuleCondition[]; - onChange: (conditions: RuleCondition[]) => void; -} - -export const ConditionBuilder = ({}: ConditionBuilderProps) => ( -
-

- Match conditions (body content, headers) are not yet available in this - build. -

-
-); diff --git a/apps/web/src/lib/components/condition-builder/condition-builder.tsx b/apps/web/src/lib/components/condition-builder/condition-builder.tsx new file mode 100644 index 00000000..13c55c75 --- /dev/null +++ b/apps/web/src/lib/components/condition-builder/condition-builder.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { Plus } from "lucide-react"; +import type { RuleCondition } from "@onecli/api/validations/policy-rule"; +import { Button } from "@onecli/ui/components/button"; +import { ConditionRow } from "./condition-row"; + +/** Mirrors the API's `z.array(ruleConditionSchema).max(10)` cap. */ +const MAX_CONDITIONS = 10; + +export interface ConditionBuilderProps { + conditions: RuleCondition[]; + onChange: (conditions: RuleCondition[]) => void; +} + +/** + * Build the behavioral conditions (body/header matching) that narrow when a + * rule applies. The gateway evaluates them per request: ALL conditions must + * match for the rule to apply. Body matching runs over the buffered request + * body (up to 256 KB); a body over 256 KB can't be evaluated — Block rules + * treat it as matching (fail-closed), other rules don't apply. Header names + * are case-insensitive, header values case-sensitive (use a `(?i)` regex for + * case-insensitive values). Regex uses Rust syntax — no lookaround or + * backreferences. + */ +export const ConditionBuilder = ({ + conditions, + onChange, +}: ConditionBuilderProps) => { + const update = (index: number, condition: RuleCondition) => + onChange(conditions.map((c, i) => (i === index ? condition : c))); + const remove = (index: number) => + onChange(conditions.filter((_, i) => i !== index)); + const add = () => + onChange([...conditions, { target: "body", operator: "contains" }]); + + return ( +
+ {conditions.length === 0 ? ( +

+ No conditions — the rule applies to every matching request. Add a + condition to also match on request body content or headers. +

+ ) : ( +
+ {conditions.map((condition, index) => ( + update(index, c)} + onRemove={() => remove(index)} + /> + ))} +
+ )} +
+ + {conditions.length > 1 && ( +

+ All conditions must match. +

+ )} +
+ {conditions.length > 0 && ( +

+ Bodies over 256 KB can't be evaluated: Block rules treat them as + matching; other rules don't apply. Header values are + case-sensitive — use a (?i) regex + for case-insensitive matching. Regex uses Rust syntax (no lookaround + or backreferences). +

+ )} +
+ ); +}; diff --git a/apps/web/src/lib/components/condition-builder/condition-row.tsx b/apps/web/src/lib/components/condition-builder/condition-row.tsx new file mode 100644 index 00000000..8f0d9dd8 --- /dev/null +++ b/apps/web/src/lib/components/condition-builder/condition-row.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { X } from "lucide-react"; +import type { RuleCondition } from "@onecli/api/validations/policy-rule"; +import { Button } from "@onecli/ui/components/button"; +import { Input } from "@onecli/ui/components/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@onecli/ui/components/select"; +import { conditionError } from "./validate"; + +const TARGET_OPTIONS: { value: RuleCondition["target"]; label: string }[] = [ + { value: "body", label: "Request body" }, + { value: "header", label: "Header" }, +]; + +const OPERATOR_OPTIONS: { + value: RuleCondition["operator"]; + label: string; + headerOnly?: boolean; +}[] = [ + { value: "contains", label: "contains" }, + { value: "equals", label: "equals" }, + { value: "regex", label: "matches regex" }, + // `exists` is header-only ("has a body" is not a meaningful policy). + { value: "exists", label: "is present", headerOnly: true }, +]; + +export interface ConditionRowProps { + condition: RuleCondition; + onChange: (condition: RuleCondition) => void; + onRemove: () => void; +} + +export const ConditionRow = ({ + condition, + onChange, + onRemove, +}: ConditionRowProps) => { + const isHeader = condition.target === "header"; + const needsValue = condition.operator !== "exists"; + const error = conditionError(condition); + + const setTarget = (target: RuleCondition["target"]) => { + onChange({ + target, + // `exists` doesn't apply to bodies; `key` is the header name. + operator: + target === "body" && condition.operator === "exists" + ? "contains" + : condition.operator, + value: condition.value, + key: target === "header" ? condition.key : undefined, + }); + }; + + const setOperator = (operator: RuleCondition["operator"]) => { + onChange({ + ...condition, + operator, + value: operator === "exists" ? undefined : condition.value, + }); + }; + + return ( +
+
+ + {isHeader && ( + onChange({ ...condition, key: e.target.value })} + placeholder="x-header-name" + maxLength={500} + className="h-8 w-36 font-mono text-xs" + aria-label="Header name" + /> + )} + + {needsValue && ( + onChange({ ...condition, value: e.target.value })} + placeholder={ + condition.operator === "regex" ? "(?i)delete\\s+repo" : "text…" + } + maxLength={1000} + className="h-8 min-w-32 flex-1 font-mono text-xs" + aria-label="Condition value" + /> + )} + +
+ {error &&

{error}

} +
+ ); +}; diff --git a/apps/web/src/lib/components/condition-builder/index.ts b/apps/web/src/lib/components/condition-builder/index.ts new file mode 100644 index 00000000..480a23e1 --- /dev/null +++ b/apps/web/src/lib/components/condition-builder/index.ts @@ -0,0 +1,3 @@ +export { ConditionBuilder } from "./condition-builder"; +export type { ConditionBuilderProps } from "./condition-builder"; +export { conditionError, isConditionsValid } from "./validate"; diff --git a/apps/web/src/lib/components/condition-builder/validate.ts b/apps/web/src/lib/components/condition-builder/validate.ts new file mode 100644 index 00000000..cd31b35e --- /dev/null +++ b/apps/web/src/lib/components/condition-builder/validate.ts @@ -0,0 +1,38 @@ +import type { RuleCondition } from "@onecli/api/validations/policy-rule"; +import { + compilesAsConditionRegex, + isValidHeaderName, +} from "@onecli/api/validations/condition-syntax"; + +/** The row's inline validation message, mirroring the API's rules (the syntax + * checks are the SAME shared helpers, so the two can't drift) — so a 422 is + * never the first feedback. `null` = valid. */ +export const conditionError = (condition: RuleCondition): string | null => { + if (condition.target === "header") { + if (!condition.key?.trim()) { + return "Header name is required."; + } + // The gateway accepts RFC 9110 token names only — anything else (a + // copy-pasted trailing space, an embedded space) would save but be + // permanently unevaluable. + if (!isValidHeaderName(condition.key)) { + return "Invalid header name."; + } + } + if (condition.operator !== "exists" && !condition.value) { + return "Value is required."; + } + if ( + condition.operator === "regex" && + condition.value && + !compilesAsConditionRegex(condition.value) + ) { + return "Invalid regular expression."; + } + return null; +}; + +/** Whether every condition would pass API validation — for consumers that want + * to gate submit (the API still backstops). */ +export const isConditionsValid = (conditions: RuleCondition[]): boolean => + conditions.every((condition) => conditionError(condition) === null); diff --git a/apps/web/src/lib/policy-editor/how-rules-evaluated.tsx b/apps/web/src/lib/policy-editor/how-rules-evaluated.tsx index 753cc483..ad8e433f 100644 --- a/apps/web/src/lib/policy-editor/how-rules-evaluated.tsx +++ b/apps/web/src/lib/policy-editor/how-rules-evaluated.tsx @@ -77,6 +77,11 @@ export const HowRulesEvaluated = () => ( Only one rule ever decides — rate limits and approvals don’t stack.

+

+ Conditions further narrow when a rule matches: ALL of a rule’s + conditions must hold. A condition that can’t be evaluated (e.g. an + oversized body) makes a Block rule match — it never weakens one. +

); diff --git a/apps/web/src/lib/policy-editor/policy-rule-form.tsx b/apps/web/src/lib/policy-editor/policy-rule-form.tsx index 67aaf044..4150db7e 100644 --- a/apps/web/src/lib/policy-editor/policy-rule-form.tsx +++ b/apps/web/src/lib/policy-editor/policy-rule-form.tsx @@ -47,7 +47,10 @@ import { useScopedSecrets } from "@/hooks/use-secrets"; // them to the real editors; the OSS modules are locked "available in OneCLI // Cloud" surfaces (conditions) or inert (the org picker — OSS mounts no org // scope). -import { ConditionBuilder } from "@/lib/components/condition-builder"; +import { + ConditionBuilder, + isConditionsValid, +} from "@/lib/components/condition-builder"; // Alias key on purpose (see editor-chrome's note): a relative import would // bypass the edition seam. import { OrgIdentityPicker } from "@/lib/policy-editor/identity-picker"; @@ -882,29 +885,14 @@ export const PolicyRuleForm = ({ {!(targetKind === "app" && appTarget.mode === "specific") && (
Conditions + {/* Every target kind honors conditions in the gateway — network + verbatim, app targets through the catalog fan-out (whole-app + included), secrets on their resolved hosts — so no per-target + carve-out note is needed. */} - {/* Whether conditions gate matching depends on the target: a - secret, or a WHOLE-app (no-tools) app/connection target, matches - host-only and ignores conditions; a tool-narrowed target runs - the tool fan-out, which honors conditions like a network rule - (so no note is shown then). */} - {targetKind === "secret" && ( -

- Conditions don't apply to this target type — it matches - its hosts regardless of request content. -

- )} - {targetKind === "app" && - appTarget.tools.length === 0 && - appTarget.mode === "all" && ( -

- Conditions don't apply to a whole-app target — it - matches the app's hosts regardless of request content. -

- )}
)} {/* The Conditions editor is hidden for a specific-connection target, but @@ -933,7 +921,16 @@ export const PolicyRuleForm = ({ + + ))} + + ) : ( +

+ All {itemLabel.plural} (no restriction) +

+ )} +
+ { + setDraft(event.target.value); + if (error) { + setError(null); + } + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + add(); + } + }} + /> + +
+ {error ? ( +

+ {error} +

+ ) : null} + + ); +}; diff --git a/apps/web/src/lib/policy-editor/resource-scope.tsx b/apps/web/src/lib/policy-editor/resource-scope.tsx index d905e195..432cec57 100644 --- a/apps/web/src/lib/policy-editor/resource-scope.tsx +++ b/apps/web/src/lib/policy-editor/resource-scope.tsx @@ -1,15 +1,23 @@ "use client"; import type { Connection } from "@/lib/api"; +import { granularAccessConfigs } from "@/lib/granular-access"; +import { ScopeChecklist } from "./_components/scope-checklist"; +import { ScopeTextList } from "./_components/scope-text-list"; /** - * The OSS resource-scope seam (step 9.5): granular per-resource scoping - * (GitHub repositories / Dropbox folders on a connection's injected - * credential) is not implemented in this build — the gateway has no guard to - * enforce it (Tier 3). Rendered only where the real editor would appear (a - * single specific connection on an Allow), as a locked capability hint. The - * EE editions alias this file to `@/ee/policy-editor/resource-scope` (the - * real fields). + * The OSS resource-scope editor: granular per-resource scoping (GitHub + * repositories / Dropbox folders) on a connection's injected credential. Driven + * by the shared `granularAccessConfigs` — a checklist for providers that + * enumerate their resources at connect time (GitHub repos), a free-text path + * list for those that can't (Dropbox folders). The gateway enforces the emitted + * `{repositories}` / `{folders}` policy (`policy_engine::scope`). + * + * Rendered only where scoping is meaningful (a single specific connection on an + * Allow without behavioral conditions — see the consumer in + * `_components/app-target-fields.tsx`). Providers with no granular config, or a + * connection that can't be scoped, render nothing. The EE editions alias this + * file to `@/ee/policy-editor/resource-scope`. */ export interface ResourceScopeFieldsProps { @@ -18,11 +26,56 @@ export interface ResourceScopeFieldsProps { onChange: (policy: Record | null) => void; } -export const ResourceScopeFields: ( - props: ResourceScopeFieldsProps, -) => React.JSX.Element = () => ( -

- Resource scoping (limit this connection to specific repositories or folders) - is not yet available in this build. -

-); +const asRecord = (value: unknown): Record => + value != null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; + +export const ResourceScopeFields = ({ + connection, + policy, + onChange, +}: ResourceScopeFieldsProps): React.JSX.Element | null => { + const config = granularAccessConfigs.get(connection.provider); + const metadata = asRecord(connection.metadata); + + // No granular axis for this provider, or this connection can't be scoped: + // there is nothing to narrow, so render nothing (the whole connection is + // reachable, matching a null policy). + if (!config || !config.isSupported(metadata)) { + return null; + } + + const items = config.getItems(metadata); + const selectedIds = config.getSelectedItems(policy ?? {}); + // Coerce an empty policy to null at the emit boundary: "no restriction" + // must clear the scope, not send `{}` — the API's strict `sessionPolicySchema` + // rejects an empty object (it requires a `repositories`/`folders` key), so a + // user deselecting all repos / removing all folders would otherwise 400. + const emit = (ids: string[]) => { + const p = config.buildPolicy(ids); + onChange(Object.keys(p).length ? p : null); + }; + + return ( +
+

Resource access

+ {items.length > 0 ? ( + + ) : ( + + )} +
+ ); +}; From 5581e1e6a3e400d326d47eafe5256a7098f52d7c Mon Sep 17 00:00:00 2001 From: marcorivm Date: Wed, 29 Jul 2026 22:25:26 -0600 Subject: [PATCH 07/10] feat(web): org policy page, identity picker, role-mappings UI, dead-code sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reconciliation Stage E (final feature stage). Mounts the OSS org policy route via the eeRoutes seam (the write path and org-capable editor already existed upstream — only the mount was missing, and removed-routes pointed at a dangling path); adds the org policy page at /policy. The identity picker is real again — people and user-groups only, no agent-groups — and safe now that the gateway (Stages F/G/H) enforces those principals, so it never writes rules the gateway ignores. Adds the role-mappings management section to /groups (create/edit/reorder/delete, raise-only copy, live blast-radius preview) that the backend had shipped without a UI. Sweeps the unused SSO/SCIM/domain clients, hooks, types, and query keys. No orphan-neutralization pass (inert under grants), no agent-group, no migration. --- .../groups/_components/groups-content.tsx | 11 +- .../_components/role-mapping-dialog.tsx | 194 +++ .../_components/role-mapping-row-actions.tsx | 158 ++ .../_components/role-mappings-section.tsx | 179 +++ .../src/app/(dashboard)/policy/loading.tsx | 30 + apps/web/src/app/(dashboard)/policy/page.tsx | 34 + apps/web/src/hooks/use-domains.ts | 53 - apps/web/src/hooks/use-scim-tokens.ts | 41 - apps/web/src/hooks/use-sso-connections.ts | 71 - apps/web/src/hooks/use-sso-enforcement.ts | 33 - apps/web/src/lib/api/domains.ts | 14 - apps/web/src/lib/api/index.ts | 16 - apps/web/src/lib/api/keys.ts | 16 - apps/web/src/lib/api/scim-tokens.ts | 12 - apps/web/src/lib/api/sso-connections.ts | 24 - apps/web/src/lib/api/sso-enforcement.ts | 10 - apps/web/src/lib/api/types.ts | 85 -- apps/web/src/lib/nav-config.ts | 4 + .../_components/identity-picker-section.tsx | 97 ++ .../src/lib/policy-editor/identity-picker.tsx | 322 +++- packages/api/src/routes/org/index.ts | 2 + packages/api/src/routes/org/policy.test.ts | 1342 +++++++++++++++++ packages/api/src/routes/org/policy.ts | 57 + 23 files changed, 2421 insertions(+), 384 deletions(-) create mode 100644 apps/web/src/app/(dashboard)/groups/_components/role-mapping-dialog.tsx create mode 100644 apps/web/src/app/(dashboard)/groups/_components/role-mapping-row-actions.tsx create mode 100644 apps/web/src/app/(dashboard)/groups/_components/role-mappings-section.tsx create mode 100644 apps/web/src/app/(dashboard)/policy/loading.tsx create mode 100644 apps/web/src/app/(dashboard)/policy/page.tsx delete mode 100644 apps/web/src/hooks/use-domains.ts delete mode 100644 apps/web/src/hooks/use-scim-tokens.ts delete mode 100644 apps/web/src/hooks/use-sso-connections.ts delete mode 100644 apps/web/src/hooks/use-sso-enforcement.ts delete mode 100644 apps/web/src/lib/api/domains.ts delete mode 100644 apps/web/src/lib/api/scim-tokens.ts delete mode 100644 apps/web/src/lib/api/sso-connections.ts delete mode 100644 apps/web/src/lib/api/sso-enforcement.ts create mode 100644 apps/web/src/lib/policy-editor/_components/identity-picker-section.tsx create mode 100644 packages/api/src/routes/org/policy.test.ts create mode 100644 packages/api/src/routes/org/policy.ts diff --git a/apps/web/src/app/(dashboard)/groups/_components/groups-content.tsx b/apps/web/src/app/(dashboard)/groups/_components/groups-content.tsx index 5479fe50..480cc11e 100644 --- a/apps/web/src/app/(dashboard)/groups/_components/groups-content.tsx +++ b/apps/web/src/app/(dashboard)/groups/_components/groups-content.tsx @@ -6,6 +6,7 @@ import { useGroups } from "@/hooks/use-groups"; import { AdminOnlyNotice } from "./admin-only-notice"; import { LocalModeNotice } from "./local-mode-notice"; import { GroupsTable } from "./groups-table"; +import { RoleMappingsSection } from "./role-mappings-section"; export interface GroupsContentProps { /** Threaded from the RSC page (server-only auth mode); false = local mode. */ @@ -43,5 +44,13 @@ export const GroupsContent = ({ groupsEnabled }: GroupsContentProps) => { if (groups.isError) return ; - return ; + return ( +
+ + {/* Role mappings live below the groups table: they map these groups to + org roles, so authoring them alongside the groups they reference keeps + the whole group-based access model on one page. */} + +
+ ); }; diff --git a/apps/web/src/app/(dashboard)/groups/_components/role-mapping-dialog.tsx b/apps/web/src/app/(dashboard)/groups/_components/role-mapping-dialog.tsx new file mode 100644 index 00000000..dfbab07b --- /dev/null +++ b/apps/web/src/app/(dashboard)/groups/_components/role-mapping-dialog.tsx @@ -0,0 +1,194 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@onecli/ui/components/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@onecli/ui/components/select"; +import { Button } from "@onecli/ui/components/button"; +import { Label } from "@onecli/ui/components/label"; +import { + useCreateRoleMapping, + useUpdateRoleMapping, + useRoleMappingPreview, +} from "@/hooks/use-role-mappings"; +import type { GroupRow, RoleMappingRow } from "@/lib/api"; + +export interface RoleMappingDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Groups selectable for a NEW mapping — the ones without a mapping already + * (a group maps to at most one role). Ignored when editing. */ + availableGroups: GroupRow[]; + /** Present = edit an existing mapping (group is fixed, only the role changes); + * absent = create. */ + mapping?: RoleMappingRow; +} + +type Role = "admin" | "member"; + +/** + * Create or edit a group→role mapping. On create the admin picks a group and a + * role; on edit the group is fixed (a mapping's group is its identity — only + * the granted role is editable) and the select is disabled. + * + * The live preview is the honest part: it asks the server how many members + * WOULD change role under the proposed mapping before it is written, so an + * admin sees the blast radius (a raise-only change, but still a change) up + * front rather than from a surprised teammate. + */ +export const RoleMappingDialog = ({ + open, + onOpenChange, + availableGroups, + mapping, +}: RoleMappingDialogProps) => { + const isEdit = mapping !== undefined; + const [groupId, setGroupId] = useState(mapping?.groupId ?? ""); + const [role, setRole] = useState(mapping?.role ?? "member"); + + const create = useCreateRoleMapping(); + const update = useUpdateRoleMapping(); + const pending = create.isPending || update.isPending; + + // Reset the form whenever the dialog (re)opens — a create after an edit must + // not inherit the edited mapping's group/role. + useEffect(() => { + if (open) { + setGroupId(mapping?.groupId ?? ""); + setRole(mapping?.role ?? "member"); + } + }, [open, mapping]); + + // Only preview once a group is chosen — the hook already no-ops on an empty + // groupId, but this keeps the query key stable. + const previewInput = useMemo( + () => (groupId ? { groupId, role } : null), + [groupId, role], + ); + const preview = useRoleMappingPreview(previewInput); + + const handleSubmit = () => { + if (!groupId || pending) return; + if (isEdit) { + update.mutate( + { id: mapping.id, input: { role } }, + { onSuccess: () => onOpenChange(false) }, + ); + } else { + create.mutate( + { groupId, role }, + { onSuccess: () => onOpenChange(false) }, + ); + } + }; + + const affected = preview.data?.affectedCount ?? 0; + + return ( + + + + + {isEdit ? "Edit role mapping" : "Add role mapping"} + + + Members of the group are granted at least this role. Mappings only + raise a member's role — they never lower it. + + + +
+
+ + +
+ +
+ + +
+ +

+ {!groupId + ? "Select a group to preview the impact." + : preview.isPending + ? "Checking impact…" + : preview.isError + ? "Couldn't preview the impact." + : affected === 0 + ? "No members would change role." + : `${affected} member${affected === 1 ? "" : "s"} would be raised to ${role}.`} +

+
+ + + + + +
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/groups/_components/role-mapping-row-actions.tsx b/apps/web/src/app/(dashboard)/groups/_components/role-mapping-row-actions.tsx new file mode 100644 index 00000000..e77b2f82 --- /dev/null +++ b/apps/web/src/app/(dashboard)/groups/_components/role-mapping-row-actions.tsx @@ -0,0 +1,158 @@ +"use client"; + +import { useState } from "react"; +import { ArrowDown, ArrowUp, Loader2, MoreHorizontal } from "lucide-react"; +import { Button } from "@onecli/ui/components/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@onecli/ui/components/dropdown-menu"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@onecli/ui/components/alert-dialog"; +import { useDeleteRoleMapping } from "@/hooks/use-role-mappings"; +import type { GroupRow, RoleMappingRow } from "@/lib/api"; +import { RoleMappingDialog } from "./role-mapping-dialog"; + +export interface RoleMappingRowActionsProps { + mapping: RoleMappingRow; + /** Groups selectable when editing — the edit dialog fixes the group, so this + * only needs to name the current one, but the dialog shares the prop. */ + groups: GroupRow[]; + canMoveUp: boolean; + canMoveDown: boolean; + /** Move this mapping one step higher/lower in priority. Owned by the section + * (it holds the single reorder mutation over the full ordered set). */ + onMove: (direction: "up" | "down") => void; + /** A reorder is in flight for the whole list — lock the move controls. */ + reordering: boolean; +} + +/** + * Per-row controls for a role mapping: reorder (priority is first-match, so the + * order is load-bearing), edit the granted role, and delete. Reordering is + * lifted to the section so a single `reorder` call carries the whole ordered + * id set (a partial order 409s server-side). + */ +export const RoleMappingRowActions = ({ + mapping, + groups, + canMoveUp, + canMoveDown, + onMove, + reordering, +}: RoleMappingRowActionsProps) => { + const [editOpen, setEditOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + const remove = useDeleteRoleMapping(); + + return ( +
+ + + + + + + + + setEditOpen(true)}> + Edit role + + + setDeleteOpen(true)} + > + Delete + + + + + + + + + + + Delete the {mapping.groupName} mapping? + + + Members of {mapping.groupName} will no longer be raised to{" "} + {mapping.role} through this mapping. Anyone whose role was granted + only by it reverts to their base role. This cannot be undone. + + + + + Cancel + + { + e.preventDefault(); + remove.mutate(mapping.id, { + onSuccess: () => setDeleteOpen(false), + }); + }} + disabled={remove.isPending} + > + {remove.isPending ? ( + <> + + Deleting... + + ) : ( + "Delete" + )} + + + + +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/groups/_components/role-mappings-section.tsx b/apps/web/src/app/(dashboard)/groups/_components/role-mappings-section.tsx new file mode 100644 index 00000000..3459cc5a --- /dev/null +++ b/apps/web/src/app/(dashboard)/groups/_components/role-mappings-section.tsx @@ -0,0 +1,179 @@ +"use client"; + +import { useState } from "react"; +import { Lock, Plus, Shuffle } from "lucide-react"; +import { Badge } from "@onecli/ui/components/badge"; +import { Button } from "@onecli/ui/components/button"; +import { Card } from "@onecli/ui/components/card"; +import { Skeleton } from "@onecli/ui/components/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@onecli/ui/components/table"; +import { useGroups } from "@/hooks/use-groups"; +import { + useReorderRoleMappings, + useRoleMappings, +} from "@/hooks/use-role-mappings"; +import { RoleMappingDialog } from "./role-mapping-dialog"; +import { RoleMappingRowActions } from "./role-mapping-row-actions"; + +export interface RoleMappingsSectionProps { + /** Threaded from the RSC page — false = local auth mode (no org backend). */ + groupsEnabled: boolean; +} + +/** + * Group→role mappings: members of a mapped group are granted at least its role. + * + * Ordering is load-bearing. Mappings are first-match by priority (top of the + * list wins), and the effect is MONOTONIC — a mapping can only RAISE a member's + * role, never lower it. So a member in several mapped groups lands on the + * highest role any applicable mapping grants, and reordering only matters where + * mappings would otherwise disagree. + * + * The section sits below the groups table on `/groups`. It renders for admins + * only: the parent already replaces the whole surface with the admin-only + * notice when the groups directory 403s, so a non-admin never reaches this. + * The defensive error branch stays for the rare case the two reads disagree. + */ +export const RoleMappingsSection = ({ + groupsEnabled, +}: RoleMappingsSectionProps) => { + const mappings = useRoleMappings(groupsEnabled); + // The create dialog offers groups that DON'T already have a mapping (a group + // maps to at most one role — a second create 409s server-side). + const groups = useGroups(groupsEnabled); + const reorder = useReorderRoleMappings(); + const [createOpen, setCreateOpen] = useState(false); + + // Inert without an org backend — the parent returns before this in local mode, + // but guard anyway so an accidental mount fires no doomed request. + if (!groupsEnabled) return null; + + const rows = mappings.data ?? []; + const mappedGroupIds = new Set(rows.map((m) => m.groupId)); + const availableGroups = (groups.data ?? []).filter( + (g) => !mappedGroupIds.has(g.id), + ); + + const move = (index: number, direction: "up" | "down") => { + const target = direction === "up" ? index - 1 : index + 1; + if (target < 0 || target >= rows.length) return; + const orderedIds = rows.map((m) => m.id); + const moved = orderedIds.splice(index, 1); + orderedIds.splice(target, 0, ...moved); + reorder.mutate(orderedIds); + }; + + return ( +
+
+
+

Role mappings

+

+ Grant members of a group an organization role automatically. + Mappings only raise a + member's role, never lower it. When several apply, the + highest-priority mapping wins — top of the list first. +

+
+ +
+ + {mappings.isPending ? ( + +
+ + +
+
+ ) : mappings.isError ? ( + +
+ +
+

Admins only

+

+ Managing role mappings requires an organization admin. +

+
+ ) : rows.length === 0 ? ( + +
+ +
+

No role mappings yet

+

+ Map a group to a role so its members are granted it automatically. +

+
+ ) : ( + + + + + Priority + Group + Grants role + Members + Order + + + + {rows.map((mapping, index) => ( + + + {index + 1} + + + {mapping.groupName} + + + + {mapping.role} + + + + {mapping.memberCount} + + + 0} + canMoveDown={index < rows.length - 1} + onMove={(direction) => move(index, direction)} + reordering={reorder.isPending} + /> + + + ))} + +
+
+ )} + + +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/policy/loading.tsx b/apps/web/src/app/(dashboard)/policy/loading.tsx new file mode 100644 index 00000000..2b7a7a0f --- /dev/null +++ b/apps/web/src/app/(dashboard)/policy/loading.tsx @@ -0,0 +1,30 @@ +import { Card } from "@onecli/ui/components/card"; +import { Skeleton } from "@onecli/ui/components/skeleton"; + +/** + * Route-level skeleton. Mirrors the page frame (heading + rule cards) so the + * layout doesn't jump when the client editor mounts. + */ +export default function PolicyLoading() { + return ( +
+
+ + +
+
+ {[1, 2].map((i) => ( + +
+
+ + +
+ +
+
+ ))} +
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/policy/page.tsx b/apps/web/src/app/(dashboard)/policy/page.tsx new file mode 100644 index 00000000..f41c687b --- /dev/null +++ b/apps/web/src/app/(dashboard)/policy/page.tsx @@ -0,0 +1,34 @@ +import { Suspense } from "react"; +import type { Metadata } from "next"; +import { PageHeader } from "@dashboard/page-header"; +import { PolicyEditor } from "@/lib/policy-editor"; + +export const metadata: Metadata = { + title: "Policy", +}; + +/** + * The ORGANIZATION policy surface — the guardrails every project is evaluated + * against. The gateway evaluates these rules alongside each project's own + * policy and takes the stricter verdict (`policy_engine/evaluate.rs`), so they + * override nothing and can only tighten. + * + * A single scope: project-scope authoring retired in attach-model step 6 + * (`/v1/policy/*` is 410'd; project rules compile from agent grants), so there + * is no scope switcher. No server-side role resolution — the API's 403 is the + * authority on who is an admin, and `PolicyEditor` renders the degrade when the + * org policy read fails. + */ +export default function PolicyPage() { + return ( +
+ + + + +
+ ); +} diff --git a/apps/web/src/hooks/use-domains.ts b/apps/web/src/hooks/use-domains.ts deleted file mode 100644 index b1fc29e4..00000000 --- a/apps/web/src/hooks/use-domains.ts +++ /dev/null @@ -1,53 +0,0 @@ -"use client"; - -// No gateway-cache involvement: domains never affect agent traffic routing, -// so neither these hooks nor the API routes flush the gateway. - -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { toast } from "sonner"; -import { domains } from "@/lib/api"; -import { queryKeys } from "@/lib/api/keys"; - -export const useDomains = () => - useQuery({ - queryKey: queryKeys.domains.list(), - queryFn: () => domains.list(), - }); - -export const useCreateDomain = () => { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (domain: string) => domains.create(domain), - onSuccess: () => { - qc.invalidateQueries({ queryKey: queryKeys.domains.all() }); - toast.success("Domain added — publish the TXT record to verify it"); - }, - // Surface the server reason (blocklist, already claimed, invalid shape). - onError: (err) => toast.error(err.message), - }); -}; - -export const useVerifyDomain = () => { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (domainId: string) => domains.verify(domainId), - onSuccess: () => { - qc.invalidateQueries({ queryKey: queryKeys.domains.all() }); - toast.success("Domain verified"); - }, - // Usually "TXT record not found yet" — show the server message. - onError: (err) => toast.error(err.message), - }); -}; - -export const useDeleteDomain = () => { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (domainId: string) => domains.remove(domainId), - onSuccess: () => { - qc.invalidateQueries({ queryKey: queryKeys.domains.all() }); - toast.success("Domain removed"); - }, - onError: () => toast.error("Failed to remove domain"), - }); -}; diff --git a/apps/web/src/hooks/use-scim-tokens.ts b/apps/web/src/hooks/use-scim-tokens.ts deleted file mode 100644 index 4338d8c6..00000000 --- a/apps/web/src/hooks/use-scim-tokens.ts +++ /dev/null @@ -1,41 +0,0 @@ -"use client"; - -// No gateway involvement: SCIM tokens gate the provisioning endpoint, not -// agent traffic — nothing here flushes the gateway cache. - -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { toast } from "sonner"; -import { scimTokens } from "@/lib/api"; -import { queryKeys } from "@/lib/api/keys"; - -export const useScimTokens = () => - useQuery({ - queryKey: queryKeys.scimTokens.list(), - queryFn: () => scimTokens.list(), - }); - -export const useCreateScimToken = () => { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (label: string) => scimTokens.create(label), - onSuccess: () => { - // No success toast — the show-once dialog IS the confirmation. - qc.invalidateQueries({ queryKey: queryKeys.scimTokens.all() }); - }, - onError: (err) => toast.error(err.message), - }); -}; - -export const useRevokeScimToken = () => { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (tokenId: string) => scimTokens.revoke(tokenId), - onSuccess: () => { - qc.invalidateQueries({ queryKey: queryKeys.scimTokens.all() }); - toast.success( - "Token revoked — provisioning requests with it stop immediately", - ); - }, - onError: () => toast.error("Failed to revoke token"), - }); -}; diff --git a/apps/web/src/hooks/use-sso-connections.ts b/apps/web/src/hooks/use-sso-connections.ts deleted file mode 100644 index 8fc49332..00000000 --- a/apps/web/src/hooks/use-sso-connections.ts +++ /dev/null @@ -1,71 +0,0 @@ -"use client"; - -// No gateway-cache involvement: SSO connections configure login, not agent -// traffic — neither these hooks nor the API routes flush the gateway. - -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { toast } from "sonner"; -import { ssoConnections } from "@/lib/api"; -import type { - CreateSsoConnectionInput, - UpdateSsoConnectionInput, -} from "@/lib/api"; -import { queryKeys } from "@/lib/api/keys"; - -export const useSsoConnections = () => - useQuery({ - queryKey: queryKeys.ssoConnections.list(), - queryFn: () => ssoConnections.list(), - }); - -export const useCreateSsoConnection = () => { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (input: CreateSsoConnectionInput) => - ssoConnections.create(input), - onSuccess: () => { - qc.invalidateQueries({ queryKey: queryKeys.ssoConnections.all() }); - toast.success("SSO connection created"); - }, - // Surface the server reason (duplicate, Cognito rejection, lock busy). - onError: (err) => toast.error(err.message), - }); -}; - -export const useUpdateSsoConnection = () => { - const qc = useQueryClient(); - return useMutation({ - mutationFn: ({ - connectionId, - input, - }: { - connectionId: string; - input: UpdateSsoConnectionInput; - }) => ssoConnections.update(connectionId, input), - onSuccess: () => { - qc.invalidateQueries({ queryKey: queryKeys.ssoConnections.all() }); - toast.success("SSO connection updated"); - }, - onError: (err) => toast.error(err.message), - }); -}; - -export const useDeleteSsoConnection = () => { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (connectionId: string) => ssoConnections.remove(connectionId), - onSuccess: () => { - qc.invalidateQueries({ queryKey: queryKeys.ssoConnections.all() }); - toast.success("SSO connection removed"); - }, - onError: (err) => toast.error(err.message), - }); -}; - -// Returns the per-check results for inline rendering — the caller decides -// how to present them; only hard errors toast. -export const useTestSsoConnection = () => - useMutation({ - mutationFn: (connectionId: string) => ssoConnections.test(connectionId), - onError: (err) => toast.error(err.message), - }); diff --git a/apps/web/src/hooks/use-sso-enforcement.ts b/apps/web/src/hooks/use-sso-enforcement.ts deleted file mode 100644 index a0e576c1..00000000 --- a/apps/web/src/hooks/use-sso-enforcement.ts +++ /dev/null @@ -1,33 +0,0 @@ -"use client"; - -// Require-SSO is a LOGIN policy — no gateway-cache involvement (agent -// traffic is unaffected), so neither these hooks nor the API routes flush -// the gateway. Same posture as use-domains / use-sso-connections. - -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { toast } from "sonner"; -import { ssoEnforcement } from "@/lib/api"; -import { queryKeys } from "@/lib/api/keys"; - -export const useSsoEnforcement = () => - useQuery({ - queryKey: queryKeys.ssoEnforcement.get(), - queryFn: () => ssoEnforcement.get(), - }); - -export const useUpdateSsoEnforcement = () => { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (ssoRequired: boolean) => ssoEnforcement.update(ssoRequired), - onSuccess: (state) => { - qc.invalidateQueries({ queryKey: queryKeys.ssoEnforcement.all() }); - toast.success( - state.ssoRequired - ? "Single sign-on is now required for this organization" - : "Single sign-on is no longer required", - ); - }, - // Surface the server reason (missing precondition, plan gate). - onError: (err) => toast.error(err.message), - }); -}; diff --git a/apps/web/src/lib/api/domains.ts b/apps/web/src/lib/api/domains.ts deleted file mode 100644 index d348acd7..00000000 --- a/apps/web/src/lib/api/domains.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { apiGet, apiPost, apiDelete } from "./client"; -import type { OrgDomain } from "./types"; - -// Org email domains are organization-scoped only — no project variant. -const base = "/v1/org/domains"; - -export const list = () => apiGet(base); - -export const create = (domain: string) => apiPost(base, { domain }); - -export const verify = (domainId: string) => - apiPost(`${base}/${domainId}/verify`, {}); - -export const remove = (domainId: string) => apiDelete(`${base}/${domainId}`); diff --git a/apps/web/src/lib/api/index.ts b/apps/web/src/lib/api/index.ts index 08c53b62..c13edee4 100644 --- a/apps/web/src/lib/api/index.ts +++ b/apps/web/src/lib/api/index.ts @@ -5,14 +5,10 @@ import * as connections from "./connections"; import * as grants from "./grants"; import * as projects from "./projects"; import * as projectAccess from "./project-access"; -import * as domains from "./domains"; import * as orgMembers from "./org-members"; import * as invitations from "./invitations"; import * as groups from "./groups"; import * as roleMappings from "./role-mappings"; -import * as ssoConnections from "./sso-connections"; -import * as ssoEnforcement from "./sso-enforcement"; -import * as scimTokens from "./scim-tokens"; import * as counts from "./counts"; import * as appBlocklist from "./app-blocklist"; import * as appConfig from "./app-config"; @@ -29,14 +25,10 @@ export { grants, projects, projectAccess, - domains, orgMembers, invitations, groups, roleMappings, - ssoConnections, - ssoEnforcement, - scimTokens, counts, appBlocklist, appConfig, @@ -56,8 +48,6 @@ export type { ProjectAccessUserRow, ProjectAccessGroupRow, SetProjectAccessInput, - OrgDomain, - OrgSsoEnforcement, OrgMemberRow, UpdatedOrgMember, UpdateOrgMemberInput, @@ -72,12 +62,6 @@ export type { UpdateRoleMappingInput, RoleMappingImpact, OrgMemberListRow, - OrgSsoConnection, - SsoTestResult, - CreateSsoConnectionInput, - UpdateSsoConnectionInput, - ScimToken, - CreatedScimToken, ResourceCounts, CreateAgentInput, CreateSecretInput, diff --git a/apps/web/src/lib/api/keys.ts b/apps/web/src/lib/api/keys.ts index cecc88c7..52784fc3 100644 --- a/apps/web/src/lib/api/keys.ts +++ b/apps/web/src/lib/api/keys.ts @@ -22,10 +22,6 @@ export const queryKeys = { lastPublish: (pageScope: PageScope = "project") => [...queryKeys.policy.all(), "last-publish", pageScope] as const, }, - domains: { - all: () => ["domains", ...scope()] as const, - list: () => [...queryKeys.domains.all(), "list"] as const, - }, groups: { all: () => ["groups", ...scope()] as const, list: () => [...queryKeys.groups.all(), "list"] as const, @@ -44,18 +40,6 @@ export const queryKeys = { all: () => ["invitations", ...scope()] as const, list: () => [...queryKeys.invitations.all(), "list"] as const, }, - ssoConnections: { - all: () => ["sso-connections", ...scope()] as const, - list: () => [...queryKeys.ssoConnections.all(), "list"] as const, - }, - ssoEnforcement: { - all: () => ["sso-enforcement", ...scope()] as const, - get: () => [...queryKeys.ssoEnforcement.all(), "get"] as const, - }, - scimTokens: { - all: () => ["scim-tokens", ...scope()] as const, - list: () => [...queryKeys.scimTokens.all(), "list"] as const, - }, grants: { all: () => ["grants", ...scope()] as const, agent: (agentId: string) => diff --git a/apps/web/src/lib/api/scim-tokens.ts b/apps/web/src/lib/api/scim-tokens.ts deleted file mode 100644 index 7b60331e..00000000 --- a/apps/web/src/lib/api/scim-tokens.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { apiGet, apiPost, apiDelete } from "./client"; -import type { ScimToken, CreatedScimToken } from "./types"; - -// SCIM provisioning tokens are organization-scoped only — no project variant. -const base = "/v1/org/scim/tokens"; - -export const list = () => apiGet(base); - -export const create = (label: string) => - apiPost(base, { label }); - -export const revoke = (tokenId: string) => apiDelete(`${base}/${tokenId}`); diff --git a/apps/web/src/lib/api/sso-connections.ts b/apps/web/src/lib/api/sso-connections.ts deleted file mode 100644 index f326fb90..00000000 --- a/apps/web/src/lib/api/sso-connections.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { apiGet, apiPost, apiPatch, apiDelete } from "./client"; -import type { - CreateSsoConnectionInput, - OrgSsoConnection, - SsoTestResult, - UpdateSsoConnectionInput, -} from "./types"; - -// SSO connections are organization-scoped only — no project variant. -const base = "/v1/org/sso/connections"; - -export const list = () => apiGet(base); - -export const create = (input: CreateSsoConnectionInput) => - apiPost(base, input); - -export const update = (connectionId: string, input: UpdateSsoConnectionInput) => - apiPatch(`${base}/${connectionId}`, input); - -export const remove = (connectionId: string) => - apiDelete(`${base}/${connectionId}`); - -export const test = (connectionId: string) => - apiPost(`${base}/${connectionId}/test`, {}); diff --git a/apps/web/src/lib/api/sso-enforcement.ts b/apps/web/src/lib/api/sso-enforcement.ts deleted file mode 100644 index 4b34a7a6..00000000 --- a/apps/web/src/lib/api/sso-enforcement.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { apiGet, apiPatch } from "./client"; -import type { OrgSsoEnforcement } from "./types"; - -// Organization-scoped only — the require-SSO login policy. -const base = "/v1/org/sso/enforcement"; - -export const get = () => apiGet(base); - -export const update = (ssoRequired: boolean) => - apiPatch(base, { ssoRequired }); diff --git a/apps/web/src/lib/api/types.ts b/apps/web/src/lib/api/types.ts index ee9e0f44..aca6766c 100644 --- a/apps/web/src/lib/api/types.ts +++ b/apps/web/src/lib/api/types.ts @@ -102,91 +102,6 @@ export interface SetProjectAccessInput { groupIds: string[]; } -export type SsoConnectionStatus = "pending" | "active" | "disabled"; - -// An org's SSO/IdP connection — the redacted API shape (the OIDC client -// secret never leaves the server). -export interface OrgSsoConnection { - id: string; - type: "saml" | "oidc"; - status: SsoConnectionStatus; - displayName: string; - cognitoProviderName: string; - config: { - metadataUrl?: string; - metadataXml?: string; - issuer?: string; - clientId?: string; - certExpiresAt?: string | null; - }; - createdAt: string; - updatedAt: string; -} - -export interface SsoTestCheck { - name: string; - ok: boolean; - detail?: string; -} - -export interface SsoTestResult { - ok: boolean; - checks: SsoTestCheck[]; -} - -export interface CreateSsoConnectionInput { - type: "saml" | "oidc"; - displayName: string; - metadataUrl?: string; - metadataXml?: string; - issuer?: string; - clientId?: string; - clientSecret?: string; -} - -export interface UpdateSsoConnectionInput { - displayName?: string; - enabled?: boolean; - metadataUrl?: string; - metadataXml?: string; - issuer?: string; - clientId?: string; - clientSecret?: string; -} - -// An org's claimed email domain. `verifiedAt` null = pending the DNS TXT -// check; the token is published in DNS, so it's safe to expose here. -export interface OrgDomain { - id: string; - domain: string; - verificationToken: string; - verifiedAt: string | null; - createdAt: string; -} - -// A bearer token for the org's /scim/v2 provisioning endpoint. Reads only -// ever carry metadata — the plaintext exists solely in the create response. -export interface ScimToken { - id: string; - label: string; - lastUsedAt: string | null; - createdAt: string; -} - -// POST /v1/org/scim/tokens — `token` is shown once and never retrievable. -export interface CreatedScimToken extends ScimToken { - token: string; -} - -// Require-SSO enforcement state (GET/PATCH /v1/org/sso/enforcement). -export interface OrgSsoEnforcement { - ssoRequired: boolean; - hasActiveConnection: boolean; - hasVerifiedDomain: boolean; - canRequire: boolean; - exemptMemberCount: number; -} - // PATCH /v1/org/members/:userId — exactly one change per request. `owner` is // not assignable here (owner transfer is a separate operation); the `ssoExempt` // arm is gone with the SSO feature it belonged to. diff --git a/apps/web/src/lib/nav-config.ts b/apps/web/src/lib/nav-config.ts index 0cb6bb93..fafc6dad 100644 --- a/apps/web/src/lib/nav-config.ts +++ b/apps/web/src/lib/nav-config.ts @@ -28,6 +28,10 @@ export const navItems: NavItem[] = [ { title: "Overview", url: "/overview", icon: LayoutDashboard }, { title: "Agents", url: "/agents", icon: Bot }, { title: "Connections", url: "/connections", icon: Plug }, + // Always visible: the organization policy surface degrades for non-admins + // (the API's 403 is the authority), so hiding it would require a session role + // field. Org rules are the guardrails every project is evaluated against. + { title: "Policy", url: "/policy", icon: ShieldCheck }, { title: "Activity", url: "/activity", icon: Activity }, // Always visible (D-J): the page itself degrades for non-admins and in // local auth mode — hiding the item would require a session role field. diff --git a/apps/web/src/lib/policy-editor/_components/identity-picker-section.tsx b/apps/web/src/lib/policy-editor/_components/identity-picker-section.tsx new file mode 100644 index 00000000..e99b292f --- /dev/null +++ b/apps/web/src/lib/policy-editor/_components/identity-picker-section.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { Checkbox } from "@onecli/ui/components/checkbox"; +import type { ProjectionIdentity } from "@/lib/api"; + +/** One selectable directory row: a stable id, a display label, and an optional + * second line (member counts / emails). */ +export interface IdentityPickerRow { + id: string; + label: string; + hint?: string | null; +} + +export interface IdentityPickerSectionProps { + /** The identity kind every row in this section produces. */ + type: Extract; + title: string; + /** The "project audience" caveat, rendered under People / Groups. */ + note?: string; + rows: IdentityPickerRow[]; + selected: Set; + onToggle: (type: IdentityPickerSectionProps["type"], id: string) => void; + /** Shown instead of the rows when the section resolved to nothing. */ + emptyLabel: string; + /** The section's directory read is still in flight. Distinct from an empty + * directory: `emptyLabel` asserts the org HAS no groups/people, which would + * be a false statement (and a wrong call to action) while loading. */ + pending?: boolean; + /** The section's directory read failed (a non-admin's 403, typically). */ + failed: boolean; +} + +/** + * One labelled block of the org identity picker. Kept in its own file (one + * component per file) and deliberately dumb: the picker owns the selection, + * the search filter, and every query. + */ +export const IdentityPickerSection = ({ + type, + title, + note, + rows, + selected, + onToggle, + emptyLabel, + pending = false, + failed, +}: IdentityPickerSectionProps) => ( +
+

{title}

+ {note &&

{note}

} + {failed ? ( +

+ Not available — organization directories are admin-only. +

+ ) : pending ? ( +

+ Loading… +

+ ) : rows.length === 0 ? ( +

{emptyLabel}

+ ) : ( +
+ {rows.map((row) => { + const inputId = `identity-${type}-${row.id}`; + return ( +
+ onToggle(type, row.id)} + /> + +
+ ); + })} +
+ )} +
+); diff --git a/apps/web/src/lib/policy-editor/identity-picker.tsx b/apps/web/src/lib/policy-editor/identity-picker.tsx index 2979506b..efd35392 100644 --- a/apps/web/src/lib/policy-editor/identity-picker.tsx +++ b/apps/web/src/lib/policy-editor/identity-picker.tsx @@ -1,14 +1,39 @@ "use client"; +import { useMemo, useState } from "react"; +import { Search } from "lucide-react"; +import { Badge } from "@onecli/ui/components/badge"; +import { Button } from "@onecli/ui/components/button"; +import { Input } from "@onecli/ui/components/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@onecli/ui/components/popover"; +import { useGroups } from "@/hooks/use-groups"; +import { useOrgMembersList } from "@/hooks/use-org-members"; import type { ProjectionIdentity } from "@/lib/api"; +import { + IdentityPickerSection, + type IdentityPickerRow, +} from "./_components/identity-picker-section"; /** - * The OSS identity-picker seam (step 9.5). Directory identities (users, - * user-groups) are not implemented in this build, and since attach-model step 6 - * the only policy console left is the ORG one — which OSS does not mount at - * all. So this stub can never render; it exists to keep the shared rule form - * compiling in an OSS build. The EE editions alias this file to - * `@/ee/policy-editor/identity-picker`. + * The OSS identity-picker seam. OSS mounts the organization policy scope + * (`/v1/org/policy`) and the Rust gateway ENFORCES org rules against a resolved + * principal set (`policy_engine/{loaders,assemble,evaluate}.rs`), so this + * picker authors real, enforced targeting. + * + * It offers exactly the identity kinds the API accepts at org scope and the + * gateway matches: `user`, `group`, and "none" (= any agent). Specific AGENTS + * are deliberately absent — `assertIdentitiesValid` 422s an `agent` identity on + * an org rule in every edition, so offering them would build a selection the + * server rejects. There is no agent-group kind on this base. + * + * The EE editions alias this file to `@/ee/policy-editor/identity-picker`; the + * path and the export name are the turbopack alias key + * (`next.config.js` → `POLICY_EDITOR_ALIASES`) and must not move or change + * shape. */ export interface OrgIdentityPickerProps { @@ -18,5 +43,286 @@ export interface OrgIdentityPickerProps { id?: string; } -export const OrgIdentityPicker: (props: OrgIdentityPickerProps) => null = () => - null; +type DirectoryKind = "user" | "group"; + +/** Proxied traffic carries NO connecting-user identity — `ProxyContext` is + * agent-only. A `user` / `group` principal therefore resolves to the projects + * that person (or group) can access, and matches EVERY agent in them. Said + * plainly here rather than left for an operator to discover from a block. */ +const AUDIENCE_NOTE = + "Matches any agent in a project this person or group can access — proxied requests carry no signed-in user."; + +const ALL_AGENTS_LABEL = "All agents in the organization"; +/** How many chips the trigger shows before collapsing into a "+N". */ +const CHIP_LIMIT = 3; + +/** Placeholder chip text for a principal whose directory never loaded — the + * kind is known from the identity row, the name is not. */ +const KIND_LABEL: Record = { + group: "Group", + user: "Person", +}; + +const idsOfKind = (value: ProjectionIdentity[], kind: DirectoryKind) => + new Set(value.flatMap((i) => (i.type === kind ? [i.id] : []))); + +const matches = (q: string, ...fields: (string | null | undefined)[]) => + !q || fields.some((f) => (f ?? "").toLowerCase().includes(q)); + +export const OrgIdentityPicker = ({ + value, + onChange, + id, +}: OrgIdentityPickerProps) => { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + + // Both feeds are the admin-only directories and both are `retry: false`, so a + // non-admin's 403 is deterministic and cheap. Only fetched while the popover + // is open — the rule drawer mounts this for every org rule, and the + // directories are otherwise unused there. + const groups = useGroups(open); + const members = useOrgMembersList(open); + + const groupRows = useMemo(() => groups.data ?? [], [groups.data]); + // Kept COMPLETE (suspended included) — it is what resolves chip names, and a + // suspended teammate an older rule already names must render as themselves, + // not as "Unknown (removed)". The selectable set is narrowed below. + const memberRows = useMemo(() => members.data ?? [], [members.data]); + + // Every directory failed → there is nothing to pick from. NOT gated on + // `open`: the notice under the trigger has to survive the popover closing, + // else the picker silently reads "All agents in the organization" with no + // hint that the feeds failed. + const allFailed = groups.isError && members.isError; + /** Per-kind resolution: a chip is only "removed" when ITS OWN directory + * loaded and did not contain it. One feed succeeding says nothing about the + * other. */ + const loaded: Record = { + group: groups.isSuccess, + user: members.isSuccess, + }; + + const selected = useMemo( + () => ({ + group: idsOfKind(value, "group"), + user: idsOfKind(value, "user"), + }), + [value], + ); + + const toggle = (type: DirectoryKind, rowId: string) => { + const present = value.some((i) => i.type === type && i.id === rowId); + onChange( + present + ? value.filter((i) => !(i.type === type && i.id === rowId)) + : [...value, { type, id: rowId }], + ); + }; + + const q = search.trim().toLowerCase(); + + const groupOptions: IdentityPickerRow[] = groupRows + .filter((g) => matches(q, g.name)) + .map((g) => ({ + id: g.id, + label: g.name, + hint: `${g.memberCount} member${g.memberCount === 1 ? "" : "s"}`, + })); + // SUSPENDED members are not OFFERED: `assertIdentitiesValid` counts users + // with `status: { not: "suspended" }` and 422s a miss with the generic "A + // referenced identity does not belong to this organization", so picking one + // would build a selection the server refuses — for a reason the toast gets + // wrong. One already on the rule stays listed (labelled), so it can be + // unselected — which is exactly what makes the rule saveable again. + const memberOptions: IdentityPickerRow[] = memberRows + .filter((m) => m.status !== "suspended" || selected.user.has(m.userId)) + .filter((m) => matches(q, m.email, m.name)) + .map((m) => ({ + id: m.userId, + label: m.name ?? m.email, + hint: + m.status === "suspended" + ? "Suspended — remove to save this rule" + : m.name + ? m.email + : null, + })); + + const nameOf = (identity: ProjectionIdentity): string | null => { + switch (identity.type) { + case "group": + return groupRows.find((g) => g.id === identity.id)?.name ?? null; + case "user": { + const row = memberRows.find((m) => m.userId === identity.id); + return row ? (row.name ?? row.email) : null; + } + default: + return null; + } + }; + + /** + * A chip's text and whether it may claim the principal is GONE. "Removed" is + * asserted only when the principal's OWN directory loaded and did not contain + * it — the visible face of the identity cascade (the server disables + * wholly-orphaned rules, but a multi-principal rule keeps its remaining + * targets and loses this one). A directory that 403'd or failed in transport + * renders a neutral kind placeholder instead: the principal exists, this + * build just cannot name it. + */ + const chip = ( + identity: ProjectionIdentity, + ): { label: string; removed: boolean; title?: string } => { + const kind = identity.type; + if (kind !== "group" && kind !== "user") + return { label: identity.id, removed: false }; + if (!loaded[kind]) + return { + label: KIND_LABEL[kind], + removed: false, + title: + "Still targeted — this directory could not be loaded, so its name is unavailable.", + }; + const name = nameOf(identity); + return name + ? { label: name, removed: false } + : { + label: "Unknown (removed)", + removed: true, + title: + "This principal no longer exists — the rule no longer targets it.", + }; + }; + // NOTHING loaded (no feed has succeeded yet) → render the count rather than a + // row of bare kind placeholders. + const resolvable = loaded.group || loaded.user; + + const shown = value.slice(0, CHIP_LIMIT); + const overflow = value.length - shown.length; + + return ( +
+ + + + + +
+
+
+
+

+ {value.length === 0 + ? ALL_AGENTS_LABEL + : `${value.length} selected`} +

+ +
+
+ +
+ + +
+
+
+ {allFailed && ( +

+ Organization directories are admin-only.{" "} + {value.length === 0 + ? "This rule applies to all agents in the organization." + : "This rule's existing targets are preserved, but they can't be listed or changed here."} +

+ )} +
+ ); +}; diff --git a/packages/api/src/routes/org/index.ts b/packages/api/src/routes/org/index.ts index d14c5b6c..49ae7484 100644 --- a/packages/api/src/routes/org/index.ts +++ b/packages/api/src/routes/org/index.ts @@ -4,6 +4,7 @@ import { orgMemberRoutes } from "./members"; import { orgInvitationRoutes } from "./invitations"; import { orgGroupRoutes } from "./groups"; import { orgRoleMappingRoutes } from "./role-mappings"; +import { ossOrgPolicyRoutes } from "./policy"; import { ossProjectRoutes } from "./projects"; /** @@ -33,5 +34,6 @@ export const registerOssOrgRoutes = (app: Hono) => { app.route("/org/invitations", orgInvitationRoutes()); app.route("/org/groups", orgGroupRoutes()); app.route("/org/role-mappings", orgRoleMappingRoutes()); + app.route("/org/policy", ossOrgPolicyRoutes()); app.route("/projects", ossProjectRoutes()); }; diff --git a/packages/api/src/routes/org/policy.test.ts b/packages/api/src/routes/org/policy.test.ts new file mode 100644 index 00000000..71b5b5c0 --- /dev/null +++ b/packages/api/src/routes/org/policy.test.ts @@ -0,0 +1,1342 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Hono } from "hono"; +import type { ApiEnv } from "../../types"; + +// `/v1/org/policy` end-to-end through the real app: the OSS org routes mounted +// on the `eeRoutes` seam, the OSS role resolver wired as the RoleResolver, and +// `CAPS.rbac` on. Admin callers arrive with an ORG API key; the non-admin cases +// use a session, since a non-admin's org key fails key authentication outright. +// (Same harness shape as groups.test.ts — cloned, not shared — with an +// in-memory `policy_rules_v2` + identity/target row store, because the whole +// point of this router is which SCOPE the shared policy handlers write in.) + +const ORG = "org-1"; +const OTHER_ORG = "org-2"; +const PROJECT = "proj-1"; +const OWNER = "user-owner"; +const ADMIN = "user-admin"; +const MEMBER = "user-member"; +const SUSPENDED = "user-suspended"; +const OUTSIDER = "user-outsider"; +const ADMIN_KEY = "oc_org_admin-key"; +const OTHER_ADMIN_KEY = "oc_org_outsider-key"; +const PROJECT_KEY = "oc_project-key-of-owner"; + +vi.hoisted(() => { + process.env.NEXT_PUBLIC_EDITION = "oss"; + process.env.SECRET_ENCRYPTION_KEY = "test-secret"; + process.env.OAUTH_STATE_SECRET = "test-secret"; +}); + +interface MemberRow { + organizationId: string; + userId: string; + userEmail: string; + role: string; + status: string; + ssoExempt: boolean; + suspendedAt: Date | null; + createdAt: Date; +} + +interface UserRow { + id: string; + externalAuthId: string; + email: string; + name: string | null; +} + +interface RuleRow { + id: string; + scope: string; + organizationId: string | null; + projectId: string | null; + status: string; + generation: number; + priority: number; + enabled: boolean; + isDefault: boolean; + logicalId: string; + source: string; + name: string; + description: string | null; + action: string; + rateLimit: number | null; + rateLimitWindow: string | null; + requireApproval: boolean; + conditions: unknown; + createdByUserId: string | null; + createdAt: Date; + updatedAt: Date; +} + +interface IdentityRow { + id: string; + ruleId: string; + agentId: string | null; + userId: string | null; + groupId: string | null; +} + +interface TargetRow { + id: string; + ruleId: string; + kind: string; + appProvider: string | null; + appTools: string[]; + appConnectionScope: string | null; + appConnectionId: string | null; + secretId: string | null; + secretScope: string | null; + hostPattern: string | null; + pathPattern: string | null; + method: string | null; +} + +interface DirectoryRow { + id: string; + organizationId: string; +} + +interface ResourceRow { + id: string; + organizationId: string | null; + projectId: string | null; + scope: string; +} + +interface AuditRow { + organizationId?: string; + projectId?: string; + userId: string; + action: string; + service: string; + source: string; + metadata: Record; +} + +const store = vi.hoisted(() => ({ + members: [] as MemberRow[], + users: [] as UserRow[], + rules: [] as RuleRow[], + identities: [] as IdentityRow[], + targets: [] as TargetRow[], + groups: [] as DirectoryRow[], + connections: [] as ResourceRow[], + secrets: [] as ResourceRow[], + audits: [] as AuditRow[], + seq: 0, + /** Which user the session provider resolves to (null = no session). */ + sessionUserId: null as string | null, +})); + +vi.mock("@onecli/db", () => { + class PrismaClientKnownRequestError extends Error { + code: string; + constructor(message: string, code: string) { + super(message); + this.code = code; + } + } + + // The subset of `where` shapes the policy service actually builds. + interface RuleWhere { + id?: string; + scope?: string; + organizationId?: string; + projectId?: string; + status?: string; + isDefault?: boolean; + generation?: number | { lte: number }; + } + interface OrgMemberWhere { + organizationId?: string; + userId?: string | { in: string[] }; + role?: string | { not?: string }; + status?: string | { not?: string }; + } + interface IdWhere { + id?: { in: string[] }; + organizationId?: string; + projectId?: string; + scope?: string; + } + + const nextId = (prefix: string) => `${prefix}-${++store.seq}`; + + const matchesRule = (row: RuleRow, where: RuleWhere = {}) => { + if (where.id !== undefined && row.id !== where.id) return false; + if (where.scope !== undefined && row.scope !== where.scope) return false; + // `undefined` means "not fenced"; the service always passes exactly one of + // organizationId / projectId (policyScope's invariant). + if ( + where.organizationId !== undefined && + row.organizationId !== where.organizationId + ) + return false; + if (where.projectId !== undefined && row.projectId !== where.projectId) + return false; + if (where.status !== undefined && row.status !== where.status) return false; + if (where.isDefault !== undefined && row.isDefault !== where.isDefault) + return false; + if (where.generation !== undefined) { + if (typeof where.generation === "number") { + if (row.generation !== where.generation) return false; + } else if (row.generation > where.generation.lte) return false; + } + return true; + }; + + const filterRules = (where?: RuleWhere) => + store.rules.filter((row) => matchesRule(row, where)); + + const withRelations = ( + row: RuleRow, + include?: { identities?: boolean; targets?: boolean }, + ) => ({ + ...row, + ...(include?.identities + ? { identities: store.identities.filter((i) => i.ruleId === row.id) } + : {}), + ...(include?.targets + ? { targets: store.targets.filter((t) => t.ruleId === row.id) } + : {}), + }); + + const pickRule = ( + row: RuleRow, + select?: Record, + include?: { identities?: boolean; targets?: boolean }, + ) => { + if (!select) return withRelations(row, include); + const picked: Record = {}; + for (const key of Object.keys(select)) { + if (select[key]) picked[key] = row[key as keyof RuleRow]; + } + return picked; + }; + + /** A nested identity `create` entry — `{ group: { connect: { id } } }`. */ + interface IdentityCreate { + agent?: { connect: { id: string } }; + user?: { connect: { id: string } }; + group?: { connect: { id: string } }; + } + /** A nested target `create` entry — scalars plus optional relation connects. */ + interface TargetCreate { + kind: string; + appProvider?: string | null; + appTools?: string[]; + appConnectionScope?: string | null; + appConnection?: { connect: { id: string } }; + secret?: { connect: { id: string } }; + secretId?: string | null; + secretScope?: string | null; + hostPattern?: string | null; + pathPattern?: string | null; + method?: string | null; + } + interface RuleCreateData { + scope: string; + organizationId?: string; + projectId?: string; + status?: string; + generation?: number; + priority: number; + enabled?: boolean; + isDefault?: boolean; + logicalId?: string; + source?: string; + name: string; + description?: string | null; + action: string; + rateLimit?: number | null; + rateLimitWindow?: string | null; + requireApproval?: boolean; + conditions?: unknown; + createdByUserId?: string | null; + identities?: { create: IdentityCreate[] }; + targets?: { create: TargetCreate[] }; + } + + const writeIdentities = (ruleId: string, entries: IdentityCreate[]) => { + for (const entry of entries) { + // The DB CHECK allows exactly one principal per row; the mock mirrors it + // so a malformed nested create surfaces here instead of silently storing + // an all-null row that would decode as "any". + const row: IdentityRow = { + id: nextId("pri"), + ruleId, + agentId: entry.agent?.connect.id ?? null, + userId: entry.user?.connect.id ?? null, + groupId: entry.group?.connect.id ?? null, + }; + store.identities.push(row); + } + }; + + const writeTargets = (ruleId: string, entries: TargetCreate[]) => { + for (const entry of entries) { + store.targets.push({ + id: nextId("tgt"), + ruleId, + kind: entry.kind, + appProvider: entry.appProvider ?? null, + appTools: entry.appTools ?? [], + appConnectionScope: entry.appConnectionScope ?? null, + appConnectionId: entry.appConnection?.connect.id ?? null, + secretId: entry.secret?.connect.id ?? entry.secretId ?? null, + secretScope: entry.secretScope ?? null, + hostPattern: entry.hostPattern ?? null, + pathPattern: entry.pathPattern ?? null, + method: entry.method ?? null, + }); + } + }; + + const createRule = ({ + data, + include, + }: { + data: RuleCreateData; + include?: { identities?: boolean; targets?: boolean }; + }) => { + const now = new Date(); + const row: RuleRow = { + id: nextId("r"), + scope: data.scope, + organizationId: data.organizationId ?? null, + projectId: data.projectId ?? null, + status: data.status ?? "draft", + generation: data.generation ?? 0, + priority: data.priority, + enabled: data.enabled ?? true, + isDefault: data.isDefault ?? false, + // `@default(gen_random_uuid())` in the schema — a snapshot copies its + // source draft's, which is what keeps rate counters stable. + logicalId: data.logicalId ?? nextId("logical"), + source: data.source ?? "custom", + name: data.name, + description: data.description ?? null, + action: data.action, + rateLimit: data.rateLimit ?? null, + rateLimitWindow: data.rateLimitWindow ?? null, + requireApproval: data.requireApproval ?? false, + conditions: data.conditions ?? null, + createdByUserId: data.createdByUserId ?? null, + createdAt: now, + updatedAt: now, + }; + store.rules.push(row); + writeIdentities(row.id, data.identities?.create ?? []); + writeTargets(row.id, data.targets?.create ?? []); + return withRelations(row, include); + }; + + const dropRules = (ids: Set) => { + store.rules = store.rules.filter((r) => !ids.has(r.id)); + // The FK cascade on PolicyRuleIdentity / PolicyRuleTarget. + store.identities = store.identities.filter((i) => !ids.has(i.ruleId)); + store.targets = store.targets.filter((t) => !ids.has(t.ruleId)); + }; + + const policyRuleV2 = { + aggregate: async ({ + where, + _max, + }: { + where?: RuleWhere; + _max?: { generation?: boolean; priority?: boolean }; + }) => { + const rows = filterRules(where); + const max = (values: number[]) => + values.length === 0 ? null : Math.max(...values); + return { + _max: { + ...(_max?.generation + ? { generation: max(rows.map((r) => r.generation)) } + : {}), + ...(_max?.priority + ? { priority: max(rows.map((r) => r.priority)) } + : {}), + }, + }; + }, + count: async ({ where }: { where?: RuleWhere }) => + filterRules(where).length, + findMany: async ({ + where, + include, + select, + }: { + where?: RuleWhere; + orderBy?: unknown; + include?: { identities?: boolean; targets?: boolean }; + select?: Record; + }) => + filterRules(where) + .slice() + // The service always asks for [{ priority: asc }, { id: asc }] — the + // exact order the gateway's loader mirrors, so it is hard-coded here + // rather than interpreted. + .sort((a, b) => a.priority - b.priority || a.id.localeCompare(b.id)) + .map((row) => pickRule(row, select, include)), + findFirst: async ({ + where, + include, + select, + }: { + where?: RuleWhere; + include?: { identities?: boolean; targets?: boolean }; + select?: Record; + }) => { + const row = filterRules(where) + .slice() + .sort((a, b) => a.priority - b.priority || a.id.localeCompare(b.id))[0]; + return row ? pickRule(row, select, include) : null; + }, + create: createRule, + update: async ({ + where, + data, + include, + }: { + where: { id: string }; + data: Partial & { + identities?: { create: IdentityCreate[] }; + targets?: { create: TargetCreate[] }; + }; + include?: { identities?: boolean; targets?: boolean }; + }) => { + const row = store.rules.find((r) => r.id === where.id); + if (!row) { + throw new PrismaClientKnownRequestError("Record not found", "P2025"); + } + for (const [key, value] of Object.entries(data)) { + if (key === "identities" || key === "targets") continue; + (row as unknown as Record)[key] = value; + } + row.updatedAt = new Date(); + if (data.identities) writeIdentities(row.id, data.identities.create); + if (data.targets) writeTargets(row.id, data.targets.create); + return withRelations(row, include); + }, + delete: async ({ where }: { where: { id: string } }) => { + const row = store.rules.find((r) => r.id === where.id); + if (!row) { + throw new PrismaClientKnownRequestError("Record not found", "P2025"); + } + dropRules(new Set([row.id])); + return row; + }, + deleteMany: async ({ where }: { where?: RuleWhere }) => { + const rows = filterRules(where); + dropRules(new Set(rows.map((r) => r.id))); + return { count: rows.length }; + }, + }; + + const policyRuleIdentity = { + findMany: async ({ where }: { where: Partial }) => + store.identities.filter((row) => + Object.entries(where).every( + ([key, value]) => row[key as keyof IdentityRow] === value, + ), + ), + deleteMany: async ({ where }: { where: { ruleId: string } }) => { + const before = store.identities.length; + store.identities = store.identities.filter( + (i) => i.ruleId !== where.ruleId, + ); + return { count: before - store.identities.length }; + }, + }; + + const policyRuleTarget = { + deleteMany: async ({ where }: { where: { ruleId: string } }) => { + const before = store.targets.length; + store.targets = store.targets.filter((t) => t.ruleId !== where.ruleId); + return { count: before - store.targets.length }; + }, + }; + + const countDirectory = (rows: DirectoryRow[], where: IdWhere) => + rows.filter( + (row) => + (where.id?.in ?? []).includes(row.id) && + (where.organizationId === undefined || + row.organizationId === where.organizationId), + ).length; + + const countResources = (rows: ResourceRow[], where: IdWhere) => + rows.filter( + (row) => + (where.id?.in ?? []).includes(row.id) && + (where.organizationId === undefined || + row.organizationId === where.organizationId) && + (where.projectId === undefined || row.projectId === where.projectId) && + (where.scope === undefined || row.scope === where.scope), + ).length; + + const findMember = (organizationId: string, userId: string) => + store.members.find( + (row) => row.organizationId === organizationId && row.userId === userId, + ); + + const filterOrgMembers = (where: OrgMemberWhere) => + store.members.filter((row) => { + if ( + where.organizationId !== undefined && + row.organizationId !== where.organizationId + ) + return false; + if (typeof where.userId === "string" && row.userId !== where.userId) + return false; + if ( + typeof where.userId === "object" && + where.userId !== null && + !where.userId.in.includes(row.userId) + ) + return false; + if (where.status !== undefined) { + const ok = + typeof where.status === "string" + ? row.status === where.status + : where.status.not === undefined || row.status !== where.status.not; + if (!ok) return false; + } + if (where.role !== undefined) { + const ok = + typeof where.role === "string" + ? row.role === where.role + : where.role.not === undefined || row.role !== where.role.not; + if (!ok) return false; + } + return true; + }); + + const db = { + apiKey: { + findUnique: async ({ where }: { where: { key?: string } }) => { + if (where.key === "oc_org_admin-key") + return { + userId: "user-admin", + organizationId: "org-1", + scope: "organization", + }; + if (where.key === "oc_org_outsider-key") + return { + userId: "user-outsider", + organizationId: "org-2", + scope: "organization", + }; + // A PROJECT-scoped key owned by the org's OWNER: it authenticates + // fine, which is exactly why the router needs its own scope guard. + if (where.key === "oc_project-key-of-owner") + return { userId: "user-owner", projectId: "proj-1" }; + return null; + }, + findFirst: async () => null, + findMany: async () => [], + }, + user: { + findUnique: async ({ + where, + select, + }: { + where: { id?: string; externalAuthId?: string; email?: string }; + select?: Record; + }) => { + if (select?.organizationMemberships) { + return { + organizationMemberships: store.members + .filter((m) => m.userId === where.id) + .map((m) => ({ organizationId: m.organizationId })), + }; + } + return ( + store.users.find( + (u) => + (where.id !== undefined && u.id === where.id) || + (where.externalAuthId !== undefined && + u.externalAuthId === where.externalAuthId) || + (where.email !== undefined && u.email === where.email), + ) ?? null + ); + }, + }, + organizationMember: { + findUnique: async ({ + where, + }: { + where: { + organizationId_userId: { organizationId: string; userId: string }; + }; + }) => { + const { organizationId, userId } = where.organizationId_userId; + return findMember(organizationId, userId) ?? null; + }, + findFirst: async ({ where }: { where: OrgMemberWhere }) => + filterOrgMembers(where)[0] ?? null, + findMany: async ({ where }: { where: OrgMemberWhere }) => + filterOrgMembers(where), + // The identity ownership check: a suspended member is excluded by + // `status: { not: "suspended" }`, so this count is what makes a rule + // targeting a suspended user a 422. + count: async ({ where }: { where: OrgMemberWhere & IdWhere }) => + filterOrgMembers(where).filter((row) => + (where.id?.in ?? [row.userId]).includes(row.userId), + ).length, + }, + group: { + count: async ({ where }: { where: IdWhere }) => + countDirectory(store.groups, where), + }, + agent: { count: async () => 0 }, + appConnection: { + count: async ({ where }: { where: IdWhere }) => + countResources(store.connections, where), + findMany: async () => [], + }, + secret: { + count: async ({ where }: { where: IdWhere }) => + countResources(store.secrets, where), + }, + project: { + findFirst: async () => ({ id: "proj-1", organizationId: "org-1" }), + findUnique: async () => ({ id: "proj-1", organizationId: "org-1" }), + }, + projectAccess: { findFirst: async () => null }, + policyRuleV2, + policyRuleIdentity, + policyRuleTarget, + auditLog: { + create: async ({ data }: { data: AuditRow }) => { + store.audits.push(data); + return data; + }, + }, + // The policy service uses the INTERACTIVE form; the org group/agent-group + // services (not exercised here) use the array form. Support both. + $transaction: async (arg: unknown) => { + if (typeof arg === "function") { + const tx = { + // lockScope's advisory lock — a tagged template. + $executeRaw: async () => 0, + policyRuleV2, + policyRuleIdentity, + policyRuleTarget, + }; + return (arg as (tx: unknown) => Promise)(tx); + } + return Promise.all(arg as Promise[]); + }, + }; + + return { + Prisma: { JsonNull: null, PrismaClientKnownRequestError }, + db, + }; +}); + +import { createApiApp } from "../../app"; +import { registerOssOrgRoutes } from "./index"; +import { ossRoleResolver } from "../../services/org-role-resolver"; + +const sessionProvider = { + getSession: async () => { + const user = store.users.find((u) => u.id === store.sessionUserId); + return user ? { id: user.externalAuthId, email: user.email } : null; + }, +}; + +const app: Hono = createApiApp(sessionProvider, { + eeRoutes: registerOssOrgRoutes, + roleResolver: ossRoleResolver, +}); + +const at = (minutes: number) => new Date(Date.UTC(2026, 0, 1, 0, minutes)); + +const member = ( + userId: string, + role: string, + createdAt: Date, + organizationId = ORG, + status = "active", +): MemberRow => ({ + organizationId, + userId, + userEmail: `${userId}@example.com`, + role, + status, + ssoExempt: false, + suspendedAt: status === "suspended" ? createdAt : null, + createdAt, +}); + +const rule = (id: string, overrides: Partial = {}): RuleRow => ({ + id, + scope: "organization", + organizationId: ORG, + projectId: null, + status: "draft", + generation: 0, + priority: 1, + enabled: true, + isDefault: false, + logicalId: `logical-${id}`, + source: "custom", + name: id, + description: null, + action: "block", + rateLimit: null, + rateLimitWindow: null, + requireApproval: false, + conditions: null, + createdByUserId: ADMIN, + createdAt: at(1), + updatedAt: at(1), + ...overrides, +}); + +beforeEach(() => { + store.users = [ + { + id: OWNER, + externalAuthId: "ext-owner", + email: "owner@example.com", + name: "Olive Owner", + }, + { + id: ADMIN, + externalAuthId: "ext-admin", + email: "admin@example.com", + name: "Adam Admin", + }, + { + id: MEMBER, + externalAuthId: "ext-member", + email: "member@example.com", + name: null, + }, + { + id: SUSPENDED, + externalAuthId: "ext-suspended", + email: "suspended@example.com", + name: null, + }, + { + id: OUTSIDER, + externalAuthId: "ext-outsider", + email: "outsider@other.test", + name: "Odette Outsider", + }, + ]; + store.members = [ + member(OWNER, "owner", at(0)), + member(ADMIN, "admin", at(1)), + member(MEMBER, "member", at(2)), + member(SUSPENDED, "member", at(3), ORG, "suspended"), + member(OUTSIDER, "admin", at(4), OTHER_ORG), + ]; + store.groups = [ + { id: "g-1", organizationId: ORG }, + { id: "g-2", organizationId: ORG }, + { id: "g-x", organizationId: OTHER_ORG }, + ]; + store.connections = [ + { + id: "conn-org", + organizationId: ORG, + projectId: null, + scope: "organization", + }, + { + id: "conn-proj", + organizationId: ORG, + projectId: PROJECT, + scope: "project", + }, + ]; + store.secrets = [ + { + id: "sec-org", + organizationId: ORG, + projectId: null, + scope: "organization", + }, + { + id: "sec-proj", + organizationId: ORG, + projectId: PROJECT, + scope: "project", + }, + ]; + store.rules = [ + // Two org-1 draft rules, deliberately seeded out of priority order. + rule("r-b", { priority: 2, name: "Block paste sites" }), + rule("r-a", { priority: 1, name: "Block internal admin API" }), + // Another org's rule, and this org's PROJECT rule — neither may ever + // appear through /v1/org/policy. + rule("r-foreign", { organizationId: OTHER_ORG, name: "Foreign" }), + rule("r-project", { + scope: "project", + organizationId: null, + projectId: PROJECT, + name: "Project rule", + }), + ]; + store.identities = [ + { + id: "pri-a", + ruleId: "r-a", + agentId: null, + userId: null, + groupId: "g-1", + }, + ]; + store.targets = [ + { + id: "tgt-a", + ruleId: "r-a", + kind: "network", + appProvider: null, + appTools: [], + appConnectionScope: null, + appConnectionId: null, + secretId: null, + secretScope: null, + hostPattern: "admin.internal", + pathPattern: null, + method: null, + }, + ]; + store.audits = []; + store.seq = 1000; + store.sessionUserId = null; +}); + +const asAdmin = { headers: { Authorization: `Bearer ${ADMIN_KEY}` } }; +const asOtherAdmin = { + headers: { Authorization: `Bearer ${OTHER_ADMIN_KEY}` }, +}; +const asProjectKey = { headers: { Authorization: `Bearer ${PROJECT_KEY}` } }; + +interface RuleBody { + id: string; + scope: string; + name: string; + priority: number; + action: string; + isDefault: boolean; + identities: { type: string; id: string }[]; + targets: Record[]; +} + +const NETWORK_TARGET = { kind: "network", hostPattern: "example.com" }; + +const listRules = async ( + init: RequestInit = asAdmin, + query = "", +): Promise => { + const res = await app.request(`/v1/org/policy/rules${query}`, init); + expect(res.status).toBe(200); + return (await res.json()) as RuleBody[]; +}; + +const create = (body: unknown, init: RequestInit = asAdmin) => + app.request("/v1/org/policy/rules", { + ...init, + method: "POST", + body: JSON.stringify(body), + }); + +const patch = (id: string, body: unknown, init: RequestInit = asAdmin) => + app.request(`/v1/org/policy/rules/${id}`, { + ...init, + method: "PATCH", + body: JSON.stringify(body), + }); + +const remove = (id: string, init: RequestInit = asAdmin) => + app.request(`/v1/org/policy/rules/${id}`, { ...init, method: "DELETE" }); + +const reorder = (orderedIds: string[], init: RequestInit = asAdmin) => + app.request("/v1/org/policy/rules/order", { + ...init, + method: "PUT", + body: JSON.stringify({ orderedIds }), + }); + +const setDefault = (action: string, init: RequestInit = asAdmin) => + app.request("/v1/org/policy/default", { + ...init, + method: "PATCH", + body: JSON.stringify({ action }), + }); + +const publish = (init: RequestInit = asAdmin) => + app.request("/v1/org/policy/publish", { + ...init, + method: "POST", + body: "{}", + }); + +const orgRules = (status = "draft") => + store.rules.filter((r) => r.organizationId === ORG && r.status === status); + +const identitiesOf = (ruleId: string) => + store.identities.filter((i) => i.ruleId === ruleId); + +describe("mount + scope fencing", () => { + it("serves the org's own organization-scope rules, in priority order", async () => { + const body = await listRules(); + expect(body.map((r) => r.id)).toEqual(["r-a", "r-b"]); + expect(body[0]).toMatchObject({ + id: "r-a", + scope: "organization", + name: "Block internal admin API", + identities: [{ type: "group", id: "g-1" }], + }); + }); + + it("never returns another org's rules, nor this org's PROJECT rules", async () => { + const body = await listRules(); + const ids = body.map((r) => r.id); + expect(ids).not.toContain("r-foreign"); + expect(ids).not.toContain("r-project"); + }); + + it("writes rules into the ORGANIZATION scope, never a project", async () => { + const res = await create({ + name: "Block pastebin", + action: "block", + targets: [NETWORK_TARGET], + }); + expect(res.status).toBe(201); + const created = (await res.json()) as RuleBody; + const row = store.rules.find((r) => r.id === created.id); + expect(row).toMatchObject({ + scope: "organization", + organizationId: ORG, + projectId: null, + status: "draft", + isDefault: false, + }); + }); + + it("appends a new rule below the existing ones (manual ordering)", async () => { + const res = await create({ + name: "Appended", + action: "block", + targets: [NETWORK_TARGET], + }); + const created = (await res.json()) as RuleBody; + expect(created.priority).toBe(3); + expect((await listRules()).map((r) => r.id)).toEqual([ + "r-a", + "r-b", + created.id, + ]); + }); +}); + +describe("authorization", () => { + it("403s every verb for a non-admin member session", async () => { + store.sessionUserId = MEMBER; + const asMember: RequestInit = {}; + const responses = await Promise.all([ + app.request("/v1/org/policy/rules", asMember), + app.request("/v1/org/policy/default", asMember), + create( + { name: "x", action: "block", targets: [NETWORK_TARGET] }, + asMember, + ), + patch("r-a", { name: "y" }, asMember), + remove("r-a", asMember), + reorder(["r-a", "r-b"], asMember), + setDefault("allow", asMember), + publish(asMember), + ]); + expect(responses.map((r) => r.status)).toEqual([ + 403, 403, 403, 403, 403, 403, 403, 403, + ]); + // Nothing changed behind the 403s. + expect( + orgRules() + .map((r) => r.id) + .sort(), + ).toEqual(["r-a", "r-b"]); + }); + + it("403s a PROJECT-scoped key even when its holder is an org admin", async () => { + // The Slice-3 invariant, and the security test that matters most here: + // `role` is scope-blind, so a leaked agent key belonging to an admin would + // otherwise rewrite the guardrails its own traffic is judged against. + const res = await app.request("/v1/org/policy/rules", asProjectKey); + expect(res.status).toBe(403); + const write = await create( + { name: "x", action: "block", targets: [NETWORK_TARGET] }, + asProjectKey, + ); + expect(write.status).toBe(403); + }); + + it("401s an unauthenticated caller (auth runs before the editing gate)", async () => { + const res = await app.request("/v1/org/policy/rules"); + expect(res.status).toBe(401); + }); +}); + +describe("cross-org isolation", () => { + it("hides org-1's rules from org-2's admin", async () => { + const body = await listRules(asOtherAdmin); + expect(body.map((r) => r.id)).toEqual(["r-foreign"]); + }); + + it("404s org-2's admin on org-1's rule for read, patch and delete", async () => { + const get = await app.request("/v1/org/policy/rules/r-a", asOtherAdmin); + expect(get.status).toBe(404); + expect( + (await patch("r-a", { name: "hijacked" }, asOtherAdmin)).status, + ).toBe(404); + expect((await remove("r-a", asOtherAdmin)).status).toBe(404); + expect(store.rules.find((r) => r.id === "r-a")?.name).toBe( + "Block internal admin API", + ); + }); + + it("409s a reorder that names another org's rules", async () => { + const res = await reorder(["r-a", "r-b"], asOtherAdmin); + expect(res.status).toBe(409); + expect(store.rules.find((r) => r.id === "r-a")?.priority).toBe(1); + }); + + it("publishes only its own org's rules", async () => { + const res = await publish(asOtherAdmin); + expect(res.status).toBe(200); + const published = store.rules.filter((r) => r.status === "published"); + expect(published.every((r) => r.organizationId === OTHER_ORG)).toBe(true); + }); +}); + +describe("identity authoring (the picker's contract)", () => { + const withIdentities = (identities: unknown[]) => ({ + name: "Directory rule", + action: "block", + targets: [NETWORK_TARGET], + identities, + }); + + it("accepts user and group identities, one row per principal", async () => { + const res = await create( + withIdentities([ + { type: "user", id: MEMBER }, + { type: "group", id: "g-1" }, + { type: "group", id: "g-2" }, + ]), + ); + expect(res.status).toBe(201); + const created = (await res.json()) as RuleBody; + expect(created.identities).toEqual([ + { type: "user", id: MEMBER }, + { type: "group", id: "g-1" }, + { type: "group", id: "g-2" }, + ]); + // Exactly one principal column per row — the shape `assemble.rs` decodes. + for (const row of identitiesOf(created.id)) { + const named = [row.agentId, row.userId, row.groupId].filter( + (v) => v !== null, + ); + expect(named).toHaveLength(1); + } + }); + + it("accepts an empty identity set (= all agents in the organization)", async () => { + const res = await create(withIdentities([])); + expect(res.status).toBe(201); + const created = (await res.json()) as RuleBody; + expect(created.identities).toEqual([]); + expect(identitiesOf(created.id)).toHaveLength(0); + }); + + it("422s a specific AGENT — the kind the picker must never offer", async () => { + const res = await create( + withIdentities([{ type: "agent", id: "agent-1" }]), + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { error: { message: string } }; + expect(body.error.message).toMatch(/not a specific agent/i); + }); + + it("422s a principal from another organization", async () => { + for (const identity of [ + { type: "group", id: "g-x" }, + { type: "user", id: OUTSIDER }, + ]) { + const res = await create(withIdentities([identity])); + expect(res.status).toBe(422); + } + }); + + it("422s a SUSPENDED member (the gateway drops them from the principal set)", async () => { + const res = await create(withIdentities([{ type: "user", id: SUSPENDED }])); + expect(res.status).toBe(422); + }); + + it("replaces the identity set on PATCH", async () => { + const res = await patch("r-a", { + identities: [{ type: "group", id: "g-1" }], + }); + expect(res.status).toBe(200); + expect(identitiesOf("r-a").map((i) => i.groupId)).toEqual(["g-1"]); + expect( + identitiesOf("r-a").every((i) => i.agentId === null && i.userId === null), + ).toBe(true); + }); +}); + +describe("target authoring at org scope", () => { + const withTargets = (targets: unknown[]) => ({ + name: "Target rule", + action: "block", + targets, + }); + + it("accepts both level markers on an org rule", async () => { + for (const level of ["organization", "project"] as const) { + const app_ = await create( + withTargets([ + { kind: "app", provider: "github", connectionScope: level }, + ]), + ); + expect(app_.status).toBe(201); + const secret = await create( + withTargets([{ kind: "secret", secretScope: level }]), + ); + expect(secret.status).toBe(201); + } + }); + + it("422s an org rule naming a PROJECT-level connection or secret", async () => { + const conn = await create( + withTargets([{ kind: "connection", connectionId: "conn-proj" }]), + ); + expect(conn.status).toBe(422); + const secret = await create( + withTargets([{ kind: "secret", secretId: "sec-proj" }]), + ); + expect(secret.status).toBe(422); + }); + + it("accepts an org-level connection / secret by id", async () => { + const conn = await create( + withTargets([{ kind: "connection", connectionId: "conn-org" }]), + ); + expect(conn.status).toBe(201); + const secret = await create( + withTargets([{ kind: "secret", secretId: "sec-org" }]), + ); + expect(secret.status).toBe(201); + }); + + it("422s a rule with no targets (an empty target set matches nothing)", async () => { + const res = await create(withTargets([])); + expect(res.status).toBe(422); + }); +}); + +describe("the Default Rule", () => { + it("GET returns a VIRTUAL default and writes nothing", async () => { + const res = await app.request("/v1/org/policy/default", asAdmin); + expect(res.status).toBe(200); + const body = (await res.json()) as RuleBody; + // The virtual (unpersisted) default is `allow` in every scope on this base + // (`defaultAction`); `id: ""` marks it virtual. + expect(body).toMatchObject({ id: "", isDefault: true, action: "allow" }); + // A read must never mint a persisted default row. + expect(store.rules.some((r) => r.isDefault)).toBe(false); + }); + + it("PATCH persists the admin's choice — an ALLOW default, not the minted Block", async () => { + const res = await setDefault("allow"); + expect(res.status).toBe(200); + const defaults = store.rules.filter( + (r) => r.isDefault && r.organizationId === ORG && r.status === "draft", + ); + expect(defaults).toHaveLength(1); + expect(defaults[0]).toMatchObject({ action: "allow", priority: 0 }); + }); + + it("PATCH is idempotent — it never mints a second default", async () => { + await setDefault("allow"); + await setDefault("block"); + const defaults = store.rules.filter( + (r) => r.isDefault && r.organizationId === ORG && r.status === "draft", + ); + expect(defaults).toHaveLength(1); + expect(defaults[0]?.action).toBe("block"); + }); + + it("keeps the default out of the rules list", async () => { + await setDefault("allow"); + expect((await listRules()).map((r) => r.id)).toEqual(["r-a", "r-b"]); + }); +}); + +describe("publish + generations", () => { + it("snapshots the draft set plus the default into generation 1, then 2", async () => { + const first = await publish(); + expect(first.status).toBe(200); + expect(await first.json()).toMatchObject({ generation: 1, ruleCount: 3 }); + + const published = store.rules.filter( + (r) => r.organizationId === ORG && r.status === "published", + ); + expect(published).toHaveLength(3); // r-a, r-b + the minted default + expect(published.filter((r) => r.isDefault)).toHaveLength(1); + + const second = await publish(); + expect(await second.json()).toMatchObject({ generation: 2 }); + }); + + it("copies identities and targets onto the published generation", async () => { + await publish(); + const snapshot = store.rules.find( + (r) => r.status === "published" && r.name === "Block internal admin API", + ); + expect(snapshot).toBeDefined(); + expect(identitiesOf(snapshot?.id ?? "").map((i) => i.groupId)).toEqual([ + "g-1", + ]); + expect( + store.targets + .filter((t) => t.ruleId === snapshot?.id) + .map((t) => t.hostPattern), + ).toEqual(["admin.internal"]); + }); + + it("returns only the ACTIVE published generation from ?status=published", async () => { + await publish(); + await publish(); + const body = await listRules(asAdmin, "?status=published"); + expect(body.map((r) => r.name).sort()).toEqual([ + "Block internal admin API", + "Block paste sites", + ]); + const generations = new Set( + body.map((r) => store.rules.find((row) => row.id === r.id)?.generation), + ); + expect([...generations]).toEqual([2]); + }); + + it("prunes published generations beyond the retention window", async () => { + for (let i = 0; i < 11; i++) await publish(); + const generations = store.rules + .filter((r) => r.organizationId === ORG && r.status === "published") + .map((r) => r.generation); + expect(Math.max(...generations)).toBe(11); + expect(Math.min(...generations)).toBe(2); // generation 1 pruned + }); + + it("410s a PROJECT publish — project-scope policy CRUD was retired", async () => { + // Project rules are compiled from agent grants now; `/v1/policy/*` answers + // 410 (`removedProjectPolicyRoutes`), so an org publish is the only writer. + const projectPublish = await app.request("/v1/policy/publish", { + ...asProjectKey, + method: "POST", + body: "{}", + headers: { ...asProjectKey.headers, "X-Project-Id": PROJECT }, + }); + expect(projectPublish.status).toBe(410); + + const again = await publish(); + expect(await again.json()).toMatchObject({ generation: 1 }); + // No project generations were ever written. + expect( + store.rules.some( + (r) => r.projectId === PROJECT && r.status === "published", + ), + ).toBe(false); + }); +}); + +describe("ordering", () => { + it("renumbers the draft densely and returns the new order", async () => { + const res = await reorder(["r-b", "r-a"]); + expect(res.status).toBe(200); + expect(((await res.json()) as RuleBody[]).map((r) => r.id)).toEqual([ + "r-b", + "r-a", + ]); + expect(store.rules.find((r) => r.id === "r-b")?.priority).toBe(1); + expect(store.rules.find((r) => r.id === "r-a")?.priority).toBe(2); + }); + + it("409s a stale id set rather than writing a partial order", async () => { + const missing = await reorder(["r-a"]); + expect(missing.status).toBe(409); + const extra = await reorder(["r-a", "r-b", "r-project"]); + expect(extra.status).toBe(409); + const duplicated = await reorder(["r-a", "r-a"]); + expect(duplicated.status).toBe(409); + expect(store.rules.find((r) => r.id === "r-a")?.priority).toBe(1); + }); +}); + +describe("audit + gateway cache flush", () => { + // `withAudit` keys `invalidateGatewayCacheForOrg` off `organizationId`, so an + // audit row missing it is also a MISSED FLUSH — the guardrail would keep + // enforcing its old shape for the cache window. + const expectOrgAudit = (action: string) => { + const row = store.audits.at(-1); + expect(row).toMatchObject({ + organizationId: ORG, + userId: ADMIN, + action, + service: "policy", + source: "api", + }); + expect(row?.projectId).toBeUndefined(); + }; + + it("audits create, update, delete, reorder, default and publish", async () => { + const created = (await ( + await create({ + name: "Audited", + action: "block", + targets: [NETWORK_TARGET], + }) + ).json()) as RuleBody; + expectOrgAudit("create"); + + await patch(created.id, { name: "Audited again" }); + expectOrgAudit("update"); + + await reorder(["r-a", "r-b", created.id]); + expectOrgAudit("update"); + + await setDefault("allow"); + expectOrgAudit("update"); + + await publish(); + expectOrgAudit("publish"); + + await remove(created.id); + expectOrgAudit("delete"); + }); + + it("carries the rule id in the metadata (never the whole rule)", async () => { + const created = (await ( + await create({ + name: "Metadata", + action: "block", + targets: [NETWORK_TARGET], + }) + ).json()) as RuleBody; + expect(store.audits.at(-1)?.metadata).toEqual({ + ruleId: created.id, + name: "Metadata", + }); + }); +}); diff --git a/packages/api/src/routes/org/policy.ts b/packages/api/src/routes/org/policy.ts new file mode 100644 index 00000000..4ccb4040 --- /dev/null +++ b/packages/api/src/routes/org/policy.ts @@ -0,0 +1,57 @@ +import { Hono } from "hono"; +import type { ApiEnv } from "../../types"; +import { auth } from "../../middleware/auth"; +import { ServiceError } from "../../services/errors"; +import { registerPolicyRoutes } from "../policy"; + +/** + * `/v1/org/policy` — the ORGANIZATION policy scope (the guardrail level the + * gateway evaluates alongside each project's policy, taking the stricter + * verdict). + * + * Same guard stack as `/v1/org/groups`, for the same reasons: + * + * `requireProject: false`: these are ORG-scoped routes, so a caller with no + * project context (an org API key without `X-Project-Id`) must still get + * through. `role: "admin"` makes the whole router admin-only — a plain member + * gets a deterministic 403, which is what the web client renders (the org + * policy page's admin-only notice) rather than retries. + * + * `role` alone is SCOPE-BLIND, so it is not sufficient on its own: a + * project-scoped key (the credential an agent carries) resolves to its owning + * user, and if that user happens to be an org admin the role check passes. Org + * guardrails override every project's policy, so a leaked agent key would + * otherwise be able to rewrite the rules its own traffic is evaluated against. + * Org-wide authority requires an org-wide credential. + * + * Mounting a sub-app re-registers its `use("*")` guards under the mount path, + * so these two guards — and `registerPolicyRoutes`'s own editing-flag gate — + * cover every path beneath `/v1/org/policy` and only those. Registration order + * puts the auth guards first, so an unauthenticated caller gets 401 rather + * than "editing disabled". + */ +export const ossOrgPolicyRoutes = () => { + const app = new Hono(); + app.use("*", auth({ requireProject: false, role: "admin" })); + app.use("*", async (c, next) => { + if (c.get("auth").scope === "project") { + throw new ServiceError( + "FORBIDDEN", + "Organization policy requires an organization-scoped credential.", + ); + } + return next(); + }); + + registerPolicyRoutes(app, { + // `organizationId` ONLY: `policyScope` requires exactly one key, and a + // scope carrying both would let project rows into this scope's reads. + resolveScope: (auth) => ({ organizationId: auth.organizationId }), + // Not decoration: `withAudit` keys `invalidateGatewayCacheForOrg` off + // `organizationId`, so this is the gateway cache-flush key. A missed flush + // is an org guardrail that keeps enforcing for up to the cache window + // after it was changed. + auditScope: (auth) => ({ organizationId: auth.organizationId }), + }); + return app; +}; From 741a45c53b8cbc64ec80b010aac821b122669ebd Mon Sep 17 00:00:00 2001 From: marcorivm Date: Wed, 29 Jul 2026 23:11:05 -0600 Subject: [PATCH 08/10] feat: re-land spend budgets onto 1.44.0 Reconciliation Stage I (final). Per-secret monthly cost caps: the gateway meters anthropic/openai token usage post-response (bounded stream tee, no byte corruption, Accept-Encoding: identity so usage parses, prompt-cache tokens priced), keeps a nano-dollar running total in the cache counter, and enforces a pre-request 402 when over. Fail-OPEN on any metering or read error (a cost control, not a security gate) while an over-budget org is still blocked; the budget gate runs after the security decision so it can never turn a Block into an allow. Org-scoped budget CRUD on the eeRoutes seam + a Budgets tab. The reconciliation preserved every hook site, so forward.rs needed no edits. +21 gateway / +12 api tests, no agent-group, no migration. --- apps/gateway/src/budget.rs | 533 ++++++++++++++++- apps/gateway/src/db.rs | 75 +++ apps/gateway/src/gateway/hooks.rs | 262 +++++++- apps/gateway/src/telemetry.rs | 56 +- .../_components/budget-row-actions.tsx | 177 ++++++ .../budgets/_components/budget-usage-bar.tsx | 60 ++ .../budgets/_components/budgets-content.tsx | 57 ++ .../budgets/_components/budgets-list.tsx | 59 ++ .../_components/create-budget-dialog.tsx | 185 ++++++ .../connections/(tabs)/budgets/page.tsx | 10 + .../_components/connections-tabs.tsx | 4 + apps/web/src/hooks/use-budgets.ts | 81 +++ apps/web/src/lib/api/budgets.ts | 43 ++ apps/web/src/lib/api/index.ts | 8 + apps/web/src/lib/api/keys.ts | 4 + packages/api/src/routes/org/budgets.test.ts | 559 ++++++++++++++++++ packages/api/src/routes/org/budgets.ts | 146 +++++ packages/api/src/routes/org/index.ts | 2 + packages/api/src/services/budget-service.ts | 234 ++++++++ 19 files changed, 2520 insertions(+), 35 deletions(-) create mode 100644 apps/web/src/app/(dashboard)/connections/(tabs)/budgets/_components/budget-row-actions.tsx create mode 100644 apps/web/src/app/(dashboard)/connections/(tabs)/budgets/_components/budget-usage-bar.tsx create mode 100644 apps/web/src/app/(dashboard)/connections/(tabs)/budgets/_components/budgets-content.tsx create mode 100644 apps/web/src/app/(dashboard)/connections/(tabs)/budgets/_components/budgets-list.tsx create mode 100644 apps/web/src/app/(dashboard)/connections/(tabs)/budgets/_components/create-budget-dialog.tsx create mode 100644 apps/web/src/app/(dashboard)/connections/(tabs)/budgets/page.tsx create mode 100644 apps/web/src/hooks/use-budgets.ts create mode 100644 apps/web/src/lib/api/budgets.ts create mode 100644 packages/api/src/routes/org/budgets.test.ts create mode 100644 packages/api/src/routes/org/budgets.ts create mode 100644 packages/api/src/services/budget-service.ts diff --git a/apps/gateway/src/budget.rs b/apps/gateway/src/budget.rs index a2785537..e6ec7281 100644 --- a/apps/gateway/src/budget.rs +++ b/apps/gateway/src/budget.rs @@ -1,18 +1,36 @@ -//! Budget layer — stub for the OSS build. All functions are no-ops; the cloud -//! build swaps this module for `ee/budget.rs` via `#[path]` in `main.rs`. +//! Budget layer — spend caps on org/project-owned LLM secrets (OSS). //! -//! The shared types (`BudgetBinding`, `BudgetPeriod`) and `resolve_bindings` are -//! referenced by the shared `connect.rs`/`gateway/mitm.rs` threading, so they -//! exist in both builds with the same surface — inert in OSS -//! (`resolve_bindings` always returns an empty Vec, so the threaded field stays -//! empty and the cloud-only enforcement/metering in `ee/hooks.rs` never runs). +//! An admin sets a per-secret cost cap (`Budget` row). The gateway meters LLM +//! spend against it by parsing the provider `usage` object out of the response +//! (see `gateway/hooks.rs`), pricing it against the static [`price`] table, and +//! recording it (see `telemetry.rs`). Enforcement is a PRE-request gate in +//! `hooks::pre_forward`: once a prior request pushes the running total to/over +//! the limit, the NEXT request is denied `402`. The in-flight request that +//! crosses the line completes (cost is only known at stream end), so one +//! request may overshoot — the cap blocks new requests once exceeded. +//! +//! Fail direction: the budget gate fails OPEN. A budget is a cost control, not +//! a security control — a metering/read glitch lets the request through rather +//! than causing a self-inflicted outage. Only the normal over-limit path bites. +//! +//! ⚠ KEEP THE SHARED TYPES (`BudgetBinding`, `BudgetPeriod`) IDENTICAL to +//! `ee/budget.rs`. Only one of the two modules compiles per build (feature +//! swap), so the shared threading in `connect.rs`/`gateway/mitm.rs` uses +//! whichever copy is active. Treat the types as one. + +use std::collections::HashMap; use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; +use tracing::warn; + +/// One cent = 1e7 nano-dollars (1e-9 USD). +pub(crate) const CENT_TO_NANOS: i64 = 10_000_000; -// ⚠ KEEP THE TYPES BELOW IDENTICAL to `ee/budget.rs`. Only one of the two -// modules compiles per build (feature swap), so a field added to one and not the -// other will NOT fail compilation — the shared threading in `connect.rs`/ -// `gateway/mitm.rs` just uses whichever copy is active. Treat them as one type. +/// TTL for the hot spend counter. A monthly period rolls to a new counter key +/// on the 1st (so a new month resets regardless of TTL); this bound just forces +/// a periodic rehydrate from the durable `BudgetSpend` floor for `total` caps. +pub(crate) const PERIOD_TTL: u64 = 60 * 60 * 24 * 40; // 40 days /// How a budget's spend window resets. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -37,14 +55,491 @@ pub(crate) struct BudgetBinding { pub period: BudgetPeriod, } -/// Resolve budget bindings for the effective partner secrets among a request's -/// host-filtered secrets. OSS: always empty (no budgets enforced). Concrete on -/// `db::SecretRow` — the cloud impl is generic over a `BudgetSecret` trait, but -/// the stub only needs to accept what `connect.rs` passes (`&[SecretRow]`). +/// Parsed token usage from a metered LLM response. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct Usage { + pub input: u64, + pub output: u64, + /// Anthropic `cache_creation_input_tokens` — cache-WRITE tokens, billed at + /// 1.25× base input and EXCLUDED from `input`. Always 0 for OpenAI (which + /// folds cached tokens into `prompt_tokens`). + pub cache_write: u64, + /// Anthropic `cache_read_input_tokens` — cache-READ tokens, billed at 0.1× + /// base input and EXCLUDED from `input`. Always 0 for OpenAI. + pub cache_read: u64, + /// Model served (from the response body) — selects the price row. + pub model: String, +} + +/// Whether this secret type has a meter + price table entry, so a budget on it +/// can actually be enforced. Everything else is DESCOPED (see the plan): a +/// budget on an unmeterable secret is rejected at create time by the API. +pub(crate) fn is_metered_type(secret_type: &str) -> bool { + matches!(secret_type, "anthropic" | "openai") +} + +/// Resolve budget bindings for the org/project LLM secrets among a request's +/// host-filtered secrets. Loads `Budget` rows for `(organization_id, secret_id ∈ +/// metered host-matched secrets)` and maps each to a [`BudgetBinding`]. Errors ⇒ +/// log + return `[]` (fail-open at resolution too — no binding ⇒ no cap). pub(crate) async fn resolve_bindings( - _pool: &sqlx::PgPool, - _org_id: &str, - _secrets: &[crate::db::SecretRow], + pool: &sqlx::PgPool, + org_id: &str, + secrets: &[crate::db::SecretRow], ) -> Vec { - Vec::new() + // Only metered LLM types can be priced; others can't carry a spend cap. + let type_by_id: HashMap<&str, &str> = secrets + .iter() + .filter(|s| is_metered_type(&s.type_)) + .map(|s| (s.id.as_str(), s.type_.as_str())) + .collect(); + if type_by_id.is_empty() { + return Vec::new(); + } + + let ids: Vec = type_by_id.keys().map(|id| id.to_string()).collect(); + let rows = match crate::db::find_budgets_for_secrets(pool, org_id, &ids).await { + Ok(rows) => rows, + Err(e) => { + warn!(error = %e, "budget: failed to load budgets; enforcing none (fail-open)"); + return Vec::new(); + } + }; + + rows.into_iter() + .filter_map(|row| { + let secret_type = type_by_id.get(row.secret_id.as_str())?; + Some(BudgetBinding { + secret_id: row.secret_id, + organization_id: org_id.to_string(), + secret_type: (*secret_type).to_string(), + limit_nanos: row.limit_cents as i64 * CENT_TO_NANOS, + period: period_from_str(&row.period), + }) + }) + .collect() +} + +fn period_from_str(s: &str) -> BudgetPeriod { + match s { + "total" => BudgetPeriod::Total, + _ => BudgetPeriod::Monthly, + } +} + +// ── Period + cache keys ────────────────────────────────────────────────── + +/// The spend-window key. Monthly → `m:YYYY-MM` (UTC), so a new month is a new +/// key = automatic reset. Total → `total` (lifetime). +pub(crate) fn period_key(period: BudgetPeriod, now: OffsetDateTime) -> String { + match period { + BudgetPeriod::Monthly => { + format!("m:{:04}-{:02}", now.year(), u8::from(now.month())) + } + BudgetPeriod::Total => "total".to_string(), + } +} + +/// The hot-counter cache key holding accumulated nano-dollars for this window. +pub(crate) fn counter_key(secret_id: &str, org_id: &str, period_key: &str) -> String { + format!("budget:spent:{secret_id}:{org_id}:{period_key}") +} + +/// Enforcement predicate: a spend cap denies the NEXT request once the running +/// total meets or exceeds the limit. `>=` — at exactly the limit, deny. +pub(crate) fn is_over(spent: i64, limit: i64) -> bool { + spent >= limit +} + +// ── Metering: parse + price ────────────────────────────────────────────── + +/// Bounded head/tail budget for the metering copy (per direction), enough for a +/// non-stream JSON `usage` (trailing) or an SSE `message_start` (leading). +pub(crate) const META_CAP: usize = 16 * 1024; + +/// Parse the provider `usage` from a bounded response sample. `head` is the +/// first bytes (SSE `message_start` with input tokens + model), `tail` the last +/// bytes (non-stream trailing `usage`, or the SSE final `message_delta` output). +/// Best-effort substring scan — tolerant of truncation; when no usage is present +/// (e.g. OpenAI SSE without `include_usage`) returns `None` ⇒ the caller charges +/// 0 (fail-open). Never fabricates. +pub(crate) fn parse_usage(secret_type: &str, head: &[u8], tail: &[u8]) -> Option { + let (in_key, out_key) = match secret_type { + "anthropic" => ("input_tokens", "output_tokens"), + "openai" => ("prompt_tokens", "completion_tokens"), + _ => return None, + }; + let h = String::from_utf8_lossy(head); + let t = String::from_utf8_lossy(tail); + + // Input tokens live in the leading usage (SSE message_start) or the trailing + // usage (non-stream) — first occurrence in either. + let input = find_uint(&h, in_key, false).or_else(|| find_uint(&t, in_key, false)); + // Output tokens live in the trailing usage / final message_delta — last + // occurrence in the tail, then the head as a degraded fallback. + let output = find_uint(&t, out_key, true).or_else(|| find_uint(&h, out_key, true)); + let model = find_str(&h, "model").or_else(|| find_str(&t, "model"))?; + + // Anthropic reports prompt-cache tokens in fields EXCLUDED from `input_tokens` + // (`cache_creation_input_tokens`, `cache_read_input_tokens`). Agent workloads + // lean heavily on caching, so omitting these systematically under-meters + // input cost. They live in the leading `message_start` usage (SSE) or the + // trailing usage (non-stream) — first occurrence in either. OpenAI folds its + // cached tokens into `prompt_tokens`, so they stay 0 there. + let (cache_write, cache_read) = if secret_type == "anthropic" { + ( + find_uint(&h, "cache_creation_input_tokens", false) + .or_else(|| find_uint(&t, "cache_creation_input_tokens", false)) + .unwrap_or(0), + find_uint(&h, "cache_read_input_tokens", false) + .or_else(|| find_uint(&t, "cache_read_input_tokens", false)) + .unwrap_or(0), + ) + } else { + (0, 0) + }; + + match (input, output, cache_write, cache_read) { + (None, None, 0, 0) => None, + (i, o, _, _) => Some(Usage { + input: i.unwrap_or(0), + output: o.unwrap_or(0), + cache_write, + cache_read, + model, + }), + } +} + +/// Price a usage into nano-dollars: `input × input_price + output × output_price`. +/// Unknown model → 0 + `warn!` once (a fabricated price on a blocking control is +/// worse than a documented under-meter). +pub(crate) fn price(secret_type: &str, usage: &Usage) -> i64 { + match price_per_token(secret_type, &usage.model) { + // Cache-write is 1.25× (×5/4) and cache-read 0.1× (÷10) of base input; + // multiply before dividing to keep the integer rounding error sub-token. + Some((per_in, per_out)) => { + usage.input as i64 * per_in + + usage.output as i64 * per_out + + (usage.cache_write as i64 * per_in * 5) / 4 + + (usage.cache_read as i64 * per_in) / 10 + } + None => { + warn!(secret_type, model = %usage.model, "budget: no price for model; metering as 0"); + 0 + } + } +} + +/// `(input_nanos_per_token, output_nanos_per_token)` for the given provider + +/// model, by longest-prefix match. Prices are nano-dollars/token = USD-per-M × +/// 1000. Static curated table; new/unknown models meter as 0 until updated. +fn price_per_token(secret_type: &str, model: &str) -> Option<(i64, i64)> { + // Ordered arbitrarily; longest matching prefix wins so specific rows + // (e.g. gpt-4o-mini) beat general ones (gpt-4o, gpt-4). + const ANTHROPIC: &[(&str, i64, i64)] = &[ + ("claude-opus-5", 5_000, 25_000), + ("claude-opus-4", 5_000, 25_000), + ("claude-opus-3", 15_000, 75_000), + ("claude-3-opus", 15_000, 75_000), + ("claude-opus", 5_000, 25_000), + ("claude-fable-5", 10_000, 50_000), + ("claude-sonnet", 3_000, 15_000), + ("claude-3-5-sonnet", 3_000, 15_000), + ("claude-3-7-sonnet", 3_000, 15_000), + ("claude-3-sonnet", 3_000, 15_000), + ("claude-haiku-4", 1_000, 5_000), + ("claude-3-5-haiku", 800, 4_000), + ("claude-3-haiku", 250, 1_250), + ("claude-haiku", 1_000, 5_000), + ]; + const OPENAI: &[(&str, i64, i64)] = &[ + ("gpt-4o-mini", 150, 600), + ("gpt-4o", 2_500, 10_000), + ("gpt-4.1-mini", 400, 1_600), + ("gpt-4.1-nano", 100, 400), + ("gpt-4.1", 2_000, 8_000), + ("gpt-4-turbo", 10_000, 30_000), + ("gpt-4", 30_000, 60_000), + ("gpt-3.5-turbo", 500, 1_500), + ("o1-mini", 1_100, 4_400), + ("o3-mini", 1_100, 4_400), + ("o1", 15_000, 60_000), + ]; + + let table = match secret_type { + "anthropic" => ANTHROPIC, + "openai" => OPENAI, + _ => return None, + }; + table + .iter() + .filter(|(prefix, _, _)| model.starts_with(prefix)) + .max_by_key(|(prefix, _, _)| prefix.len()) + .map(|(_, per_in, per_out)| (*per_in, *per_out)) +} + +/// Find the integer following `"key":` in `hay`. `last` picks the final match +/// (streamed final `message_delta`), otherwise the first (leading usage). +fn find_uint(hay: &str, key: &str, last: bool) -> Option { + let needle = format!("\"{key}\""); + let mut found = None; + let mut idx = 0; + while let Some(rel) = hay[idx..].find(&needle) { + let after = idx + rel + needle.len(); + if let Some(n) = uint_after_colon(&hay[after..]) { + found = Some(n); + if !last { + return found; + } + } + idx = after; + } + found +} + +fn uint_after_colon(s: &str) -> Option { + let s = s.trim_start().strip_prefix(':')?.trim_start(); + let digits: String = s.chars().take_while(|c| c.is_ascii_digit()).collect(); + digits.parse().ok() +} + +/// Find the string value following the first `"key":` in `hay`. +fn find_str(hay: &str, key: &str) -> Option { + let needle = format!("\"{key}\""); + let rel = hay.find(&needle)?; + let rest = hay[rel + needle.len()..] + .trim_start() + .strip_prefix(':')? + .trim_start() + .strip_prefix('"')?; + let end = rest.find('"')?; + Some(rest[..end].to_string()) +} + +// ── Tests (pure unit — no DB/network) ──────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use time::{Date, Month}; + + /// Build a UTC `OffsetDateTime` without needing the `time` `macros` feature. + fn utc(year: i32, month: Month, day: u8) -> OffsetDateTime { + Date::from_calendar_date(year, month, day) + .unwrap() + .midnight() + .assume_utc() + } + + #[test] + fn is_metered_type_covers_anthropic_openai_only() { + assert!(is_metered_type("anthropic")); + assert!(is_metered_type("openai")); + assert!(!is_metered_type("generic")); + assert!(!is_metered_type("github-app")); + } + + #[test] + fn parse_anthropic_non_stream() { + let body = br#"{"id":"msg_1","model":"claude-sonnet-4-5-20250929","content":[{"type":"text","text":"hi"}],"usage":{"input_tokens":100,"output_tokens":50}}"#; + let u = parse_usage("anthropic", body, body).unwrap(); + assert_eq!(u.input, 100); + assert_eq!(u.output, 50); + assert_eq!(u.model, "claude-sonnet-4-5-20250929"); + } + + #[test] + fn parse_openai_non_stream() { + let body = br#"{"id":"cmpl","model":"gpt-4o-mini","choices":[],"usage":{"prompt_tokens":200,"completion_tokens":80,"total_tokens":280}}"#; + let u = parse_usage("openai", body, body).unwrap(); + assert_eq!(u.input, 200); + assert_eq!(u.output, 80); + assert_eq!(u.model, "gpt-4o-mini"); + } + + #[test] + fn parse_anthropic_sse_head_and_tail() { + // input + model in message_start (head); final output in message_delta (tail). + let head = br#"event: message_start +data: {"type":"message_start","message":{"model":"claude-opus-4-1-20250805","usage":{"input_tokens":1200,"output_tokens":1}}} +"#; + let tail = br#"event: message_delta +data: {"type":"message_delta","delta":{},"usage":{"output_tokens":640}} +"#; + let u = parse_usage("anthropic", head, tail).unwrap(); + assert_eq!(u.input, 1200); + assert_eq!(u.output, 640); + assert_eq!(u.model, "claude-opus-4-1-20250805"); + } + + #[test] + fn parse_anthropic_cache_tokens() { + // A cache-write turn: small non-cached input_tokens plus the more + // expensive cache_creation, and a cache_read on top — all excluded from + // input_tokens and each parsed into its own field. + let body = br#"{"id":"msg_1","model":"claude-sonnet-4-5","usage":{"input_tokens":10,"cache_creation_input_tokens":2000,"cache_read_input_tokens":500,"output_tokens":40}}"#; + let u = parse_usage("anthropic", body, body).unwrap(); + assert_eq!(u.input, 10); + assert_eq!(u.output, 40); + assert_eq!(u.cache_write, 2000); + assert_eq!(u.cache_read, 500); + } + + #[test] + fn parse_openai_ignores_cache_fields() { + // OpenAI folds cached tokens into prompt_tokens; the anthropic-only cache + // fields must never be read for openai even if present in the body. + let body = br#"{"model":"gpt-4o","usage":{"prompt_tokens":100,"completion_tokens":20,"cache_creation_input_tokens":999}}"#; + let u = parse_usage("openai", body, body).unwrap(); + assert_eq!(u.cache_write, 0); + assert_eq!(u.cache_read, 0); + } + + #[test] + fn price_anthropic_cache_tokens_exact_math() { + // sonnet base input = 3000 nanos/token. cache_write bills 1.25× (3750), + // cache_read 0.1× (300). + let u = Usage { + input: 100, + output: 50, + cache_write: 1_000, + cache_read: 2_000, + model: "claude-sonnet-4-5".to_string(), + }; + // 100*3000 + 50*15000 + 1000*3750 + 2000*300 + // = 300_000 + 750_000 + 3_750_000 + 600_000 + assert_eq!(price("anthropic", &u), 5_400_000); + } + + #[test] + fn parse_openai_sse_without_usage_is_none() { + let body = br#"data: {"choices":[{"delta":{"content":"hi"}}],"model":"gpt-4o"} + +data: [DONE] +"#; + assert!(parse_usage("openai", body, body).is_none()); + } + + #[test] + fn parse_missing_usage_but_model_is_none() { + let body = br#"{"model":"claude-sonnet-4-5","content":[]}"#; + assert!(parse_usage("anthropic", body, body).is_none()); + } + + #[test] + fn parse_truncated_garbage_is_none() { + assert!(parse_usage("anthropic", b"{not json at al", b"l").is_none()); + } + + #[test] + fn parse_wrong_provider_is_none() { + let body = br#"{"model":"x","usage":{"input_tokens":5,"output_tokens":5}}"#; + assert!(parse_usage("generic", body, body).is_none()); + } + + #[test] + fn price_known_model_exact_math() { + let u = Usage { + input: 1_000, + output: 2_000, + cache_write: 0, + cache_read: 0, + model: "claude-sonnet-4-5".to_string(), + }; + // 1000*3000 + 2000*15000 = 3_000_000 + 30_000_000 + assert_eq!(price("anthropic", &u), 33_000_000); + } + + #[test] + fn price_longest_prefix_wins() { + let mini = Usage { + input: 1_000_000, + output: 0, + cache_write: 0, + cache_read: 0, + model: "gpt-4o-mini-2024".to_string(), + }; + assert_eq!(price("openai", &mini), 150_000_000); // 1e6 * 150 + let full = Usage { + input: 1_000_000, + output: 0, + cache_write: 0, + cache_read: 0, + model: "gpt-4o-2024".to_string(), + }; + assert_eq!(price("openai", &full), 2_500_000_000); // 1e6 * 2500 + } + + #[test] + fn price_zero_tokens_is_zero() { + let u = Usage { + input: 0, + output: 0, + cache_write: 0, + cache_read: 0, + model: "claude-opus-4-1".to_string(), + }; + assert_eq!(price("anthropic", &u), 0); + } + + #[test] + fn price_unknown_model_is_zero_no_panic() { + let u = Usage { + input: 1_000, + output: 1_000, + cache_write: 0, + cache_read: 0, + model: "some-unlisted-model".to_string(), + }; + assert_eq!(price("anthropic", &u), 0); + } + + #[test] + fn period_key_monthly_zero_pads() { + let key = period_key(BudgetPeriod::Monthly, utc(2026, Month::July, 29)); + assert_eq!(key, "m:2026-07"); + let jan = period_key(BudgetPeriod::Monthly, utc(2027, Month::January, 1)); + assert_eq!(jan, "m:2027-01"); + } + + #[test] + fn period_key_dec_jan_rollover_distinct() { + let dec = period_key(BudgetPeriod::Monthly, utc(2026, Month::December, 31)); + let jan = period_key(BudgetPeriod::Monthly, utc(2027, Month::January, 1)); + assert_eq!(dec, "m:2026-12"); + assert_eq!(jan, "m:2027-01"); + assert_ne!(dec, jan); + } + + #[test] + fn period_key_total_is_constant() { + assert_eq!( + period_key(BudgetPeriod::Total, utc(2026, Month::July, 29)), + "total" + ); + } + + #[test] + fn counter_key_stable_and_collision_free() { + let a = counter_key("sec1", "org1", "m:2026-07"); + assert_eq!(a, "budget:spent:sec1:org1:m:2026-07"); + assert_ne!(a, counter_key("sec2", "org1", "m:2026-07")); + assert_ne!(a, counter_key("sec1", "org2", "m:2026-07")); + assert_ne!(a, counter_key("sec1", "org1", "m:2026-08")); + } + + #[test] + fn is_over_boundary() { + assert!(!is_over(99, 100)); // under + assert!(is_over(100, 100)); // exactly at → deny + assert!(is_over(101, 100)); // over + assert!(!is_over(0, 100)); + } + + #[test] + fn cent_to_nanos_conversion() { + // 500 cents ($5.00) → 5e9 nano-dollars + assert_eq!(500 * CENT_TO_NANOS, 5_000_000_000); + } } diff --git a/apps/gateway/src/db.rs b/apps/gateway/src/db.rs index c99c9af8..beff8deb 100644 --- a/apps/gateway/src/db.rs +++ b/apps/gateway/src/db.rs @@ -55,6 +55,14 @@ pub(crate) struct SecretRow { pub metadata: Option, } +/// A budget row from the `budgets` table (cost cap on a secret for an org). +#[derive(Debug, FromRow)] +pub(crate) struct BudgetRow { + pub secret_id: String, + pub limit_cents: i32, + pub period: String, +} + /// A user row from the `users` table. #[derive(Debug, FromRow)] pub(crate) struct UserRow { @@ -344,6 +352,73 @@ pub(crate) async fn find_secrets_by_org( .context("querying secrets by organization_id") } +/// Load budgets for the given org and secret ids (host-matched metered LLM +/// secrets). Returns one row per bound secret (0/1 in practice per host). +pub(crate) async fn find_budgets_for_secrets( + pool: &PgPool, + organization_id: &str, + secret_ids: &[String], +) -> Result> { + sqlx::query_as::<_, BudgetRow>( + r#"SELECT secret_id, limit_cents, period + FROM budgets + WHERE organization_id = $1 AND secret_id = ANY($2)"#, + ) + .bind(organization_id) + .bind(secret_ids) + .fetch_all(pool) + .await + .context("querying budgets for secrets") +} + +/// Read the durable accumulated spend (nano-dollars) for a `(secret, org, +/// period)` window. `None` when the window has no recorded spend yet. +pub(crate) async fn read_budget_spend( + pool: &PgPool, + secret_id: &str, + organization_id: &str, + period: &str, +) -> Result> { + let row: Option<(i64,)> = sqlx::query_as( + r#"SELECT spent_nanos FROM budget_spends + WHERE secret_id = $1 AND organization_id = $2 AND period = $3"#, + ) + .bind(secret_id) + .bind(organization_id) + .bind(period) + .fetch_optional(pool) + .await + .context("reading budget spend")?; + Ok(row.map(|r| r.0)) +} + +/// Accumulate `delta_nanos` into the durable spend floor for a `(secret, org, +/// period)` window and return the new total. The durable floor is rehydrated +/// into the hot counter on cache miss so a flush can't silently refill a budget. +pub(crate) async fn upsert_budget_spend( + pool: &PgPool, + secret_id: &str, + organization_id: &str, + period: &str, + delta_nanos: i64, +) -> Result { + let row: (i64,) = sqlx::query_as( + r#"INSERT INTO budget_spends (secret_id, organization_id, period, spent_nanos, updated_at) + VALUES ($1, $2, $3, $4, NOW()) + ON CONFLICT (secret_id, organization_id, period) + DO UPDATE SET spent_nanos = budget_spends.spent_nanos + $4, updated_at = NOW() + RETURNING spent_nanos"#, + ) + .bind(secret_id) + .bind(organization_id) + .bind(period) + .bind(delta_nanos) + .fetch_one(pool) + .await + .context("upserting budget spend")?; + Ok(row.0) +} + /// Update a secret's encrypted value (used for token refresh). pub(crate) async fn update_secret_value( pool: &PgPool, diff --git a/apps/gateway/src/gateway/hooks.rs b/apps/gateway/src/gateway/hooks.rs index 7724703e..a5b50b63 100644 --- a/apps/gateway/src/gateway/hooks.rs +++ b/apps/gateway/src/gateway/hooks.rs @@ -1,23 +1,36 @@ //! Forward hooks — extension points for the request forwarding pipeline. //! -//! OSS version: all hooks are no-ops. The cloud build swaps this module -//! via `#[path = "ee/hooks.rs"]` to add cloud-specific telemetry. +//! OSS spend budgets live here: +//! - [`pre_forward`] is the PRE-request enforcement gate: read the running spend +//! total for each metered budget binding and deny `402` if it already meets or +//! exceeds the cap. Fails OPEN — a read error lets the request through. +//! - [`track_and_wrap`] is the POST-response meter: when a metered budget binding +//! applies it tees a bounded copy of the response stream, parses the provider +//! `usage` at stream end, prices it, and attaches a `BudgetCharge` to the +//! emitted `RequestEvent` (the telemetry flush accumulates it). use std::pin::Pin; +use std::task::{Context, Poll}; -use futures_util::TryStreamExt; +use futures_util::{Stream, TryStreamExt}; use http_body_util::{Either, Full, StreamBody}; use hyper::body::{Bytes, Frame}; -use hyper::Response; +use hyper::header::CONTENT_TYPE; +use hyper::{Response, StatusCode}; use super::mitm::ResolvedRules; use super::ProxyContext; +use crate::budget::{self, BudgetBinding}; // ── Shared types ──────────────────────────────────────────────────────── pub(crate) type BodyStream = Pin, reqwest::Error>> + Send>>; +/// Raw upstream body stream (bytes), before framing. +type RawBodyStream = + Pin> + Send>>; + pub(crate) type ForwardResponseBody = Either, StreamBody>; /// Common telemetry fields for a proxied request, passed from forward to hooks. @@ -45,12 +58,33 @@ pub(crate) struct RequestMeta { // ── Hooks ─────────────────────────────────────────────────────────────── +/// Pre-forward request-header hook. When a metered budget binding applies to this +/// host, force `Accept-Encoding: identity`. +/// +/// The meter reads the provider `usage` object out of the response bytes +/// ([`track_and_wrap`]). `reqwest` is built WITHOUT gzip/brotli/deflate, and the +/// client's `accept-encoding` is otherwise forwarded upstream verbatim — so a +/// client default of `gzip` (the httpx/urllib3 SDKs) would make the provider +/// return compressed bytes that [`budget::parse_usage`] cannot scan, silently +/// charging 0 and never firing the cap. Overriding to `identity` keeps the tee'd +/// copy parseable. (Streaming/SSE is served identity-encoded anyway; this closes +/// the non-stream compressed gap.) pub(crate) fn prepare_request( - _rules: &ResolvedRules, + rules: &ResolvedRules, _host: &str, _path: &str, - _headers: &mut hyper::HeaderMap, + headers: &mut hyper::HeaderMap, ) { + let has_metered = rules + .budget_bindings + .iter() + .any(|b| budget::is_metered_type(&b.secret_type)); + if has_metered { + headers.insert( + hyper::header::ACCEPT_ENCODING, + hyper::header::HeaderValue::from_static("identity"), + ); + } } /// Whether the request guard needs the buffered request body to make a @@ -64,22 +98,104 @@ pub(crate) fn needs_request_body( false } +/// PRE-request budget gate. For each metered budget binding, read the running +/// spend total and deny `402 Payment Required` if it already meets/exceeds the +/// cap (stricter-verdict: any binding over ⇒ deny). Runs after the security +/// gates, so a policy-denied request is never budget-checked. Fails OPEN — if a +/// total can't be read, the request proceeds. #[allow(clippy::too_many_arguments)] pub(crate) async fn pre_forward( - _rules: &ResolvedRules, + rules: &ResolvedRules, _proxy_ctx: &ProxyContext, _host: &str, - _cache: &dyn crate::cache::CacheStore, - _pool: &sqlx::PgPool, + cache: &dyn crate::cache::CacheStore, + pool: &sqlx::PgPool, _injection_count: usize, _method: &str, _path: &str, _headers: &hyper::HeaderMap, _body: Option<&[u8]>, ) -> Option> { + for binding in &rules.budget_bindings { + if !budget::is_metered_type(&binding.secret_type) { + continue; + } + let period_key = budget::period_key(binding.period, time::OffsetDateTime::now_utc()); + // Fail-open: only a readable total that is over the cap denies. + if let Some(spent) = read_running_total(cache, pool, binding, &period_key).await { + if budget::is_over(spent, binding.limit_nanos) { + tracing::warn!( + secret_id = %binding.secret_id, + spent, + limit = binding.limit_nanos, + "BUDGET exceeded — denying request (402)" + ); + return Some(budget_denied_response(binding, spent)); + } + } + } None } +/// Read the hot spend counter (nano-dollars). On cache miss, rehydrate once from +/// the durable `BudgetSpend` floor and seed the counter. Returns `None` only on +/// an unreadable state (fail-open at the call site). +async fn read_running_total( + cache: &dyn crate::cache::CacheStore, + pool: &sqlx::PgPool, + binding: &BudgetBinding, + period_key: &str, +) -> Option { + let key = budget::counter_key(&binding.secret_id, &binding.organization_id, period_key); + if let Some(raw) = cache.get_raw(&key).await { + return raw.parse::().ok(); + } + match crate::db::read_budget_spend( + pool, + &binding.secret_id, + &binding.organization_id, + period_key, + ) + .await + { + Ok(spent) => { + let value = spent.unwrap_or(0); + cache + .set_raw(&key, &value.to_string(), budget::PERIOD_TTL) + .await; + Some(value) + } + Err(e) => { + tracing::warn!(error = %e, "budget: spend read failed; allowing request (fail-open)"); + None + } + } +} + +/// Build the structured `402` deny body so the SDK can render "spend cap reached" +/// distinctly from a `429` rate-limit. +fn budget_denied_response(binding: &BudgetBinding, spent: i64) -> Response { + let period = match binding.period { + budget::BudgetPeriod::Monthly => "monthly", + budget::BudgetPeriod::Total => "total", + }; + let body = serde_json::json!({ + "error": "budget_exceeded", + "secretId": binding.secret_id, + "period": period, + "limitCents": binding.limit_nanos / budget::CENT_TO_NANOS, + "spentCents": spent / budget::CENT_TO_NANOS, + }); + let bytes = serde_json::to_vec(&body).unwrap_or_default(); + let mut resp = Response::new(Either::Left(Full::new(Bytes::from(bytes)))); + *resp.status_mut() = StatusCode::PAYMENT_REQUIRED; + resp.headers_mut().insert( + CONTENT_TYPE, + hyper::header::HeaderValue::from_static("application/json"), + ); + resp +} + /// Request-body transform hook. OSS: passthrough. The cloud build injects a /// claim note into LLM requests for unclaimed (partner-created) orgs. pub(crate) async fn prepare_request_body( @@ -90,13 +206,36 @@ pub(crate) async fn prepare_request_body( body } +/// POST-response telemetry + optional spend metering. When a metered budget +/// binding applies, the response stream is teed (bounded copy) so the emitted +/// `RequestEvent` carries a priced `BudgetCharge`; otherwise telemetry emits +/// immediately and the stream passes through unchanged. pub(crate) fn track_and_wrap( meta: RequestMeta, - _rules: &ResolvedRules, + rules: &ResolvedRules, _resp_headers: &hyper::HeaderMap, stream: impl futures_util::Stream> + Send + 'static, ) -> BodyStream { - crate::telemetry::on_request(crate::telemetry::RequestEvent { + let event = build_event(meta); + + // 0/1 metered binding per host in practice; take the first that can be priced. + let metered = rules + .budget_bindings + .iter() + .find(|b| budget::is_metered_type(&b.secret_type)) + .cloned(); + + match metered { + Some(binding) => Box::pin(MeteredStream::new(Box::pin(stream), event, binding)), + None => { + crate::telemetry::on_request(event); + Box::pin(stream.map_ok(Frame::data)) + } + } +} + +fn build_event(meta: RequestMeta) -> crate::telemetry::RequestEvent { + crate::telemetry::RequestEvent { org_id: meta.org_id, project_id: meta.project_id, agent_id: meta.agent_id, @@ -118,6 +257,103 @@ pub(crate) fn track_and_wrap( log_id: None, budget_charge: None, matched_rule: meta.matched_rule, - }); - Box::pin(stream.map_ok(Frame::data)) + } +} + +// ── Metered response stream ────────────────────────────────────────────── + +/// Wraps the upstream body: forwards every frame unchanged while appending a +/// bounded head (leading bytes — SSE `message_start`) and rolling tail (trailing +/// bytes — non-stream `usage` / SSE final `message_delta`) copy. At stream end +/// it parses + prices the usage and emits the `RequestEvent` with the charge. If +/// the stream is dropped before completion, the base event (no charge) is still +/// emitted — fail-open on the charge, no lost request log. +struct MeteredStream { + inner: RawBodyStream, + head: Vec, + tail: Vec, + /// Taken exactly once — by `finalize` on clean end, else by `Drop`. + event: Option, + binding: BudgetBinding, +} + +impl MeteredStream { + fn new( + inner: RawBodyStream, + event: crate::telemetry::RequestEvent, + binding: BudgetBinding, + ) -> Self { + Self { + inner, + head: Vec::new(), + tail: Vec::new(), + event: Some(event), + binding, + } + } + + fn absorb(&mut self, bytes: &[u8]) { + if self.head.len() < budget::META_CAP { + let take = (budget::META_CAP - self.head.len()).min(bytes.len()); + self.head.extend_from_slice(&bytes[..take]); + } + self.tail.extend_from_slice(bytes); + if self.tail.len() > budget::META_CAP { + let excess = self.tail.len() - budget::META_CAP; + self.tail.drain(0..excess); + } + } + + /// Compute the charge and emit the event on clean stream end. + fn finalize(&mut self) { + let Some(mut event) = self.event.take() else { + return; + }; + if let Some(usage) = budget::parse_usage(&self.binding.secret_type, &self.head, &self.tail) + { + let cost = budget::price(&self.binding.secret_type, &usage); + if cost > 0 { + event.budget_charge = Some(crate::telemetry_core::BudgetCharge { + secret_id: self.binding.secret_id.clone(), + organization_id: self.binding.organization_id.clone(), + period_key: budget::period_key( + self.binding.period, + time::OffsetDateTime::now_utc(), + ), + cost_nanos: cost, + }); + } + } + crate::telemetry::on_request(event); + } +} + +impl Stream for MeteredStream { + type Item = Result, reqwest::Error>; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + match this.inner.as_mut().poll_next(cx) { + Poll::Ready(Some(Ok(bytes))) => { + this.absorb(&bytes); + Poll::Ready(Some(Ok(Frame::data(bytes)))) + } + Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))), + Poll::Ready(None) => { + this.finalize(); + Poll::Ready(None) + } + Poll::Pending => Poll::Pending, + } + } +} + +impl Drop for MeteredStream { + fn drop(&mut self) { + // Dropped before clean end (client disconnect): emit the base event so + // the request log is preserved; no charge (fail-open). + if let Some(event) = self.event.take() { + crate::telemetry::on_request(event); + } + } } diff --git a/apps/gateway/src/telemetry.rs b/apps/gateway/src/telemetry.rs index 1daabcb2..9b70d373 100644 --- a/apps/gateway/src/telemetry.rs +++ b/apps/gateway/src/telemetry.rs @@ -23,13 +23,36 @@ pub(crate) use crate::telemetry_core::{on_request, RequestEvent}; /// Initialize the telemetry background flush task. /// Must be called once at startup from `main()`. -pub(crate) fn init(pool: PgPool, _cache: Arc) { +pub(crate) fn init(pool: PgPool, cache: Arc) { let (tx, rx) = mpsc::channel::(CHANNEL_CAPACITY); SENDER.set(tx).ok(); - crate::telemetry_core::spawn_flush_loop(flush_loop(rx, pool)); + crate::telemetry_core::spawn_flush_loop(flush_loop(rx, pool, cache)); info!("telemetry initialized (postgres)"); } +/// The identity of a spend counter: `(secret_id, organization_id, period_key)`. +type SpendKey = (String, String, String); + +/// Record a metered spend delta for one counter: accumulate the durable +/// `BudgetSpend` floor and seed the hot counter to the new coherent total. Runs +/// off the request path (in the flush loop), so no request-path latency. Failures +/// are logged, never fatal (fail-open on spend). +async fn record_spend(pool: &PgPool, cache: &dyn CacheStore, key: &SpendKey, delta: i64) { + let (secret_id, organization_id, period_key) = key; + match crate::db::upsert_budget_spend(pool, secret_id, organization_id, period_key, delta).await + { + Ok(total) => { + let counter = crate::budget::counter_key(secret_id, organization_id, period_key); + cache + .set_raw(&counter, &total.to_string(), crate::budget::PERIOD_TTL) + .await; + } + Err(e) => { + warn!(error = %e, secret_id = %secret_id, "budget: failed to record spend"); + } + } +} + async fn insert_batch(pool: &PgPool, events: &[RequestEvent]) -> Result<(), sqlx::Error> { let filtered: Vec<&RequestEvent> = events .iter() @@ -114,7 +137,11 @@ async fn update_batch(pool: &PgPool, events: &[RequestEvent]) { } } -async fn flush_loop(mut rx: mpsc::Receiver, pool: PgPool) { +async fn flush_loop( + mut rx: mpsc::Receiver, + pool: PgPool, + cache: Arc, +) { let mut buffer: Vec = Vec::with_capacity(FLUSH_BATCH_SIZE); loop { @@ -126,9 +153,27 @@ async fn flush_loop(mut rx: mpsc::Receiver, pool: PgPool) { continue; } + // Sum metered spend per counter across the whole drained batch, so a + // busy flush does one upsert per distinct (secret, org, period) rather + // than one per request event. Durability boundary: a charge is volatile + // in the in-memory channel until this runs — a crash before the upsert + // loses at most the unflushed tail (≤FLUSH_INTERVAL_SECS), a fail-open + // under-count consistent with the async-telemetry design; flushed spend + // survives restart (rehydrated from the durable `BudgetSpend` floor). + let mut charges: std::collections::HashMap = + std::collections::HashMap::new(); let mut updates = Vec::new(); let mut regular = Vec::new(); for event in buffer.drain(..) { + if let Some(charge) = event.budget_charge.as_ref() { + *charges + .entry(( + charge.secret_id.clone(), + charge.organization_id.clone(), + charge.period_key.clone(), + )) + .or_default() += charge.cost_nanos; + } if event.existing_log_id.is_some() { updates.push(event); } else { @@ -136,6 +181,11 @@ async fn flush_loop(mut rx: mpsc::Receiver, pool: PgPool) { } } + // Persist the aggregated spend deltas (few keys per flush in practice). + for (key, delta) in &charges { + record_spend(&pool, cache.as_ref(), key, *delta).await; + } + if let Err(e) = insert_batch(&pool, ®ular).await { warn!(count = regular.len(), error = %e, "telemetry batch insert failed"); } diff --git a/apps/web/src/app/(dashboard)/connections/(tabs)/budgets/_components/budget-row-actions.tsx b/apps/web/src/app/(dashboard)/connections/(tabs)/budgets/_components/budget-row-actions.tsx new file mode 100644 index 00000000..be423abb --- /dev/null +++ b/apps/web/src/app/(dashboard)/connections/(tabs)/budgets/_components/budget-row-actions.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { useState } from "react"; +import { Loader2, MoreHorizontal } from "lucide-react"; +import { Button } from "@onecli/ui/components/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + 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 { Input } from "@onecli/ui/components/input"; +import { Label } from "@onecli/ui/components/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@onecli/ui/components/select"; +import type { Budget } from "@/lib/api"; +import { useDeleteBudget, useUpdateBudget } from "@/hooks/use-budgets"; + +export interface BudgetRowActionsProps { + budget: Budget; +} + +export const BudgetRowActions = ({ budget }: BudgetRowActionsProps) => { + const [editing, setEditing] = useState(false); + const [confirming, setConfirming] = useState(false); + const [amount, setAmount] = useState((budget.limitCents / 100).toFixed(2)); + const [period, setPeriod] = useState<"monthly" | "total">(budget.period); + + const update = useUpdateBudget(); + const remove = useDeleteBudget(); + + const cents = Math.round(Number(amount) * 100); + const canSave = Number.isFinite(cents) && cents > 0 && !update.isPending; + + const onSave = () => { + if (!canSave) return; + update.mutate( + { id: budget.id, input: { limitCents: cents, period } }, + { onSuccess: () => setEditing(false) }, + ); + }; + + return ( + <> + + + + + + setEditing(true)}> + Edit + + setConfirming(true)} + > + Delete + + + + + + + + Edit budget · {budget.secretName} + +
+
+ + setAmount(e.target.value)} + /> +
+
+ + +
+
+ + + + +
+
+ + + + + Remove this budget? + + Spend on {budget.secretName} will no longer be + capped. Recorded spend is kept. + + + + + Cancel + + { + e.preventDefault(); + remove.mutate(budget.id, { + onSuccess: () => setConfirming(false), + }); + }} + disabled={remove.isPending} + > + {remove.isPending ? ( + <> + + Removing… + + ) : ( + "Remove" + )} + + + + + + ); +}; diff --git a/apps/web/src/app/(dashboard)/connections/(tabs)/budgets/_components/budget-usage-bar.tsx b/apps/web/src/app/(dashboard)/connections/(tabs)/budgets/_components/budget-usage-bar.tsx new file mode 100644 index 00000000..55dfac7b --- /dev/null +++ b/apps/web/src/app/(dashboard)/connections/(tabs)/budgets/_components/budget-usage-bar.tsx @@ -0,0 +1,60 @@ +import { cn } from "@onecli/ui/lib/utils"; + +const formatDollars = (cents: number) => `$${(cents / 100).toFixed(2)}`; + +export interface BudgetUsageBarProps { + spentCents: number; + limitCents: number; + period: "monthly" | "total"; +} + +/** + * Spend meter: `$X of $Y this month`, filling green → amber (≥80%) → red + * (≥100%). Accessible in light and dark; the track/fill carry a text label so + * the state does not rely on color alone. + */ +export const BudgetUsageBar = ({ + spentCents, + limitCents, + period, +}: BudgetUsageBarProps) => { + const ratio = limitCents > 0 ? spentCents / limitCents : 0; + const pct = Math.min(Math.max(ratio, 0), 1) * 100; + const over = spentCents >= limitCents && limitCents > 0; + const warn = ratio >= 0.8; + + const fill = over + ? "bg-red-500 dark:bg-red-500" + : warn + ? "bg-amber-500 dark:bg-amber-400" + : "bg-emerald-500 dark:bg-emerald-400"; + + return ( +
+
+
+
+

+ {formatDollars(spentCents)} of {formatDollars(limitCents)}{" "} + {period === "monthly" ? "this month" : "total"} + {over && " — cap reached"} +

+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/connections/(tabs)/budgets/_components/budgets-content.tsx b/apps/web/src/app/(dashboard)/connections/(tabs)/budgets/_components/budgets-content.tsx new file mode 100644 index 00000000..71bb66c5 --- /dev/null +++ b/apps/web/src/app/(dashboard)/connections/(tabs)/budgets/_components/budgets-content.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { ApiError } from "@/lib/api"; +import { useBudgets } from "@/hooks/use-budgets"; +import { BudgetsList } from "./budgets-list"; +import { CreateBudgetDialog } from "./create-budget-dialog"; + +export const BudgetsContent = () => { + const { data: budgets = [], isLoading, error } = useBudgets(); + + // Budgets are org guardrails: only admins with an org credential may manage + // them. A 403 is expected for members — render an admin-only notice. + if (error instanceof ApiError && error.status === 403) { + return ( +

+ Spend budgets are managed by organization admins. +

+ ); + } + + // Any other failure (500, network) is a real error — surface it rather than + // falling through to the empty `budgets = []` state, which would present a + // transport failure as "No budgets yet." + if (error) { + return ( +

+ Failed to load spend budgets. Please try again. +

+ ); + } + + return ( +
+
+
+

Spend budgets

+

+ Cap LLM spend per secret. The gateway meters token usage and blocks + new requests once a cap is reached. +

+
+ +
+ + {isLoading ? ( +

Loading budgets…

+ ) : budgets.length === 0 ? ( +

+ No budgets yet. Create one to cap spend on an Anthropic or OpenAI + secret. +

+ ) : ( + + )} +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/connections/(tabs)/budgets/_components/budgets-list.tsx b/apps/web/src/app/(dashboard)/connections/(tabs)/budgets/_components/budgets-list.tsx new file mode 100644 index 00000000..a1cfa645 --- /dev/null +++ b/apps/web/src/app/(dashboard)/connections/(tabs)/budgets/_components/budgets-list.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@onecli/ui/components/table"; +import { Badge } from "@onecli/ui/components/badge"; +import type { Budget } from "@/lib/api"; +import { BudgetUsageBar } from "./budget-usage-bar"; +import { BudgetRowActions } from "./budget-row-actions"; + +export interface BudgetsListProps { + budgets: Budget[]; +} + +export const BudgetsList = ({ budgets }: BudgetsListProps) => { + return ( +
+ + + + Secret + Provider + Period + Usage + + + + + {budgets.map((b) => ( + + {b.secretName} + + {b.secretType} + + + {b.period} + + + + + + + + + ))} + +
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/connections/(tabs)/budgets/_components/create-budget-dialog.tsx b/apps/web/src/app/(dashboard)/connections/(tabs)/budgets/_components/create-budget-dialog.tsx new file mode 100644 index 00000000..69060c8b --- /dev/null +++ b/apps/web/src/app/(dashboard)/connections/(tabs)/budgets/_components/create-budget-dialog.tsx @@ -0,0 +1,185 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { Loader2, Plus } from "lucide-react"; +import { Button } from "@onecli/ui/components/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@onecli/ui/components/dialog"; +import { Input } from "@onecli/ui/components/input"; +import { Label } from "@onecli/ui/components/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@onecli/ui/components/select"; +import type { Budget, Secret } from "@/lib/api"; +import { useCreateBudget, useMeteredSecrets } from "@/hooks/use-budgets"; + +export interface CreateBudgetDialogProps { + /** Existing budgets — a secret already capped is excluded from the picker. */ + existing: Budget[]; +} + +const dollarsToCents = (value: string): number | null => { + const dollars = Number(value); + if (!Number.isFinite(dollars) || dollars <= 0) return null; + const cents = Math.round(dollars * 100); + return cents > 0 ? cents : null; +}; + +export const CreateBudgetDialog = ({ existing }: CreateBudgetDialogProps) => { + const [open, setOpen] = useState(false); + const [secretId, setSecretId] = useState(""); + const [amount, setAmount] = useState(""); + const [period, setPeriod] = useState<"monthly" | "total">("monthly"); + + const { data: secrets = [], isLoading: secretsLoading } = + useMeteredSecrets(open); + const create = useCreateBudget(); + + const cappedIds = useMemo( + () => new Set(existing.map((b) => b.secretId)), + [existing], + ); + const available = useMemo( + () => secrets.filter((s) => !cappedIds.has(s.id)), + [secrets, cappedIds], + ); + + const cents = dollarsToCents(amount); + const canSubmit = secretId !== "" && cents !== null && !create.isPending; + + const reset = () => { + setSecretId(""); + setAmount(""); + setPeriod("monthly"); + }; + + const onSubmit = () => { + if (!canSubmit || cents === null) return; + create.mutate( + { secretId, limitCents: cents, period }, + { + onSuccess: () => { + setOpen(false); + reset(); + }, + }, + ); + }; + + return ( + { + setOpen(next); + if (!next) reset(); + }} + > + + + + + + New spend budget + + Cap spend on an Anthropic or OpenAI secret. The gateway blocks new + requests once the cap is reached — one in-flight request may + overshoot. Streaming spend may under-count for some providers. + + + +
+
+ + +
+ +
+ + setAmount(e.target.value)} + /> +
+ +
+ + +
+
+ + + + + +
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/connections/(tabs)/budgets/page.tsx b/apps/web/src/app/(dashboard)/connections/(tabs)/budgets/page.tsx new file mode 100644 index 00000000..7d344925 --- /dev/null +++ b/apps/web/src/app/(dashboard)/connections/(tabs)/budgets/page.tsx @@ -0,0 +1,10 @@ +import { Suspense } from "react"; +import { BudgetsContent } from "./_components/budgets-content"; + +export default function ConnectionsBudgetsPage() { + return ( + + + + ); +} diff --git a/apps/web/src/app/(dashboard)/connections/_components/connections-tabs.tsx b/apps/web/src/app/(dashboard)/connections/_components/connections-tabs.tsx index c7053687..a9f8df40 100644 --- a/apps/web/src/app/(dashboard)/connections/_components/connections-tabs.tsx +++ b/apps/web/src/app/(dashboard)/connections/_components/connections-tabs.tsx @@ -23,6 +23,7 @@ const getTabRoutes = (pathname: string): Record => { apps: base, custom: `${base}/custom`, llms: `${base}/llms`, + budgets: `${base}/budgets`, vaults: `${base}/vaults`, connected: `${base}/connected`, }; @@ -32,6 +33,7 @@ const pathToTab = (pathname: string): string => { const segment = pathname.split("/connections")[1]?.replace(/^\//, "") || ""; if (segment === "custom") return "custom"; if (segment === "llms") return "llms"; + if (segment === "budgets") return "budgets"; if (segment === "vaults") return "vaults"; if (segment === "connected") return "connected"; return "apps"; @@ -61,6 +63,7 @@ export const ConnectionsTabs = ({ apps: basePath, custom: `${basePath}/custom`, llms: `${basePath}/llms`, + budgets: `${basePath}/budgets`, vaults: `${basePath}/vaults`, connected: `${basePath}/connected`, } @@ -104,6 +107,7 @@ export const ConnectionsTabs = ({ Apps Custom LLMs + Budgets {showVaults && ( Vaults diff --git a/apps/web/src/hooks/use-budgets.ts b/apps/web/src/hooks/use-budgets.ts new file mode 100644 index 00000000..ff8a6f16 --- /dev/null +++ b/apps/web/src/hooks/use-budgets.ts @@ -0,0 +1,81 @@ +"use client"; + +import { useMemo } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { budgets, secrets } from "@/lib/api"; +import type { CreateBudgetInput, UpdateBudgetInput } from "@/lib/api"; +import { queryKeys } from "@/lib/api/keys"; + +// Budget mutations are headless on the gateway cache: the audited API routes +// invalidate it server-side (withAudit keyed off organizationId). + +/** LLM secret types the gateway can meter (must match budget-service). */ +const METERED_TYPES = ["anthropic", "openai"]; + +export const useBudgets = () => + useQuery({ + queryKey: queryKeys.budgets.list(), + queryFn: budgets.list, + // A member (non-admin) gets a 403; surface it as an empty admin-only state + // rather than retrying. + retry: false, + }); + +/** + * The org's metered LLM secrets, for the create picker. Reads the org-scoped + * secrets endpoint (admin-gated) and keeps only meterable types. + */ +export const useMeteredSecrets = (enabled = true) => { + const query = useQuery({ + queryKey: [...queryKeys.secrets.all(), "organization", "metered"], + queryFn: () => secrets.listScoped("organization"), + enabled, + retry: false, + }); + const metered = useMemo( + () => (query.data ?? []).filter((s) => METERED_TYPES.includes(s.type)), + [query.data], + ); + return { ...query, data: metered }; +}; + +export const useCreateBudget = () => { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (input: CreateBudgetInput) => budgets.create(input), + onSuccess: () => { + qc.invalidateQueries({ queryKey: queryKeys.budgets.all() }); + toast.success("Budget created"); + }, + onError: (err: Error) => + toast.error(err.message || "Failed to create budget"), + }); +}; + +export const useUpdateBudget = () => { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, input }: { id: string; input: UpdateBudgetInput }) => + budgets.update(id, input), + onSuccess: () => { + qc.invalidateQueries({ queryKey: queryKeys.budgets.all() }); + toast.success("Budget updated"); + }, + onError: (err: Error) => + toast.error(err.message || "Failed to update budget"), + }); +}; + +export const useDeleteBudget = () => { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => budgets.remove(id), + onSuccess: () => { + qc.invalidateQueries({ queryKey: queryKeys.budgets.all() }); + toast.success("Budget removed"); + }, + onError: (err: Error) => + toast.error(err.message || "Failed to remove budget"), + }); +}; diff --git a/apps/web/src/lib/api/budgets.ts b/apps/web/src/lib/api/budgets.ts new file mode 100644 index 00000000..a5ce08c2 --- /dev/null +++ b/apps/web/src/lib/api/budgets.ts @@ -0,0 +1,43 @@ +import { apiGet, apiPost, apiPatch, apiDelete } from "./client"; + +// Spend budgets are ORG guardrails: always `/v1/org/budgets` (admin-gated, +// requireProject: false). A budget caps spend on a metered LLM secret the org +// owns; the gateway enforces it (see apps/gateway/src/budget.rs). + +export type BudgetPeriod = "monthly" | "total"; + +export interface Budget { + id: string; + secretId: string; + secretName: string; + secretType: string; + limitCents: number; + period: BudgetPeriod; + /** Accumulated spend this period, floored to whole cents. */ + spentCents: number; + createdAt: string; + updatedAt: string; +} + +export interface CreateBudgetInput { + secretId: string; + limitCents: number; + period?: BudgetPeriod; +} + +export interface UpdateBudgetInput { + limitCents?: number; + period?: BudgetPeriod; +} + +const base = "/v1/org/budgets"; + +export const list = () => apiGet(base); + +export const create = (input: CreateBudgetInput) => + apiPost(base, input); + +export const update = (id: string, input: UpdateBudgetInput) => + apiPatch(`${base}/${id}`, input); + +export const remove = (id: string) => apiDelete(`${base}/${id}`); diff --git a/apps/web/src/lib/api/index.ts b/apps/web/src/lib/api/index.ts index c13edee4..88461c0e 100644 --- a/apps/web/src/lib/api/index.ts +++ b/apps/web/src/lib/api/index.ts @@ -16,6 +16,7 @@ import * as appAvailability from "./app-availability"; import * as appPermissions from "./app-permissions"; import * as vaults from "./vaults"; import * as dropbox from "./dropbox"; +import * as budgets from "./budgets"; export { agents, @@ -36,6 +37,7 @@ export { appPermissions, vaults, dropbox, + budgets, }; export type { Agent, @@ -81,6 +83,12 @@ export type { GrantsSummaryEntry, } from "./types"; export type { CreatePolicyRuleInput, UpdatePolicyRuleInput } from "./policy"; +export type { + Budget, + BudgetPeriod, + CreateBudgetInput, + UpdateBudgetInput, +} from "./budgets"; export { appsPath } from "./scope"; export type { PageScope } from "./scope"; export type { AppConfigStatus } from "./app-config"; diff --git a/apps/web/src/lib/api/keys.ts b/apps/web/src/lib/api/keys.ts index 52784fc3..1aef9759 100644 --- a/apps/web/src/lib/api/keys.ts +++ b/apps/web/src/lib/api/keys.ts @@ -106,6 +106,10 @@ export const queryKeys = { byProvider: (provider: string) => [...queryKeys.appBlocklist.all(), provider] as const, }, + budgets: { + all: () => ["budgets", ...scope()] as const, + list: () => [...queryKeys.budgets.all(), "list"] as const, + }, billing: { all: () => ["billing", ...scope()] as const, planUsage: () => [...queryKeys.billing.all(), "planUsage"] as const, diff --git a/packages/api/src/routes/org/budgets.test.ts b/packages/api/src/routes/org/budgets.test.ts new file mode 100644 index 00000000..15d68ded --- /dev/null +++ b/packages/api/src/routes/org/budgets.test.ts @@ -0,0 +1,559 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Hono } from "hono"; +import type { ApiEnv } from "../../types"; + +// `/v1/org/budgets` end-to-end through the real app: OSS org routes on the +// `eeRoutes` seam, the OSS role resolver, admin callers with an ORG API key. +// Same harness shape as policy.test.ts (cloned, not shared) with an in-memory +// budgets/secrets/spend store — the point of this router is which SCOPE (and +// which org) the budget CRUD writes/reads in, plus the metered-type guard. + +const ORG = "org-1"; +const OTHER_ORG = "org-2"; +const ADMIN = "user-admin"; +const OUTSIDER = "user-outsider"; +const OWNER = "user-owner"; +const ADMIN_KEY = "oc_org_admin-key"; +const OTHER_ADMIN_KEY = "oc_org_outsider-key"; +const PROJECT_KEY = "oc_project-key-of-owner"; + +vi.hoisted(() => { + process.env.NEXT_PUBLIC_EDITION = "oss"; + process.env.SECRET_ENCRYPTION_KEY = "test-secret"; + process.env.OAUTH_STATE_SECRET = "test-secret"; +}); + +interface MemberRow { + organizationId: string; + userId: string; + userEmail: string; + role: string; + status: string; + ssoExempt: boolean; + suspendedAt: Date | null; + createdAt: Date; +} + +interface UserRow { + id: string; + externalAuthId: string; + email: string; + name: string | null; +} + +interface SecretRow { + id: string; + name: string; + type: string; + organizationId: string | null; +} + +interface BudgetRow { + id: string; + secretId: string; + organizationId: string; + limitCents: number; + period: string; + createdBy: string; + createdAt: Date; + updatedAt: Date; +} + +interface SpendRow { + secretId: string; + organizationId: string; + period: string; + spentNanos: bigint; +} + +interface AuditRow { + organizationId?: string; + projectId?: string; + userId: string; + action: string; + service: string; + source: string; + metadata: Record; +} + +const store = vi.hoisted(() => ({ + members: [] as MemberRow[], + users: [] as UserRow[], + secrets: [] as SecretRow[], + budgets: [] as BudgetRow[], + spends: [] as SpendRow[], + audits: [] as AuditRow[], + seq: 0, +})); + +vi.mock("@onecli/db", () => { + class PrismaClientKnownRequestError extends Error { + code: string; + constructor(message: string, code: string) { + super(message); + this.code = code; + } + } + + const nextId = (prefix: string) => `${prefix}-${++store.seq}`; + + const findMember = (organizationId: string, userId: string) => + store.members.find( + (m) => m.organizationId === organizationId && m.userId === userId, + ); + + const secretOf = (secretId: string) => { + const s = store.secrets.find((x) => x.id === secretId); + return s ? { name: s.name, type: s.type } : { name: "", type: "" }; + }; + + const withSecret = (b: BudgetRow) => ({ ...b, secret: secretOf(b.secretId) }); + + const db = { + apiKey: { + findUnique: async ({ where }: { where: { key?: string } }) => { + if (where.key === ADMIN_KEY) + return { + userId: ADMIN, + organizationId: ORG, + scope: "organization", + }; + if (where.key === OTHER_ADMIN_KEY) + return { + userId: OUTSIDER, + organizationId: OTHER_ORG, + scope: "organization", + }; + // A PROJECT-scoped key owned by the org's OWNER — authenticates fine, + // which is exactly why the router needs its own scope guard. + if (where.key === PROJECT_KEY) + return { userId: OWNER, projectId: "proj-1" }; + return null; + }, + findFirst: async () => null, + findMany: async () => [], + }, + user: { + findUnique: async ({ + where, + select, + }: { + where: { id?: string; externalAuthId?: 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), + ) ?? null + ); + }, + }, + organizationMember: { + findUnique: async ({ + where, + }: { + where: { + organizationId_userId: { organizationId: string; userId: string }; + }; + }) => { + const { organizationId, userId } = where.organizationId_userId; + return findMember(organizationId, userId) ?? null; + }, + findFirst: async () => null, + findMany: async () => [], + }, + project: { + findFirst: async () => ({ id: "proj-1", organizationId: ORG }), + findUnique: async () => ({ id: "proj-1", organizationId: ORG }), + }, + projectAccess: { findFirst: async () => null }, + secret: { + findUnique: async ({ where }: { where: { id: string } }) => + store.secrets.find((s) => s.id === where.id) ?? null, + }, + budget: { + findMany: async ({ where }: { where: { organizationId: string } }) => + store.budgets + .filter((b) => b.organizationId === where.organizationId) + .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) + .map(withSecret), + findFirst: async ({ + where, + }: { + where: { id: string; organizationId: string }; + }) => { + const b = store.budgets.find( + (row) => + row.id === where.id && row.organizationId === where.organizationId, + ); + return b ? withSecret(b) : null; + }, + create: async ({ + data, + }: { + data: Omit; + }) => { + if ( + store.budgets.some( + (b) => + b.secretId === data.secretId && + b.organizationId === data.organizationId, + ) + ) { + throw new PrismaClientKnownRequestError("unique", "P2002"); + } + const now = new Date(); + const row: BudgetRow = { + ...data, + id: nextId("budget"), + createdAt: now, + updatedAt: now, + }; + store.budgets.push(row); + return row; + }, + update: async ({ + where, + data, + }: { + where: { id: string }; + data: Partial; + }) => { + const b = store.budgets.find((row) => row.id === where.id); + if (!b) throw new PrismaClientKnownRequestError("missing", "P2025"); + Object.assign(b, data, { updatedAt: new Date() }); + return withSecret(b); + }, + delete: async ({ where }: { where: { id: string } }) => { + const before = store.budgets.length; + store.budgets = store.budgets.filter((row) => row.id !== where.id); + if (store.budgets.length === before) + throw new PrismaClientKnownRequestError("missing", "P2025"); + return {}; + }, + }, + budgetSpend: { + findMany: async ({ + where, + }: { + where: { + organizationId: string; + secretId: { in: string[] }; + period: { in: string[] }; + }; + }) => + store.spends.filter( + (s) => + s.organizationId === where.organizationId && + where.secretId.in.includes(s.secretId) && + where.period.in.includes(s.period), + ), + findFirst: async ({ + where, + }: { + where: { + organizationId: string; + secretId: string; + period: string; + }; + }) => + store.spends.find( + (s) => + s.organizationId === where.organizationId && + s.secretId === where.secretId && + s.period === where.period, + ) ?? null, + }, + auditLog: { + create: async ({ data }: { data: AuditRow }) => { + store.audits.push(data); + return data; + }, + }, + }; + + return { + Prisma: { JsonNull: null, PrismaClientKnownRequestError }, + db, + }; +}); + +import { createApiApp } from "../../app"; +import { registerOssOrgRoutes } from "./index"; +import { ossRoleResolver } from "../../services/org-role-resolver"; + +const sessionProvider = { getSession: async () => null }; + +const app: Hono = createApiApp(sessionProvider, { + eeRoutes: registerOssOrgRoutes, + roleResolver: ossRoleResolver, +}); + +const at = (m: number) => new Date(Date.UTC(2026, 0, 1, 0, m)); + +const member = ( + userId: string, + role: string, + organizationId: string, +): MemberRow => ({ + organizationId, + userId, + userEmail: `${userId}@example.com`, + role, + status: "active", + ssoExempt: false, + suspendedAt: null, + createdAt: at(0), +}); + +/** Current UTC monthly key — must match the service + gateway. */ +const monthlyKey = () => { + const now = new Date(); + return `m:${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`; +}; + +beforeEach(() => { + store.users = [ + { + id: ADMIN, + externalAuthId: "ext-admin", + email: "admin@x.test", + name: null, + }, + { + id: OUTSIDER, + externalAuthId: "ext-outsider", + email: "out@x.test", + name: null, + }, + { + id: OWNER, + externalAuthId: "ext-owner", + email: "owner@x.test", + name: null, + }, + ]; + store.members = [ + member(ADMIN, "admin", ORG), + member(OUTSIDER, "admin", OTHER_ORG), + member(OWNER, "owner", ORG), + ]; + store.secrets = [ + { + id: "sec-anthropic", + name: "Claude", + type: "anthropic", + organizationId: ORG, + }, + { id: "sec-openai", name: "GPT", type: "openai", organizationId: ORG }, + { + id: "sec-generic", + name: "Datadog", + type: "generic", + organizationId: ORG, + }, + { + id: "sec-foreign", + name: "Foreign", + type: "anthropic", + organizationId: OTHER_ORG, + }, + ]; + store.budgets = [ + { + id: "b-1", + secretId: "sec-anthropic", + organizationId: ORG, + limitCents: 5000, + period: "monthly", + createdBy: ADMIN, + createdAt: at(1), + updatedAt: at(1), + }, + { + id: "b-foreign", + secretId: "sec-foreign", + organizationId: OTHER_ORG, + limitCents: 1000, + period: "monthly", + createdBy: OUTSIDER, + createdAt: at(2), + updatedAt: at(2), + }, + ]; + store.spends = [ + // $12.34 spent this month on sec-anthropic → 1234 cents. + { + secretId: "sec-anthropic", + organizationId: ORG, + period: monthlyKey(), + spentNanos: 12_340_000_000n, + }, + ]; + store.audits = []; + store.seq = 100; +}); + +const asAdmin = { headers: { Authorization: `Bearer ${ADMIN_KEY}` } }; +const asOtherAdmin = { + headers: { Authorization: `Bearer ${OTHER_ADMIN_KEY}` }, +}; +const asProjectKey = { headers: { Authorization: `Bearer ${PROJECT_KEY}` } }; + +interface BudgetBody { + id: string; + secretId: string; + secretType: string; + limitCents: number; + period: string; + spentCents: number; +} + +const list = async (init: RequestInit = asAdmin): Promise => { + const res = await app.request("/v1/org/budgets", init); + expect(res.status).toBe(200); + return (await res.json()) as BudgetBody[]; +}; + +const create = (body: unknown, init: RequestInit = asAdmin) => + app.request("/v1/org/budgets", { + ...init, + method: "POST", + body: JSON.stringify(body), + }); + +const patch = (id: string, body: unknown, init: RequestInit = asAdmin) => + app.request(`/v1/org/budgets/${id}`, { + ...init, + method: "PATCH", + body: JSON.stringify(body), + }); + +const remove = (id: string, init: RequestInit = asAdmin) => + app.request(`/v1/org/budgets/${id}`, { ...init, method: "DELETE" }); + +describe("list + scope fencing", () => { + it("lists the org's budgets joined with current-period spend", async () => { + const body = await list(); + expect(body.map((b) => b.id)).toEqual(["b-1"]); + expect(body[0]).toMatchObject({ + secretId: "sec-anthropic", + secretType: "anthropic", + limitCents: 5000, + period: "monthly", + spentCents: 1234, + }); + }); + + it("never returns another org's budgets", async () => { + const body = await list(); + expect(body.map((b) => b.id)).not.toContain("b-foreign"); + // The other org's admin sees only its own. + const foreign = await list(asOtherAdmin); + expect(foreign.map((b) => b.id)).toEqual(["b-foreign"]); + }); +}); + +describe("create", () => { + it("creates a budget on a metered LLM secret and audits it", async () => { + const res = await create({ secretId: "sec-openai", limitCents: 2500 }); + expect(res.status).toBe(201); + const created = (await res.json()) as BudgetBody; + expect(created).toMatchObject({ + secretId: "sec-openai", + secretType: "openai", + limitCents: 2500, + period: "monthly", + spentCents: 0, + }); + const row = store.budgets.find((b) => b.id === created.id); + expect(row).toMatchObject({ organizationId: ORG, secretId: "sec-openai" }); + expect(store.audits.at(-1)).toMatchObject({ + organizationId: ORG, + service: "budget", + action: "create", + source: "api", + }); + }); + + it("rejects a non-metered (non-LLM) secret with 400", async () => { + const res = await create({ secretId: "sec-generic", limitCents: 2500 }); + expect(res.status).toBe(400); + expect(store.budgets.some((b) => b.secretId === "sec-generic")).toBe(false); + }); + + it("rejects a foreign-org secret with 404", async () => { + const res = await create({ secretId: "sec-foreign", limitCents: 2500 }); + expect(res.status).toBe(404); + }); + + it("rejects a duplicate (secret, org) budget with 409", async () => { + const res = await create({ secretId: "sec-anthropic", limitCents: 9999 }); + expect(res.status).toBe(409); + }); + + it("rejects a non-positive cap with 422", async () => { + const res = await create({ secretId: "sec-openai", limitCents: 0 }); + expect(res.status).toBe(422); + }); +}); + +describe("update + delete", () => { + it("updates the cap and period (org-fenced)", async () => { + const res = await patch("b-1", { limitCents: 8000, period: "total" }); + expect(res.status).toBe(200); + const body = (await res.json()) as BudgetBody; + expect(body).toMatchObject({ limitCents: 8000, period: "total" }); + expect(store.audits.at(-1)).toMatchObject({ + service: "budget", + action: "update", + }); + }); + + it("404s updating another org's budget", async () => { + const res = await patch("b-foreign", { limitCents: 8000 }); + expect(res.status).toBe(404); + }); + + it("deletes an org budget", async () => { + const res = await remove("b-1"); + expect(res.status).toBe(204); + expect(store.budgets.some((b) => b.id === "b-1")).toBe(false); + expect(store.audits.at(-1)).toMatchObject({ + service: "budget", + action: "delete", + }); + }); + + it("404s deleting another org's budget", async () => { + const res = await remove("b-foreign"); + expect(res.status).toBe(404); + expect(store.budgets.some((b) => b.id === "b-foreign")).toBe(true); + }); +}); + +describe("credential scope guard", () => { + it("403s a project-scoped credential on every method", async () => { + expect((await app.request("/v1/org/budgets", asProjectKey)).status).toBe( + 403, + ); + expect( + (await create({ secretId: "sec-openai", limitCents: 1 }, asProjectKey)) + .status, + ).toBe(403); + expect((await patch("b-1", { limitCents: 1 }, asProjectKey)).status).toBe( + 403, + ); + expect((await remove("b-1", asProjectKey)).status).toBe(403); + }); +}); diff --git a/packages/api/src/routes/org/budgets.ts b/packages/api/src/routes/org/budgets.ts new file mode 100644 index 00000000..957494d8 --- /dev/null +++ b/packages/api/src/routes/org/budgets.ts @@ -0,0 +1,146 @@ +import { Hono } from "hono"; +import { z } from "zod"; +import type { ApiEnv } from "../../types"; +import type { AuthContext } from "../../providers"; +import { auth } from "../../middleware/auth"; +import { ServiceError } from "../../services/errors"; +import { + listBudgets, + createBudget, + updateBudget, + deleteBudget, +} from "../../services/budget-service"; +import { + withAudit, + AUDIT_ACTIONS, + AUDIT_SERVICES, + AUDIT_SOURCE, +} from "../../services/audit-service"; + +/** + * `/v1/org/budgets` — per-(secret, org) spend caps. + * + * Same guard stack as `/v1/org/policy` (see `policy.ts`): admin-only, and an + * org-scope-credential guard on top. Budgets are ORG guardrails — a project- + * scoped agent key (even one whose user is an org admin) must not edit the cap + * its own traffic is metered against. Org authority requires an org credential. + * + * `withAudit` keys `invalidateGatewayCacheForOrg` off `organizationId`, so a + * new/changed cap takes effect within the connect cache TTL. + */ + +const periodSchema = z.enum(["monthly", "total"]); + +const createBudgetSchema = z.object({ + secretId: z.string().min(1), + limitCents: z.number().int().positive(), + period: periodSchema.default("monthly"), +}); + +const updateBudgetSchema = z + .object({ + limitCents: z.number().int().positive().optional(), + period: periodSchema.optional(), + }) + .refine((v) => v.limitCents !== undefined || v.period !== undefined, { + message: "Provide limitCents and/or period.", + }); + +const parse = ( + schema: S, + body: unknown, +): z.infer => { + const result = schema.safeParse(body); + if (!result.success) { + throw new ServiceError( + "UNPROCESSABLE", + result.error.issues[0]?.message ?? "Invalid request body", + ); + } + return result.data; +}; + +const jsonBody = (c: { req: { json: () => Promise } }) => + c.req.json().catch(() => null); + +export const ossOrgBudgetRoutes = () => { + 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 budgets require an organization-scoped credential.", + ); + } + return next(); + }); + + const auditBase = (a: AuthContext) => ({ + organizationId: a.organizationId, + userId: a.userId, + userEmail: a.userEmail, + service: AUDIT_SERVICES.BUDGET, + source: AUDIT_SOURCE.API, + }); + + app.get("/", async (c) => { + const { organizationId } = c.get("auth"); + return c.json(await listBudgets(organizationId)); + }); + + app.post("/", async (c) => { + const auth = c.get("auth"); + const input = parse(createBudgetSchema, await jsonBody(c)); + const budget = await withAudit( + () => createBudget(auth.organizationId, input, auth.userId), + (b) => ({ + ...auditBase(auth), + action: AUDIT_ACTIONS.CREATE, + metadata: { + budgetId: b.id, + secretId: b.secretId, + limitCents: b.limitCents, + period: b.period, + }, + }), + ); + return c.json(budget, 201); + }); + + app.patch("/:id", async (c) => { + const auth = c.get("auth"); + const id = c.req.param("id"); + const input = parse(updateBudgetSchema, await jsonBody(c)); + const budget = await withAudit( + () => updateBudget(auth.organizationId, id, input), + (b) => ({ + ...auditBase(auth), + action: AUDIT_ACTIONS.UPDATE, + metadata: { + budgetId: id, + secretId: b.secretId, + limitCents: b.limitCents, + period: b.period, + }, + }), + ); + return c.json(budget); + }); + + app.delete("/:id", async (c) => { + const auth = c.get("auth"); + const id = c.req.param("id"); + await withAudit( + () => deleteBudget(auth.organizationId, id), + (r) => ({ + ...auditBase(auth), + action: AUDIT_ACTIONS.DELETE, + metadata: { budgetId: id, secretId: r.secretId }, + }), + ); + return c.body(null, 204); + }); + + return app; +}; diff --git a/packages/api/src/routes/org/index.ts b/packages/api/src/routes/org/index.ts index 49ae7484..46a815a7 100644 --- a/packages/api/src/routes/org/index.ts +++ b/packages/api/src/routes/org/index.ts @@ -5,6 +5,7 @@ import { orgInvitationRoutes } from "./invitations"; import { orgGroupRoutes } from "./groups"; import { orgRoleMappingRoutes } from "./role-mappings"; import { ossOrgPolicyRoutes } from "./policy"; +import { ossOrgBudgetRoutes } from "./budgets"; import { ossProjectRoutes } from "./projects"; /** @@ -35,5 +36,6 @@ export const registerOssOrgRoutes = (app: Hono) => { app.route("/org/groups", orgGroupRoutes()); app.route("/org/role-mappings", orgRoleMappingRoutes()); app.route("/org/policy", ossOrgPolicyRoutes()); + app.route("/org/budgets", ossOrgBudgetRoutes()); app.route("/projects", ossProjectRoutes()); }; diff --git a/packages/api/src/services/budget-service.ts b/packages/api/src/services/budget-service.ts new file mode 100644 index 00000000..6f06bdbf --- /dev/null +++ b/packages/api/src/services/budget-service.ts @@ -0,0 +1,234 @@ +import { db, Prisma } from "@onecli/db"; +import { ServiceError } from "./errors"; + +// Per-(secret, org) spend caps. Every call is fenced to ONE organization — the +// caller's `auth.organizationId`, never a body parameter — so this can neither +// read nor write another org's budgets. A budget is a cost guardrail the gateway +// enforces (see `apps/gateway/src/budget.rs`); this service is the CRUD surface. +// +// 4a scope: a budget binds to an ORG-scoped LLM secret the org owns +// (`type ∈ {anthropic, openai}`) — the metered providers the gateway can price. +// Project-scoped binding is DESCOPED (the `projectId` column is reserved). + +/** LLM secret types the gateway meters (must match `budget.rs::is_metered_type`). */ +const METERED_TYPES = ["anthropic", "openai"] as const; + +/** 1 cent = 1e7 nano-dollars (must match `budget.rs::CENT_TO_NANOS`). */ +const CENT_TO_NANOS = 10_000_000n; + +export interface BudgetListRow { + id: string; + secretId: string; + secretName: string; + secretType: string; + limitCents: number; + period: string; + /** Accumulated spend this period, floored to whole cents (from `BudgetSpend`). */ + spentCents: number; + createdAt: string; + updatedAt: string; +} + +export interface CreateBudgetInput { + secretId: string; + limitCents: number; + period: "monthly" | "total"; +} + +export interface UpdateBudgetInput { + limitCents?: number; + period?: "monthly" | "total"; +} + +/** Current monthly window key (UTC) — must match `budget.rs::period_key`. */ +const monthlyPeriodKey = (now = new Date()): string => { + const year = now.getUTCFullYear(); + const month = String(now.getUTCMonth() + 1).padStart(2, "0"); + return `m:${year}-${month}`; +}; + +const nanosToCents = (nanos: bigint): number => Number(nanos / CENT_TO_NANOS); + +/** + * Current-period accumulated spend for one budget, in whole cents. Reads the + * `BudgetSpend` window key the period selects (monthly → `m:YYYY-MM`, total → + * `total`), matching `listBudgets`. Returns 0 when nothing has been metered yet. + */ +const currentSpentCents = async ( + organizationId: string, + secretId: string, + period: string, +): Promise => { + const key = period === "total" ? "total" : monthlyPeriodKey(); + const spend = await db.budgetSpend.findFirst({ + where: { organizationId, secretId, period: key }, + select: { spentNanos: true }, + }); + return spend ? nanosToCents(spend.spentNanos) : 0; +}; + +/** + * List the org's budgets, each joined with its current-period accumulated spend + * (`BudgetSpend.spentNanos`) for the usage display. A budget's period selects + * which spend window key it reads: monthly → `m:YYYY-MM`, total → `total`. + */ +export const listBudgets = async ( + organizationId: string, +): Promise => { + const budgets = await db.budget.findMany({ + where: { organizationId }, + include: { secret: { select: { name: true, type: true } } }, + orderBy: { createdAt: "asc" }, + }); + if (budgets.length === 0) return []; + + const monthly = monthlyPeriodKey(); + const spends = await db.budgetSpend.findMany({ + where: { + organizationId, + secretId: { in: budgets.map((b) => b.secretId) }, + period: { in: [monthly, "total"] }, + }, + }); + const spentBy = new Map(); + for (const s of spends) { + spentBy.set(`${s.secretId}:${s.period}`, s.spentNanos); + } + + return budgets.map((b) => { + const key = `${b.secretId}:${b.period === "total" ? "total" : monthly}`; + const spentNanos = spentBy.get(key) ?? 0n; + return { + id: b.id, + secretId: b.secretId, + secretName: b.secret.name, + secretType: b.secret.type, + limitCents: b.limitCents, + period: b.period, + spentCents: nanosToCents(spentNanos), + createdAt: b.createdAt.toISOString(), + updatedAt: b.updatedAt.toISOString(), + }; + }); +}; + +/** + * Create a budget on an org-owned metered LLM secret. Rejects a secret that is + * not the org's (404) or not a meterable type (400 — a budget on an unmeterable + * secret is a silent no-op). A duplicate `(secretId, org)` is a 409. + */ +export const createBudget = async ( + organizationId: string, + input: CreateBudgetInput, + createdBy: string, +): Promise => { + const secret = await db.secret.findUnique({ + where: { id: input.secretId }, + select: { id: true, name: true, type: true, organizationId: true }, + }); + if (!secret || secret.organizationId !== organizationId) { + throw new ServiceError( + "NOT_FOUND", + "Secret not found in this organization.", + ); + } + if (!(METERED_TYPES as readonly string[]).includes(secret.type)) { + throw new ServiceError( + "BAD_REQUEST", + "Budgets are only supported on Anthropic or OpenAI secrets.", + ); + } + + try { + const created = await db.budget.create({ + data: { + secretId: input.secretId, + organizationId, + limitCents: input.limitCents, + period: input.period, + createdBy, + }, + }); + return { + id: created.id, + secretId: created.secretId, + secretName: secret.name, + secretType: secret.type, + limitCents: created.limitCents, + period: created.period, + spentCents: 0, + createdAt: created.createdAt.toISOString(), + updatedAt: created.updatedAt.toISOString(), + }; + } catch (err) { + if ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === "P2002" + ) { + throw new ServiceError( + "CONFLICT", + "A budget already exists for this secret.", + ); + } + throw err; + } +}; + +/** Load a budget fenced to the org, or throw 404. */ +const requireOrgBudget = async (organizationId: string, id: string) => { + const budget = await db.budget.findFirst({ + where: { id, organizationId }, + include: { secret: { select: { name: true, type: true } } }, + }); + if (!budget) { + throw new ServiceError("NOT_FOUND", "Budget not found."); + } + return budget; +}; + +/** Update a budget's cap and/or period (org-fenced). */ +export const updateBudget = async ( + organizationId: string, + id: string, + input: UpdateBudgetInput, +): Promise => { + await requireOrgBudget(organizationId, id); + const updated = await db.budget.update({ + where: { id }, + data: { + ...(input.limitCents !== undefined + ? { limitCents: input.limitCents } + : {}), + ...(input.period !== undefined ? { period: input.period } : {}), + }, + include: { secret: { select: { name: true, type: true } } }, + }); + // Report real current-period spend (a budget may already have accumulated it) + // rather than a placeholder 0, so the PATCH response matches `listBudgets`. + const spentCents = await currentSpentCents( + organizationId, + updated.secretId, + updated.period, + ); + return { + id: updated.id, + secretId: updated.secretId, + secretName: updated.secret.name, + secretType: updated.secret.type, + limitCents: updated.limitCents, + period: updated.period, + spentCents, + createdAt: updated.createdAt.toISOString(), + updatedAt: updated.updatedAt.toISOString(), + }; +}; + +/** Delete a budget (org-fenced). Returns the removed budget's id + secret. */ +export const deleteBudget = async ( + organizationId: string, + id: string, +): Promise<{ id: string; secretId: string }> => { + const budget = await requireOrgBudget(organizationId, id); + await db.budget.delete({ where: { id } }); + return { id: budget.id, secretId: budget.secretId }; +}; From 36aebad39ee2b8c69c93fa7500b9d5d8b3c6be72 Mon Sep 17 00:00:00 2001 From: marcorivm Date: Wed, 5 Aug 2026 12:56:17 -0600 Subject: [PATCH 09/10] Add worktrees to gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index e4e94d3e..3f4f679f 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,6 @@ apps/gateway/target/ # DD skills (installed per-user via pup) .claude/skills/dd-* + +.claude/worktrees + From 8b24d2dcf73c406e96219e91b11f0edd4dbee8ac Mon Sep 17 00:00:00 2001 From: marcorivm Date: Wed, 5 Aug 2026 17:27:58 -0600 Subject: [PATCH 10/10] feat(api,web): webhook receiver and delivery pull queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit External providers can now trigger agents through OneCLI. A provider POSTs to a public per-endpoint URL; OneCLI verifies the signature, stores the delivery, renders a text template, and queues it. The agent runtime long-polls to drain the queue and acks each delivery. The consumer pulls rather than being pushed to, so the machine running the agent needs no public port, no tunnel, and no inbound firewall rule. It also makes retries free: an unacked delivery becomes claimable again when its lease lapses, so a consumer that dies mid-dispatch loses nothing. This supersedes an earlier design that put the whole surface — verification, templating, dedup, retry, replay, and a second admin UI — in a companion app beside the agent, which needed a public port on the agent's VM and only ever served one runtime. Ingest (`POST /v1/hooks/:publicId`, the one public route): - Verifier registry: GitHub HMAC-SHA256, shared token, or none. Verification runs over the raw bytes, before any parse — a re-serialized body changes the digest. Selecting "none" needs an explicit acknowledgement. - Dedup by catching P2002 rather than pre-reading, which is what makes it correct against a provider retrying concurrently. - A muted endpoint answers 200: GitHub disables a webhook that keeps failing, so a local mute must stay invisible to the sender. - A failed signature is recorded without its payload, so an unverified caller cannot write arbitrary content into the log. Queue (`GET /v1/hooks/pending`, `POST /v1/hooks/ack`): - The claim is a guarded updateMany that repeats the availability predicate, read back by a per-claim uuid. Under READ COMMITTED the loser of a race re-evaluates its WHERE against the committed row and matches nothing, so no transaction or SKIP LOCKED is needed. Proven in the pg suite with four concurrent pollers over twenty deliveries. - Leases instead of a reaper: an abandoned claim simply expires. - The wait sleeps between queries, never inside one, so it holds no claim, no transaction, and no pool connection. - `retryable: false` on a nack terminates immediately — a routing blob the consumer cannot interpret will fail identically forever. - Authenticated by the agent access token, which is also the queue selector; a project key works but must name an agent explicitly. The endpoint carries an opaque `routing` JSON that OneCLI never parses and passes through verbatim, so a runtime keeps its own dispatch semantics without OneCLI needing to know them. Mutations audit through `recordAuditEvent`, not `withAudit`: the latter also flushes the gateway's CONNECT cache, and renaming a webhook should not make every agent in the project re-resolve its credentials. `routes/hooks.ts` deliberately has no `use("*")` — Hono flattens a sub-app's routes into the parent, so a sibling's wildcard middleware would 401 the public ingest POST. A test enumerates every route and asserts 401 without credentials. Claude-Session: https://claude.ai/code/session_012yM2sscojX6zzDUaVJXo6m --- .../[hookId]/_components/deliveries-table.tsx | 109 +++++ .../_components/delivery-detail-dialog.tsx | 171 ++++++++ .../_components/delivery-status-badge.tsx | 73 ++++ .../_components/webhook-deliveries.tsx | 143 ++++++ .../_components/webhook-detail-content.tsx | 77 ++++ .../_components/webhook-summary-card.tsx | 73 ++++ .../(dashboard)/webhooks/[hookId]/loading.tsx | 27 ++ .../(dashboard)/webhooks/[hookId]/page.tsx | 31 ++ .../_components/create-webhook-dialog.tsx | 149 +++++++ .../_components/edit-webhook-dialog.tsx | 131 ++++++ .../webhooks/_components/ingest-url-panel.tsx | 135 ++++++ .../webhooks/_components/test-command.tsx | 89 ++++ .../_components/verification-badge.tsx | 38 ++ .../_components/webhook-form-fields.tsx | 259 +++++++++++ .../_components/webhook-row-actions.tsx | 164 +++++++ .../webhooks/_components/webhooks-content.tsx | 45 ++ .../webhooks/_components/webhooks-table.tsx | 171 ++++++++ .../src/app/(dashboard)/webhooks/loading.tsx | 26 ++ .../web/src/app/(dashboard)/webhooks/page.tsx | 34 ++ apps/web/src/app/v1/hooks/pending/route.ts | 23 + apps/web/src/hooks/use-public-base-url.ts | 26 ++ apps/web/src/hooks/use-webhooks.ts | 103 +++++ apps/web/src/lib/api/index.ts | 12 + apps/web/src/lib/api/keys.ts | 10 + apps/web/src/lib/api/types.ts | 92 ++++ apps/web/src/lib/api/webhooks.ts | 66 +++ apps/web/src/lib/nav-config.ts | 6 + docs/nanoclaw-integration.md | 49 ++- docs/webhooks.md | 270 ++++++++++++ packages/api/src/app.ts | 4 + packages/api/src/lib/rate-limit.test.ts | 64 +++ packages/api/src/lib/rate-limit.ts | 76 ++++ packages/api/src/middleware/agent-auth.ts | 119 +++++ packages/api/src/routes/hooks-admin.test.ts | 407 ++++++++++++++++++ packages/api/src/routes/hooks-ingest.test.ts | 322 ++++++++++++++ packages/api/src/routes/hooks-queue.test.ts | 371 ++++++++++++++++ packages/api/src/routes/hooks.ts | 376 ++++++++++++++++ packages/api/src/routes/internal.ts | 9 + packages/api/src/services/audit-service.ts | 4 + .../src/services/webhook-delivery-service.ts | 265 ++++++++++++ .../src/services/webhook-endpoint-service.ts | 293 +++++++++++++ .../src/services/webhook-ingest-service.ts | 305 +++++++++++++ .../services/webhook-queue-service.pg.test.ts | 284 ++++++++++++ .../api/src/services/webhook-queue-service.ts | 370 ++++++++++++++++ .../webhook-retention-service.test.ts | 134 ++++++ .../src/services/webhook-retention-service.ts | 130 ++++++ .../api/src/services/webhook/constants.ts | 107 +++++ packages/api/src/services/webhook/generate.ts | 18 + packages/api/src/services/webhook/notify.ts | 51 +++ .../api/src/services/webhook/render.test.ts | 154 +++++++ packages/api/src/services/webhook/render.ts | 154 +++++++ .../services/webhook/verifiers/github.test.ts | 144 +++++++ .../src/services/webhook/verifiers/github.ts | 51 +++ .../src/services/webhook/verifiers/index.ts | 37 ++ .../src/services/webhook/verifiers/none.ts | 31 ++ .../services/webhook/verifiers/token.test.ts | 91 ++++ .../src/services/webhook/verifiers/token.ts | 49 +++ .../src/services/webhook/verifiers/types.ts | 85 ++++ packages/api/src/types.ts | 3 + packages/api/src/validations/webhook.ts | 176 ++++++++ .../migration.sql | 89 ++++ packages/db/prisma/schema.prisma | 149 +++++++ 62 files changed, 7519 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/app/(dashboard)/webhooks/[hookId]/_components/deliveries-table.tsx create mode 100644 apps/web/src/app/(dashboard)/webhooks/[hookId]/_components/delivery-detail-dialog.tsx create mode 100644 apps/web/src/app/(dashboard)/webhooks/[hookId]/_components/delivery-status-badge.tsx create mode 100644 apps/web/src/app/(dashboard)/webhooks/[hookId]/_components/webhook-deliveries.tsx create mode 100644 apps/web/src/app/(dashboard)/webhooks/[hookId]/_components/webhook-detail-content.tsx create mode 100644 apps/web/src/app/(dashboard)/webhooks/[hookId]/_components/webhook-summary-card.tsx create mode 100644 apps/web/src/app/(dashboard)/webhooks/[hookId]/loading.tsx create mode 100644 apps/web/src/app/(dashboard)/webhooks/[hookId]/page.tsx create mode 100644 apps/web/src/app/(dashboard)/webhooks/_components/create-webhook-dialog.tsx create mode 100644 apps/web/src/app/(dashboard)/webhooks/_components/edit-webhook-dialog.tsx create mode 100644 apps/web/src/app/(dashboard)/webhooks/_components/ingest-url-panel.tsx create mode 100644 apps/web/src/app/(dashboard)/webhooks/_components/test-command.tsx create mode 100644 apps/web/src/app/(dashboard)/webhooks/_components/verification-badge.tsx create mode 100644 apps/web/src/app/(dashboard)/webhooks/_components/webhook-form-fields.tsx create mode 100644 apps/web/src/app/(dashboard)/webhooks/_components/webhook-row-actions.tsx create mode 100644 apps/web/src/app/(dashboard)/webhooks/_components/webhooks-content.tsx create mode 100644 apps/web/src/app/(dashboard)/webhooks/_components/webhooks-table.tsx create mode 100644 apps/web/src/app/(dashboard)/webhooks/loading.tsx create mode 100644 apps/web/src/app/(dashboard)/webhooks/page.tsx create mode 100644 apps/web/src/app/v1/hooks/pending/route.ts create mode 100644 apps/web/src/hooks/use-public-base-url.ts create mode 100644 apps/web/src/hooks/use-webhooks.ts create mode 100644 apps/web/src/lib/api/webhooks.ts create mode 100644 docs/webhooks.md create mode 100644 packages/api/src/lib/rate-limit.test.ts create mode 100644 packages/api/src/lib/rate-limit.ts create mode 100644 packages/api/src/middleware/agent-auth.ts create mode 100644 packages/api/src/routes/hooks-admin.test.ts create mode 100644 packages/api/src/routes/hooks-ingest.test.ts create mode 100644 packages/api/src/routes/hooks-queue.test.ts create mode 100644 packages/api/src/routes/hooks.ts create mode 100644 packages/api/src/services/webhook-delivery-service.ts create mode 100644 packages/api/src/services/webhook-endpoint-service.ts create mode 100644 packages/api/src/services/webhook-ingest-service.ts create mode 100644 packages/api/src/services/webhook-queue-service.pg.test.ts create mode 100644 packages/api/src/services/webhook-queue-service.ts create mode 100644 packages/api/src/services/webhook-retention-service.test.ts create mode 100644 packages/api/src/services/webhook-retention-service.ts create mode 100644 packages/api/src/services/webhook/constants.ts create mode 100644 packages/api/src/services/webhook/generate.ts create mode 100644 packages/api/src/services/webhook/notify.ts create mode 100644 packages/api/src/services/webhook/render.test.ts create mode 100644 packages/api/src/services/webhook/render.ts create mode 100644 packages/api/src/services/webhook/verifiers/github.test.ts create mode 100644 packages/api/src/services/webhook/verifiers/github.ts create mode 100644 packages/api/src/services/webhook/verifiers/index.ts create mode 100644 packages/api/src/services/webhook/verifiers/none.ts create mode 100644 packages/api/src/services/webhook/verifiers/token.test.ts create mode 100644 packages/api/src/services/webhook/verifiers/token.ts create mode 100644 packages/api/src/services/webhook/verifiers/types.ts create mode 100644 packages/api/src/validations/webhook.ts create mode 100644 packages/db/prisma/migrations/20260805143000_add_webhook_endpoints_and_deliveries/migration.sql diff --git a/apps/web/src/app/(dashboard)/webhooks/[hookId]/_components/deliveries-table.tsx b/apps/web/src/app/(dashboard)/webhooks/[hookId]/_components/deliveries-table.tsx new file mode 100644 index 00000000..1f5495b7 --- /dev/null +++ b/apps/web/src/app/(dashboard)/webhooks/[hookId]/_components/deliveries-table.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { Card } from "@onecli/ui/components/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@onecli/ui/components/table"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@onecli/ui/components/tooltip"; +import { formatRelative, formatUTC } from "@onecli/api/lib/format"; +import type { WebhookDelivery } from "@/lib/api"; +import { DeliveryStatusBadge } from "./delivery-status-badge"; + +export interface DeliveriesTableProps { + deliveries: WebhookDelivery[]; + onRowClick: (delivery: WebhookDelivery) => void; + emptyMessage: string; +} + +const DateCell = ({ value }: { value: string }) => ( + + + + {formatRelative(value)} + + + +

{formatUTC(value)}

+

+ {new Date(value).toLocaleString()} +

+
+
+); + +export const DeliveriesTable = ({ + deliveries, + onRowClick, + emptyMessage, +}: DeliveriesTableProps) => { + if (deliveries.length === 0) { + return ( + +

{emptyMessage}

+
+ ); + } + + return ( + + + + + Received + Status + Event + Attempts + Error + + + + {deliveries.map((delivery) => ( + onRowClick(delivery)} + > + + + + + + + + {delivery.event ?? ( + + )} + {delivery.replayOfId && ( + + (replay) + + )} + {delivery.duplicateCount > 0 && ( + + +{delivery.duplicateCount} duplicate + {delivery.duplicateCount === 1 ? "" : "s"} + + )} + + + {delivery.attempts} + + + {delivery.lastError} + + + ))} + +
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/webhooks/[hookId]/_components/delivery-detail-dialog.tsx b/apps/web/src/app/(dashboard)/webhooks/[hookId]/_components/delivery-detail-dialog.tsx new file mode 100644 index 00000000..5b702469 --- /dev/null +++ b/apps/web/src/app/(dashboard)/webhooks/[hookId]/_components/delivery-detail-dialog.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { Button } from "@onecli/ui/components/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@onecli/ui/components/dialog"; +import { Skeleton } from "@onecli/ui/components/skeleton"; +import { formatUTC } from "@onecli/api/lib/format"; +import { useDeliveryDetail, useReplayDelivery } from "@/hooks/use-webhooks"; +import type { WebhookDelivery } from "@/lib/api"; +import { DeliveryStatusBadge } from "./delivery-status-badge"; + +export interface DeliveryDetailDialogProps { + delivery: WebhookDelivery | null; + hookId: string; + onClose: () => void; +} + +const Row = ({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) => ( +
+ {label} +
{children}
+
+); + +const Block = ({ label, children }: { label: string; children: string }) => ( +
+ {label} +
+      {children}
+    
+
+); + +export const DeliveryDetailDialog = ({ + delivery, + hookId, + onClose, +}: DeliveryDetailDialogProps) => { + // The list row carries no payload — fetch the full record on open. + const detail = useDeliveryDetail(delivery?.id ?? null); + const replay = useReplayDelivery(hookId); + + // A rejected delivery was never stored with its payload, so there is nothing + // to re-render. + const replayable = delivery?.discardReason !== "rejected"; + + return ( + onClose()}> + + + Delivery + + + {detail.isPending ? ( +
+ {[1, 2, 3, 4].map((i) => ( + + ))} +
+ ) : detail.isError ? ( +

+ {detail.error.message} +

+ ) : ( + detail.data && ( +
+ + + + + + {formatUTC(detail.data.receivedAt)} + + + {detail.data.event ?? "—"} + + {detail.data.id} + + {detail.data.dedupeKey && ( + + + {detail.data.dedupeKey} + + + )} + + {detail.data.attempts} + + {detail.data.deliveredAt && ( + + + {formatUTC(detail.data.deliveredAt)} + + + )} + {detail.data.claimedBy && ( + + + {detail.data.claimedBy} + + + )} + {detail.data.replayOfId && ( + + + {detail.data.replayOfId} + + + )} + {/* The reason a consumer rejected this — the whole point of the + nack contract, readable without SSH access to the runtime. */} + {detail.data.lastError && ( + + + {detail.data.lastError} + + + )} + {detail.data.renderWarnings.length > 0 && ( + + + {detail.data.renderWarnings.join(", ")} + + + )} + + {detail.data.renderedText && ( + {detail.data.renderedText} + )} + + {JSON.stringify(detail.data.headers, null, 2)} + + + {detail.data.payload === null + ? "Not stored — this request failed verification." + : JSON.stringify(detail.data.payload, null, 2)} + +
+ ) + )} + + + {replayable && delivery && ( + + )} + + +
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/webhooks/[hookId]/_components/delivery-status-badge.tsx b/apps/web/src/app/(dashboard)/webhooks/[hookId]/_components/delivery-status-badge.tsx new file mode 100644 index 00000000..f7e03cae --- /dev/null +++ b/apps/web/src/app/(dashboard)/webhooks/[hookId]/_components/delivery-status-badge.tsx @@ -0,0 +1,73 @@ +import { + CircleCheck, + CircleX, + Clock, + Inbox, + Loader2, + ShieldX, +} from "lucide-react"; +import type { WebhookDelivery } from "@/lib/api"; + +export interface DeliveryStatusBadgeProps { + delivery: Pick; +} + +/** + * Same idiom as the activity log's `StatusBadge` — a span plus a lucide icon, + * not the `Badge` component. + * + * "In flight" is not a stored status: the server derives it from a pending row + * that holds a live claim, which is why it is checked before `status` here. + */ +export const DeliveryStatusBadge = ({ delivery }: DeliveryStatusBadgeProps) => { + if (delivery.inFlight) { + return ( + + + In flight + + ); + } + + if (delivery.status === "delivered") { + return ( + + + Delivered + + ); + } + + if (delivery.status === "failed") { + return ( + + + Failed + + ); + } + + if (delivery.status === "discarded") { + if (delivery.discardReason === "rejected") { + return ( + + + Rejected + + ); + } + return ( + + + {delivery.discardReason === "handshake" ? "Handshake" : "Ignored"} + + ); + } + + return ( + + + Queued + + ); +}; diff --git a/apps/web/src/app/(dashboard)/webhooks/[hookId]/_components/webhook-deliveries.tsx b/apps/web/src/app/(dashboard)/webhooks/[hookId]/_components/webhook-deliveries.tsx new file mode 100644 index 00000000..ba98d35b --- /dev/null +++ b/apps/web/src/app/(dashboard)/webhooks/[hookId]/_components/webhook-deliveries.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { Loader2, Radio } from "lucide-react"; +import { Button } from "@onecli/ui/components/button"; +import { webhooks } from "@/lib/api"; +import type { WebhookDelivery, WebhookDeliveryPage } from "@/lib/api"; +import { DeliveriesTable } from "./deliveries-table"; +import { DeliveryDetailDialog } from "./delivery-detail-dialog"; + +export interface WebhookDeliveriesProps { + hookId: string; +} + +/** + * A port of the activity log's paging + live-poll shape. + * + * Plain `useState` rather than `useInfiniteQuery` on purpose: the live poll + * replaces page 0 in place while keeping appended pages, which does not map + * cleanly onto infinite-query pages — and the activity log is the design + * authority for this surface. + */ +export const WebhookDeliveries = ({ hookId }: WebhookDeliveriesProps) => { + const [deliveries, setDeliveries] = useState([]); + const [nextCursor, setNextCursor] = + useState(null); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [liveMode, setLiveMode] = useState(true); + const [selected, setSelected] = useState(null); + const initialized = useRef(false); + + const loadInitial = useCallback(async () => { + setLoading(true); + try { + const page = await webhooks.deliveries(hookId); + setDeliveries(page.deliveries); + setNextCursor(page.nextCursor); + initialized.current = true; + } finally { + setLoading(false); + } + }, [hookId]); + + useEffect(() => { + initialized.current = false; + void loadInitial(); + }, [loadInitial]); + + useEffect(() => { + if (!liveMode || loading) return; + const timer = setInterval(async () => { + if (!initialized.current) return; + try { + const page = await webhooks.deliveries(hookId); + setDeliveries((prev) => { + // Identity check: an unchanged head means React can skip the render + // entirely, which matters at a 3s cadence. + if ( + prev.length === page.deliveries.length && + prev[0]?.id === page.deliveries[0]?.id && + prev[0]?.status === page.deliveries[0]?.status + ) { + return prev; + } + return page.deliveries; + }); + setNextCursor(page.nextCursor); + } catch { + // Best-effort polling: a transient failure should not clear the table. + } + }, 3000); + return () => clearInterval(timer); + }, [liveMode, loading, hookId]); + + const loadMore = async () => { + if (!nextCursor) return; + // Paging and live-replacing page 0 fight each other; paging wins. + setLiveMode(false); + setLoadingMore(true); + try { + const page = await webhooks.deliveries(hookId, { + cursorCreatedAt: nextCursor.createdAt, + cursorId: nextCursor.id, + }); + setDeliveries((prev) => [...prev, ...page.deliveries]); + setNextCursor(page.nextCursor); + } finally { + setLoadingMore(false); + } + }; + + return ( +
+
+

Deliveries

+ +
+ + {loading ? ( +
+ +
+ ) : ( + <> + + {nextCursor && ( +
+ +
+ )} + + )} + + setSelected(null)} + /> +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/webhooks/[hookId]/_components/webhook-detail-content.tsx b/apps/web/src/app/(dashboard)/webhooks/[hookId]/_components/webhook-detail-content.tsx new file mode 100644 index 00000000..de3421b0 --- /dev/null +++ b/apps/web/src/app/(dashboard)/webhooks/[hookId]/_components/webhook-detail-content.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { usePathname, useRouter } from "next/navigation"; +import { Badge } from "@onecli/ui/components/badge"; +import { Card } from "@onecli/ui/components/card"; +import { Skeleton } from "@onecli/ui/components/skeleton"; +import { PageHeader } from "@dashboard/page-header"; +import { useWebhook } from "@/hooks/use-webhooks"; +import { usePublicBaseUrl } from "@/hooks/use-public-base-url"; +import { withProjectPrefix } from "@/lib/navigation"; +import { WebhookRowActions } from "../../_components/webhook-row-actions"; +import { WebhookDeliveries } from "./webhook-deliveries"; +import { WebhookSummaryCard } from "./webhook-summary-card"; + +export interface WebhookDetailContentProps { + hookId: string; + publicBaseUrl: string; +} + +export const WebhookDetailContent = ({ + hookId, + publicBaseUrl, +}: WebhookDetailContentProps) => { + const endpoint = useWebhook(hookId); + const baseUrl = usePublicBaseUrl(publicBaseUrl); + const router = useRouter(); + const pathname = usePathname(); + + if (endpoint.isPending) { + return ( +
+ + + + +
+ ); + } + + if (endpoint.isError) { + return ( + +

Webhook not found

+

+ {endpoint.error.message} +

+
+ ); + } + + return ( + <> +
+ +
+ {!endpoint.data.enabled && Disabled} + + router.push(withProjectPrefix(pathname, "/webhooks")) + } + /> +
+
+ + + + + + ); +}; diff --git a/apps/web/src/app/(dashboard)/webhooks/[hookId]/_components/webhook-summary-card.tsx b/apps/web/src/app/(dashboard)/webhooks/[hookId]/_components/webhook-summary-card.tsx new file mode 100644 index 00000000..b1651c1b --- /dev/null +++ b/apps/web/src/app/(dashboard)/webhooks/[hookId]/_components/webhook-summary-card.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { Card } from "@onecli/ui/components/card"; +import type { WebhookEndpointWithSecret } from "@/lib/api"; +import { IngestUrlPanel } from "../../_components/ingest-url-panel"; + +export interface WebhookSummaryCardProps { + endpoint: WebhookEndpointWithSecret; + ingestUrl: string; +} + +const Field = ({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) => ( +
+

{label}

+
{children}
+
+); + +export const WebhookSummaryCard = ({ + endpoint, + ingestUrl, +}: WebhookSummaryCardProps) => ( +
+ + + + + + + {endpoint.agentName}{" "} + + {endpoint.agentIdentifier} + + + + +
+          {endpoint.template.trim() === ""
+            ? "(default: the slug, the event, and the raw payload)"
+            : endpoint.template}
+        
+
+ + + {endpoint.routing ? ( +
+            {JSON.stringify(endpoint.routing, null, 2)}
+          
+ ) : ( + + None — the consumer decides what to do with each delivery. + + )} +
+ + + + {endpoint.rateLimitPerMin} requests per minute + + +
+
+); diff --git a/apps/web/src/app/(dashboard)/webhooks/[hookId]/loading.tsx b/apps/web/src/app/(dashboard)/webhooks/[hookId]/loading.tsx new file mode 100644 index 00000000..c343b502 --- /dev/null +++ b/apps/web/src/app/(dashboard)/webhooks/[hookId]/loading.tsx @@ -0,0 +1,27 @@ +import { Card } from "@onecli/ui/components/card"; +import { Skeleton } from "@onecli/ui/components/skeleton"; + +export default function Loading() { + return ( +
+
+ + +
+ +
+ {[1, 2, 3].map((i) => ( + + ))} +
+
+ +
+ {[1, 2, 3, 4].map((i) => ( + + ))} +
+
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/webhooks/[hookId]/page.tsx b/apps/web/src/app/(dashboard)/webhooks/[hookId]/page.tsx new file mode 100644 index 00000000..f9ea0280 --- /dev/null +++ b/apps/web/src/app/(dashboard)/webhooks/[hookId]/page.tsx @@ -0,0 +1,31 @@ +import { Suspense } from "react"; +import type { Metadata } from "next"; +import { headers } from "next/headers"; +import { + configuredAppUrl, + originFromHeaders, +} from "@onecli/api/lib/app-origin"; +import { APP_URL } from "@/lib/env"; +import { WebhookDetailContent } from "./_components/webhook-detail-content"; + +export const metadata: Metadata = { + title: "Webhook", +}; + +interface Props { + params: Promise<{ hookId: string }>; +} + +export default async function WebhookDetailPage({ params }: Props) { + const { hookId } = await params; + const publicBaseUrl = + configuredAppUrl() ?? originFromHeaders(await headers()) ?? APP_URL; + + return ( +
+ + + +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/webhooks/_components/create-webhook-dialog.tsx b/apps/web/src/app/(dashboard)/webhooks/_components/create-webhook-dialog.tsx new file mode 100644 index 00000000..58938246 --- /dev/null +++ b/apps/web/src/app/(dashboard)/webhooks/_components/create-webhook-dialog.tsx @@ -0,0 +1,149 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@onecli/ui/components/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@onecli/ui/components/dialog"; +import { useCreateWebhook } from "@/hooks/use-webhooks"; +import { usePublicBaseUrl } from "@/hooks/use-public-base-url"; +import type { WebhookEndpointWithSecret } from "@/lib/api"; +import { IngestUrlPanel } from "./ingest-url-panel"; +import { + validateWebhookForm, + WebhookFormFields, + type WebhookFormValues, +} from "./webhook-form-fields"; + +export interface CreateWebhookDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + publicBaseUrl: string; +} + +const EMPTY: WebhookFormValues = { + name: "", + slug: "", + agentId: "", + verification: "github", + template: "", + routing: "", + enabled: true, +}; + +export const CreateWebhookDialog = ({ + open, + onOpenChange, + publicBaseUrl, +}: CreateWebhookDialogProps) => { + const [values, setValues] = useState(EMPTY); + const [touched, setTouched] = useState(false); + // Two-step: the form, then the setup panel. The URL and secret are the whole + // point of creating one, so they get a screen rather than a toast. + const [created, setCreated] = useState( + null, + ); + const create = useCreateWebhook(); + + const baseUrl = usePublicBaseUrl(publicBaseUrl); + const errors = validateWebhookForm(values); + const hasError = Object.values(errors).some(Boolean); + + const handleClose = (next: boolean) => { + if (!next) { + setValues(EMPTY); + setTouched(false); + setCreated(null); + } + onOpenChange(next); + }; + + const handleCreate = () => { + setTouched(true); + if (hasError || create.isPending) return; + + create.mutate( + { + name: values.name.trim(), + slug: values.slug.trim(), + agentId: values.agentId, + verification: values.verification, + template: values.template, + routing: + values.routing.trim() === "" + ? null + : (JSON.parse(values.routing) as Record), + ...(values.verification === "none" + ? { acknowledgeUnverified: true } + : {}), + }, + { onSuccess: setCreated }, + ); + }; + + return ( + + + {created ? ( + <> + + {created.name} is ready + + Configure your provider with the URL below. Deliveries appear in + this endpoint's log as they arrive. + + +
+ +
+ + + + + ) : ( + <> + + Create webhook endpoint + + OneCLI verifies each delivery, renders your template, and queues + it for the agent to pick up. + + +
+ + setValues((current) => ({ ...current, ...patch })) + } + /> +
+ + + + + + )} +
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/webhooks/_components/edit-webhook-dialog.tsx b/apps/web/src/app/(dashboard)/webhooks/_components/edit-webhook-dialog.tsx new file mode 100644 index 00000000..2b61229d --- /dev/null +++ b/apps/web/src/app/(dashboard)/webhooks/_components/edit-webhook-dialog.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Button } from "@onecli/ui/components/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@onecli/ui/components/dialog"; +import { useUpdateWebhook } from "@/hooks/use-webhooks"; +import type { WebhookEndpoint } from "@/lib/api"; +import { + validateWebhookForm, + WebhookFormFields, + type WebhookFormValues, +} from "./webhook-form-fields"; + +export interface EditWebhookDialogProps { + endpoint: WebhookEndpoint; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +const seed = (endpoint: WebhookEndpoint): WebhookFormValues => ({ + name: endpoint.name, + slug: endpoint.slug, + agentId: endpoint.agentId, + verification: endpoint.verification, + template: endpoint.template, + routing: endpoint.routing ? JSON.stringify(endpoint.routing, null, 2) : "", + enabled: endpoint.enabled, +}); + +export const EditWebhookDialog = ({ + endpoint, + open, + onOpenChange, +}: EditWebhookDialogProps) => { + const [values, setValues] = useState(() => seed(endpoint)); + const [touched, setTouched] = useState(false); + const update = useUpdateWebhook(); + + // Re-seed on open so a cancelled edit does not persist into the next one. + useEffect(() => { + if (open) { + setValues(seed(endpoint)); + setTouched(false); + } + }, [open, endpoint]); + + const errors = validateWebhookForm(values); + const hasError = Object.values(errors).some(Boolean); + const verificationChanged = values.verification !== endpoint.verification; + + const handleSave = () => { + setTouched(true); + if (hasError || update.isPending) return; + + update.mutate( + { + hookId: endpoint.id, + input: { + name: values.name.trim(), + slug: values.slug.trim(), + agentId: values.agentId, + verification: values.verification, + template: values.template, + routing: + values.routing.trim() === "" + ? null + : (JSON.parse(values.routing) as Record), + enabled: values.enabled, + ...(values.verification === "none" + ? { acknowledgeUnverified: true } + : {}), + }, + }, + { onSuccess: () => onOpenChange(false) }, + ); + }; + + return ( + + + + Edit {endpoint.name} + + Template changes apply to new deliveries, and to any delivery you + replay. + + +
+ + setValues((current) => ({ ...current, ...patch })) + } + showEnabled + /> + {/* A GitHub HMAC key and a shared token are not interchangeable, so + the server mints a new secret when this changes. Saying so here + beats discovering it when the provider starts getting 401s. */} + {verificationChanged && ( +

+ Changing verification issues a new secret. Update it at the + provider immediately, or deliveries will be rejected. +

+ )} +
+ + + + +
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/webhooks/_components/ingest-url-panel.tsx b/apps/web/src/app/(dashboard)/webhooks/_components/ingest-url-panel.tsx new file mode 100644 index 00000000..e6ec481e --- /dev/null +++ b/apps/web/src/app/(dashboard)/webhooks/_components/ingest-url-panel.tsx @@ -0,0 +1,135 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { Check, Copy, Eye, EyeOff } from "lucide-react"; +import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; +import { IS_CLOUD } from "@/lib/env"; +import { TestCommand } from "./test-command"; + +export interface IngestUrlPanelProps { + ingestUrl: string; + verification: string; + secret: string | null; +} + +const CopyButton = ({ value }: { value: string }) => { + const { copied, copy } = useCopyToClipboard(); + return ( + + ); +}; + +const SECRET_LABELS: Record = { + github: "Paste into GitHub's Secret field.", + token: "Send as an X-Webhook-Token header, or as ?token= on the URL.", +}; + +/** + * The setup checklist, top to bottom: where to paste the URL, the secret that + * goes with it, the content type that trips everyone up, and a command to prove + * the whole path works. + */ +export const IngestUrlPanel = ({ + ingestUrl, + verification, + secret, +}: IngestUrlPanelProps) => { + const [revealed, setRevealed] = useState(false); + + const masked = secret + ? `${secret.slice(0, 6)}${"•".repeat(12)}${secret.slice(-4)}` + : ""; + + return ( +
+
+

Payload URL

+
+ + {ingestUrl} + + +
+

+ Paste this into your provider's webhook configuration — in + GitHub, Settings → Webhooks → Payload URL. + {!IS_CLOUD && ( + <> + {" "} + + Check your public URL + {" "} + if this host is not reachable from the internet. + + )} +

+
+ + {secret ? ( +
+

Secret

+
+ + {revealed ? secret : masked} + + + +
+

+ {SECRET_LABELS[verification] ?? "Send with every request."} +

+
+ ) : ( +
+

+ This endpoint accepts any request that reaches the URL. The URL is + the only secret — do not paste it into an issue or a screenshot. +

+
+ )} + + {/* The single most common setup failure: GitHub defaults to + form-encoded, and a sender that posts something else gets a 415. */} +
+

Content type

+

+ Choose application/json. GitHub + defaults to form-urlencoded, which + works too, but JSON is what every template example assumes. +

+
+ + +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/webhooks/_components/test-command.tsx b/apps/web/src/app/(dashboard)/webhooks/_components/test-command.tsx new file mode 100644 index 00000000..9a5f379d --- /dev/null +++ b/apps/web/src/app/(dashboard)/webhooks/_components/test-command.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { Check, Copy } from "lucide-react"; +import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; + +export interface TestCommandProps { + ingestUrl: string; + verification: string; + secret: string | null; +} + +/** + * A copyable curl rather than an in-app "send test" button. + * + * Deliberate: a server-side self-POST to our own public URL is an SSRF-shaped + * surface, and a client-side one would need CORS plus an HMAC signing oracle in + * the dashboard. More importantly, this tests the thing that actually breaks — + * whether the provider's network can reach this origin at all — which an + * internal test-send would bypass while showing a reassuring green. + */ +const buildCommand = ( + ingestUrl: string, + verification: string, + secret: string | null, +): string => { + if (verification === "github") { + return [ + `BODY='{"action":"opened","repository":{"full_name":"acme/api"}}'`, + `SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac '${secret ?? ""}' -r | cut -d' ' -f1)`, + `curl -sS -X POST '${ingestUrl}' \\`, + ` -H "X-Hub-Signature-256: sha256=$SIG" \\`, + ` -H 'X-GitHub-Event: issues' \\`, + ` -H 'X-GitHub-Delivery: test-1' \\`, + ` -H 'Content-Type: application/json' \\`, + ` --data-binary "$BODY"`, + ].join("\n"); + } + + const auth = + verification === "token" + ? ` -H 'X-Webhook-Token: ${secret ?? ""}' \\\n` + : ""; + return ( + `curl -sS -X POST '${ingestUrl}' \\\n` + + auth + + ` -H 'Content-Type: application/json' \\\n` + + ` --data-binary '{"hello":"world"}'` + ); +}; + +export const TestCommand = ({ + ingestUrl, + verification, + secret, +}: TestCommandProps) => { + const { copied, copy } = useCopyToClipboard(); + const command = buildCommand(ingestUrl, verification, secret); + + return ( +
+
+

Test it

+ +
+
+        {command}
+      
+ {verification === "github" && ( +

+ Note the printf and{" "} + --data-binary: the signature covers + the exact bytes, so a trailing newline from{" "} + echo breaks it. +

+ )} +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/webhooks/_components/verification-badge.tsx b/apps/web/src/app/(dashboard)/webhooks/_components/verification-badge.tsx new file mode 100644 index 00000000..c9344a3a --- /dev/null +++ b/apps/web/src/app/(dashboard)/webhooks/_components/verification-badge.tsx @@ -0,0 +1,38 @@ +import { KeyRound, ShieldAlert, ShieldCheck } from "lucide-react"; + +export interface VerificationBadgeProps { + verification: string; +} + +/** + * Follows the activity log's badge idiom — a span plus a lucide icon, not the + * `Badge` component — so the two log-shaped surfaces read the same. + */ +export const VerificationBadge = ({ verification }: VerificationBadgeProps) => { + if (verification === "github") { + return ( + + + GitHub HMAC + + ); + } + + if (verification === "token") { + return ( + + + Shared token + + ); + } + + // Worth flagging every time it is rendered: with no verification, anyone who + // learns the URL can post to it. + return ( + + + Unverified + + ); +}; diff --git a/apps/web/src/app/(dashboard)/webhooks/_components/webhook-form-fields.tsx b/apps/web/src/app/(dashboard)/webhooks/_components/webhook-form-fields.tsx new file mode 100644 index 00000000..0cc50ea5 --- /dev/null +++ b/apps/web/src/app/(dashboard)/webhooks/_components/webhook-form-fields.tsx @@ -0,0 +1,259 @@ +"use client"; + +import { Input } from "@onecli/ui/components/input"; +import { Label } from "@onecli/ui/components/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@onecli/ui/components/select"; +import { Switch } from "@onecli/ui/components/switch"; +import { Textarea } from "@onecli/ui/components/textarea"; +import { cn } from "@onecli/ui/lib/utils"; +import { useAgents } from "@/hooks/use-agents"; +import { useWebhookVerifiers } from "@/hooks/use-webhooks"; + +export interface WebhookFormValues { + name: string; + slug: string; + agentId: string; + verification: string; + template: string; + routing: string; + enabled: boolean; +} + +export interface WebhookFormErrors { + name?: string | null; + slug?: string | null; + agentId?: string | null; + routing?: string | null; +} + +export interface WebhookFormFieldsProps { + values: WebhookFormValues; + errors: WebhookFormErrors; + touched: boolean; + onChange: (patch: Partial) => void; + /** The enabled switch only makes sense once an endpoint exists. */ + showEnabled?: boolean; + idPrefix: string; +} + +/** + * Validation lives here as plain predicates rather than a resolver — this app + * has no react-hook-form and no zodResolver, and the server's Zod schema is the + * authority regardless. These checks exist to avoid a round trip, not to be the + * gate. + */ +export const validateWebhookForm = ( + values: WebhookFormValues, +): WebhookFormErrors => ({ + name: + values.name.trim().length === 0 + ? "Name is required." + : values.name.trim().length > 100 + ? "Name must be 100 characters or fewer." + : null, + slug: !/^[a-z0-9][a-z0-9-]*$/.test(values.slug.trim()) + ? "Lowercase letters, digits and dashes only." + : null, + agentId: values.agentId === "" ? "Choose the agent to wake." : null, + routing: routingError(values.routing), +}); + +/** + * The routing blob is checked for being parseable JSON and nothing else. + * OneCLI never interprets it — validating its shape here would quietly couple + * the dashboard to one consumer's schema. + */ +const routingError = (routing: string): string | null => { + if (routing.trim() === "") return null; + try { + const parsed: unknown = JSON.parse(routing); + if ( + typeof parsed !== "object" || + parsed === null || + Array.isArray(parsed) + ) { + return "Routing must be a JSON object."; + } + return null; + } catch { + return "Routing must be valid JSON."; + } +}; + +export const WebhookFormFields = ({ + values, + errors, + touched, + onChange, + showEnabled = false, + idPrefix, +}: WebhookFormFieldsProps) => { + const agents = useAgents(); + const verifiers = useWebhookVerifiers(); + const show = (error?: string | null) => touched && Boolean(error); + + return ( +
+
+ + onChange({ name: e.target.value })} + autoFocus + className={cn(show(errors.name) && "border-destructive")} + /> + {show(errors.name) && ( +

{errors.name}

+ )} +
+ +
+ + onChange({ slug: e.target.value })} + className={cn("font-mono", show(errors.slug) && "border-destructive")} + /> +

+ Identifies the webhook to the agent, and available in templates as{" "} + {"{{$slug}}"}. +

+ {show(errors.slug) && ( +

{errors.slug}

+ )} +
+ +
+ + + {values.verification === "none" && ( +

+ Anyone who learns this URL can trigger the agent. Treat it like a + password and prefer a signed or token-verified sender. +

+ )} +
+ +
+ + + {show(errors.agentId) && ( +

{errors.agentId}

+ )} +
+ +
+ +