Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions apps/cloud/src/services/sources-api.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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",
() =>
Expand Down
35 changes: 34 additions & 1 deletion packages/react/src/plugins/secret-credential-scope.ts
Original file line number Diff line number Diff line change
@@ -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;
});
};
71 changes: 70 additions & 1 deletion packages/react/src/plugins/secret-header-auth.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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", () => {
Expand Down
83 changes: 74 additions & 9 deletions packages/react/src/plugins/secret-header-auth.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 };

Expand Down Expand Up @@ -61,9 +65,12 @@ function CreateSecretContent(props: {
onCancel?: () => void;
fallbackId?: string;
targetScope: ScopeId;
secretScopeOptions?: readonly CredentialTargetScopeOption[];
onTargetScopeChange?: (scopeId: ScopeId) => void;
}) {
return (
<SecretForm.Provider
key={String(props.targetScope)}
existingSecretIds={props.existingSecretIds}
suggestedName={props.suggestedName}
fallbackId={props.fallbackId ?? "custom-header"}
Expand All @@ -72,6 +79,26 @@ function CreateSecretContent(props: {
>
<div className="space-y-3">
<FieldGroup className="gap-3">
{props.secretScopeOptions && props.secretScopeOptions.length > 1 && (
<Field>
<FieldLabel>Save in</FieldLabel>
<Select
value={String(props.targetScope)}
onValueChange={(scopeId) => props.onTargetScopeChange?.(ScopeId.make(scopeId))}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Save in" />
</SelectTrigger>
<SelectContent>
{props.secretScopeOptions.map((option) => (
<SelectItem key={option.scopeId} value={option.scopeId}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
)}
<div className="grid grid-cols-2 gap-3">
<SecretForm.NameField label="Label" placeholder="API Token" />
<SecretForm.IdField placeholder="my-api-token" />
Expand Down Expand Up @@ -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 (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent>
Expand All @@ -129,11 +179,13 @@ function CreateSecretDialog(props: {
</DialogHeader>
<CreateSecretContent
suggestedName={props.suggestedName}
existingSecretIds={props.existingSecretIds}
existingSecretIds={selectedExistingSecretIds}
fallbackId={props.fallbackId}
onCreated={props.onCreated}
onCancel={() => props.onOpenChange(false)}
targetScope={props.targetScope}
targetScope={selectedScope}
secretScopeOptions={allowedScopeOptions}
onTargetScopeChange={setSelectedScope}
/>
</DialogContent>
</Dialog>
Expand Down Expand Up @@ -340,20 +392,26 @@ 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 (
<div className="space-y-2.5 px-4 py-3">
<CreateSecretDialog
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
}
/>
<div className="flex w-full items-center justify-between gap-4">
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">
Expand Down Expand Up @@ -496,26 +554,33 @@ 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 (
<CreateSecretDialog
open={creating}
onOpenChange={setCreating}
suggestedName={suggestedName}
existingSecretIds={scopedSecrets.map((secret) => secret.id)}
existingSecrets={secrets}
fallbackId={suggestedIdProp?.trim() || "secret"}
onCreated={(id, scopeId) => {
onCreatedScope?.(scopeId);
onSelect(id, scopeId);
setCreating(false);
}}
targetScope={targetScope}
secretScopeOptions={
credentialScopeOptions
? secretScopeOptionsForCredentialTarget(credentialScopeOptions, targetScope, scopeStack)
: undefined
}
/>
);
}
Expand Down
Loading