diff --git a/apps/cloud/src/services/sources-api.node.test.ts b/apps/cloud/src/services/sources-api.node.test.ts index a8cfa8ee5..251271647 100644 --- a/apps/cloud/src/services/sources-api.node.test.ts +++ b/apps/cloud/src/services/sources-api.node.test.ts @@ -20,6 +20,7 @@ import { makeOpenApiHttpApiTestSpecPayload, serveOpenApiEchoTestServer, } from "@executor-js/plugin-openapi/testing"; +import { secretsForCredentialTarget } from "@executor-js/react/plugins/secret-header-auth"; import { asOrg, asUser, testUserOrgScopeId } from "./__test-harness__/api-harness"; @@ -696,6 +697,62 @@ describe("sources api (HTTP)", () => { }), ); + it.effect("personal source override picker can see org-owned secrets over HTTP", () => + Effect.gen(function* () { + const orgId = `org_${crypto.randomUUID()}`; + const aliceId = `user_${crypto.randomUUID().slice(0, 8)}`; + const namespace = `ns_${crypto.randomUUID().replace(/-/g, "_")}`; + const aliceScope = testUserOrgScopeId(aliceId, orgId); + + yield* asOrg(orgId, (client) => + Effect.gen(function* () { + yield* client.openapi.addSpec({ + params: { scopeId: ScopeId.make(orgId) }, + payload: { + ...makeMinimalOpenApiSourcePayload(namespace), + headers: { + Authorization: { + kind: "secret", + prefix: "Bearer ", + }, + }, + }, + }); + + yield* client.secrets.set({ + params: { scopeId: ScopeId.make(orgId) }, + payload: { + id: SecretId.make("shared_pat"), + name: "Shared PAT", + value: "org-secret", + }, + }); + }), + ); + + const secrets = yield* asUser(aliceId, orgId, (client) => + client.secrets.listAll({ params: { scopeId: ScopeId.make(aliceScope) } }), + ); + + const pickerSecrets = secrets.map((secret) => ({ + id: String(secret.id), + scopeId: String(secret.scopeId), + name: secret.name, + provider: secret.provider ? String(secret.provider) : undefined, + })); + + expect(pickerSecrets).toContainEqual( + expect.objectContaining({ id: "shared_pat", scopeId: orgId }), + ); + expect( + secretsForCredentialTarget(pickerSecrets, ScopeId.make(aliceScope), [ + { id: ScopeId.make(aliceScope) }, + { id: ScopeId.make(orgId) }, + ]).map((secret) => secret.id), + ).toContain("shared_pat"); + }), + ); + it.effect( "addSpec persists the full Cloudflare spec through the real Drizzle/FumaDB path", () => diff --git a/packages/react/src/plugins/secret-credential-scope.ts b/packages/react/src/plugins/secret-credential-scope.ts index 0015c4eb4..947f9dd7f 100644 --- a/packages/react/src/plugins/secret-credential-scope.ts +++ b/packages/react/src/plugins/secret-credential-scope.ts @@ -1,9 +1,42 @@ import type { ScopeId } from "@executor-js/sdk/shared"; import type { SecretPickerSecret } from "./secret-picker"; +import type { CredentialTargetScopeOption } from "./credential-target-scope"; + +type ScopeStackEntryLike = { + readonly id: ScopeId | string; +}; + +const scopeRank = ( + scopeStack: readonly ScopeStackEntryLike[], + scopeId: ScopeId | string, +): number => scopeStack.findIndex((entry) => String(entry.id) === String(scopeId)); export const secretsForCredentialTarget = ( secrets: readonly SecretPickerSecret[], targetScope: ScopeId, + scopeStack: readonly ScopeStackEntryLike[] = [], ): readonly SecretPickerSecret[] => - secrets.filter((secret) => secret.scopeId === String(targetScope)); + secrets.filter((secret) => { + const targetRank = scopeRank(scopeStack, targetScope); + if (targetRank === -1) return secret.scopeId === String(targetScope); + + const secretRank = scopeRank(scopeStack, secret.scopeId); + return secretRank >= targetRank; + }); + +export const secretScopeOptionsForCredentialTarget = ( + options: readonly CredentialTargetScopeOption[], + targetScope: ScopeId, + scopeStack: readonly ScopeStackEntryLike[] = [], +): readonly CredentialTargetScopeOption[] => { + const targetRank = scopeRank(scopeStack, targetScope); + if (targetRank === -1) { + return options.filter((option) => option.scopeId === targetScope); + } + + return options.filter((option) => { + const optionRank = scopeRank(scopeStack, option.scopeId); + return optionRank >= targetRank; + }); +}; diff --git a/packages/react/src/plugins/secret-header-auth.test.ts b/packages/react/src/plugins/secret-header-auth.test.ts index 622626065..3af2c0e35 100644 --- a/packages/react/src/plugins/secret-header-auth.test.ts +++ b/packages/react/src/plugins/secret-header-auth.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from "@effect/vitest"; import { ScopeId } from "@executor-js/sdk/shared"; -import { secretsForCredentialTarget } from "./secret-credential-scope"; +import { + secretsForCredentialTarget, + secretScopeOptionsForCredentialTarget, +} from "./secret-credential-scope"; import { secretValueInputType } from "./secret-input"; describe("secretsForCredentialTarget", () => { @@ -16,6 +19,72 @@ describe("secretsForCredentialTarget", () => { ).map((secret) => secret.id), ).toEqual(["shared-token"]); }); + + it("exposes outer-scope secrets for personal credential overrides", () => { + expect( + secretsForCredentialTarget( + [ + { id: "shared-token", scopeId: "org", name: "Shared token" }, + { id: "personal-token", scopeId: "user", name: "Personal token" }, + ], + ScopeId.make("user"), + [ + { id: ScopeId.make("user") }, + { id: ScopeId.make("org") }, + ], + ).map((secret) => secret.id), + ).toEqual(["shared-token", "personal-token"]); + }); + + it("does not expose inner-scope secrets for organization default credentials", () => { + expect( + secretsForCredentialTarget( + [ + { id: "shared-token", scopeId: "org", name: "Shared token" }, + { id: "personal-token", scopeId: "user", name: "Personal token" }, + ], + ScopeId.make("org"), + [ + { id: ScopeId.make("user") }, + { id: ScopeId.make("org") }, + ], + ).map((secret) => secret.id), + ).toEqual(["shared-token"]); + }); +}); + +describe("secretScopeOptionsForCredentialTarget", () => { + const userScope = ScopeId.make("user"); + const orgScope = ScopeId.make("org"); + const options = [ + { + scopeId: userScope, + label: "Personal", + description: "Saved only for your account.", + }, + { + scopeId: orgScope, + label: "Organization", + description: "Shared with everyone who can use this source.", + }, + ]; + const scopeStack = [{ id: userScope }, { id: orgScope }]; + + it("lets personal credential creation target personal or outer scopes", () => { + expect( + secretScopeOptionsForCredentialTarget(options, userScope, scopeStack).map( + (option) => option.label, + ), + ).toEqual(["Personal", "Organization"]); + }); + + it("does not let organization credential creation target inner scopes", () => { + expect( + secretScopeOptionsForCredentialTarget(options, orgScope, scopeStack).map( + (option) => option.label, + ), + ).toEqual(["Organization"]); + }); }); describe("secretValueInputType", () => { diff --git a/packages/react/src/plugins/secret-header-auth.tsx b/packages/react/src/plugins/secret-header-auth.tsx index 065902e4a..54b3eaefa 100644 --- a/packages/react/src/plugins/secret-header-auth.tsx +++ b/packages/react/src/plugins/secret-header-auth.tsx @@ -1,4 +1,4 @@ -import { useId, useState, type ReactNode } from "react"; +import { useEffect, useId, useMemo, useState, type ReactNode } from "react"; import { ScopeId } from "@executor-js/sdk/shared"; import { Button } from "../components/button"; @@ -19,10 +19,14 @@ import { SelectTrigger, SelectValue, } from "../components/select"; +import { useScopeStack } from "../api/scope-context"; import { SecretForm } from "./secret-form"; import { SecretPicker, type SecretPickerSecret } from "./secret-picker"; import type { CredentialTargetScopeOption } from "./credential-target-scope"; -import { secretsForCredentialTarget } from "./secret-credential-scope"; +import { + secretsForCredentialTarget, + secretScopeOptionsForCredentialTarget, +} from "./secret-credential-scope"; export { secretsForCredentialTarget }; @@ -61,9 +65,12 @@ function CreateSecretContent(props: { onCancel?: () => void; fallbackId?: string; targetScope: ScopeId; + secretScopeOptions?: readonly CredentialTargetScopeOption[]; + onTargetScopeChange?: (scopeId: ScopeId) => void; }) { return (
+ {props.secretScopeOptions && props.secretScopeOptions.length > 1 && ( + + Save in + + + )}
@@ -113,11 +140,34 @@ function CreateSecretDialog(props: { readonly open: boolean; readonly onOpenChange: (open: boolean) => void; readonly suggestedName: string; - readonly existingSecretIds: readonly string[]; + readonly existingSecrets: readonly SecretPickerSecret[]; readonly onCreated: (secretId: string, scopeId: ScopeId) => void; readonly fallbackId?: string; readonly targetScope: ScopeId; + readonly secretScopeOptions?: readonly CredentialTargetScopeOption[]; }) { + const [selectedScope, setSelectedScope] = useState(props.targetScope); + const allowedScopeOptions = props.secretScopeOptions?.length + ? props.secretScopeOptions + : [ + { + scopeId: props.targetScope, + label: "Current", + description: "Saved for this credential.", + }, + ]; + const selectedExistingSecretIds = useMemo( + () => + props.existingSecrets + .filter((secret) => secret.scopeId === String(selectedScope)) + .map((secret) => secret.id), + [props.existingSecrets, selectedScope], + ); + + useEffect(() => { + if (props.open) setSelectedScope(props.targetScope); + }, [props.open, props.targetScope]); + return ( @@ -129,11 +179,13 @@ function CreateSecretDialog(props: { props.onOpenChange(false)} - targetScope={props.targetScope} + targetScope={selectedScope} + secretScopeOptions={allowedScopeOptions} + onTargetScopeChange={setSelectedScope} /> @@ -340,7 +392,8 @@ export function SecretHeaderAuthRow(props: { const copy = { ...defaultSecretCredentialRowCopy, ...copyOverride }; const headerLabel = name.trim() || "Custom Header"; const suggestedName = [sourceName?.trim(), headerLabel].filter(Boolean).join(" "); - const scopedSecrets = secretsForCredentialTarget(existingSecrets, targetScope); + const scopeStack = useScopeStack(); + const scopedSecrets = secretsForCredentialTarget(existingSecrets, targetScope, scopeStack); return (
@@ -348,12 +401,17 @@ export function SecretHeaderAuthRow(props: { open={creating} onOpenChange={setCreating} suggestedName={suggestedName} - existingSecretIds={scopedSecrets.map((secret) => secret.id)} + existingSecrets={existingSecrets} onCreated={(id, scopeId) => { onSelectSecret(id, scopeId); setCreating(false); }} targetScope={targetScope} + secretScopeOptions={ + bindingScopeOptions + ? secretScopeOptionsForCredentialTarget(bindingScopeOptions, targetScope, scopeStack) + : undefined + } />
@@ -496,11 +554,13 @@ export function CreatableSecretPicker(props: { targetScope, onCreatedScope, suggestedId: suggestedIdProp, + credentialScopeOptions, } = props; const [creating, setCreating] = useState(false); const suggestedName = [sourceName?.trim(), secretLabel].filter(Boolean).join(" "); - const scopedSecrets = secretsForCredentialTarget(secrets, targetScope); + const scopeStack = useScopeStack(); + const scopedSecrets = secretsForCredentialTarget(secrets, targetScope, scopeStack); if (creating) { return ( @@ -508,7 +568,7 @@ export function CreatableSecretPicker(props: { open={creating} onOpenChange={setCreating} suggestedName={suggestedName} - existingSecretIds={scopedSecrets.map((secret) => secret.id)} + existingSecrets={secrets} fallbackId={suggestedIdProp?.trim() || "secret"} onCreated={(id, scopeId) => { onCreatedScope?.(scopeId); @@ -516,6 +576,11 @@ export function CreatableSecretPicker(props: { setCreating(false); }} targetScope={targetScope} + secretScopeOptions={ + credentialScopeOptions + ? secretScopeOptionsForCredentialTarget(credentialScopeOptions, targetScope, scopeStack) + : undefined + } /> ); }