Skip to content
Draft
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
94 changes: 94 additions & 0 deletions apps/cloud/src/services/credential-target.node.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Credential write-target invariants — secrets and connections accept any
// scope in the URL context's stack (4 levels in workspace context, 2 in
// global), and reject scopes outside the stack with a typed storage
// failure. Mirrors `secrets-isolation.e2e.node.test.ts` for cross-org
// rejections, but exercises the full personal/shared cross product within
// a single workspace context.

import { describe, expect, it } from "@effect/vitest";
import { Effect } from "effect";

import { SecretId } from "@executor-js/sdk";

import {
asWorkspace,
asWorkspaceUser,
orgScopeId,
testUserOrgScopeId,
testUserWorkspaceScopeId,
testWorkspaceScopeId,
} from "./__test-harness__/api-harness";

const setSecret = (
client: Parameters<Parameters<typeof asWorkspace>[2]>[0],
scopeId: string,
id: string,
value: string,
) =>
client.secrets.set({
params: { scopeId: scopeId as never },
payload: {
id: SecretId.make(id),
name: id,
value,
},
});

describe("credential write targets in workspace context", () => {
it.effect(
"secrets land at every scope in the URL-resolved stack and list back tagged with that scope",
() =>
Effect.gen(function* () {
const org = `org_${crypto.randomUUID()}`;
const slug = `ws_${crypto.randomUUID().slice(0, 8)}`;
const userId = `u_${crypto.randomUUID().slice(0, 8)}`;
const wsScope = testWorkspaceScopeId(org, slug);
const orgScope = orgScopeId(org);
const userOrg = testUserOrgScopeId(userId, org);
const userWs = testUserWorkspaceScopeId(userId, org, slug);

// One secret per scope, distinct ids so they don't dedup.
yield* asWorkspaceUser(userId, org, slug, (client) =>
Effect.gen(function* () {
yield* setSecret(client, userWs, "uws", "uws-val");
yield* setSecret(client, wsScope, "ws", "ws-val");
yield* setSecret(client, userOrg, "uorg", "uorg-val");
yield* setSecret(client, orgScope, "org", "org-val");
}),
);

// Listing from the workspace scope walks the full stack — all 4
// secrets show up, each tagged with its owning scope.
const list = yield* asWorkspaceUser(userId, org, slug, (client) =>
client.secrets.list({ params: { scopeId: wsScope } }),
);
const byId = new Map(list.map((r) => [r.id, r.scopeId]));
expect(byId.get(SecretId.make("uws"))).toBe(userWs);
expect(byId.get(SecretId.make("ws"))).toBe(wsScope);
expect(byId.get(SecretId.make("uorg"))).toBe(userOrg);
expect(byId.get(SecretId.make("org"))).toBe(orgScope);
}),
);

it.effect(
"secret writes targeting an out-of-stack scope are rejected",
() =>
Effect.gen(function* () {
const orgA = `org_${crypto.randomUUID()}`;
const orgB = `org_${crypto.randomUUID()}`;
const slugA = `ws_${crypto.randomUUID().slice(0, 8)}`;

// From workspace context for orgA, try to write a secret
// targeting orgB's scope. The scoped adapter rejects writes whose
// `scope_id` isn't in the executor's stack — the cloud's
// `secrets-isolation.e2e.node.test.ts` covers the org boundary;
// this case adds the workspace-context wrapper for parity.
const exit = yield* Effect.exit(
asWorkspace(orgA, slugA, (client) =>
setSecret(client, orgScopeId(orgB), "leak", "v"),
),
);
expect(exit._tag).toBe("Failure");
}),
);
});
3 changes: 1 addition & 2 deletions packages/core/sdk/src/executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -501,8 +501,7 @@ describe("createExecutor", () => {
"personal-test"
].registerAt(personalScope).pipe(Effect.exit);
expect(exit._tag).toBe("Failure");
const err = Result.isFailure(exit) ? exit.cause : null;
const errStr = JSON.stringify(err);
const errStr = JSON.stringify(exit);
expect(errStr).toContain("InvalidSourceWriteTargetError");

// Same call to a non-personal scope (the org) succeeds.
Expand Down
19 changes: 17 additions & 2 deletions packages/react/src/pages/secrets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import { secretWriteKeys } from "../api/reactivity-keys";
import { useSecretProviderPlugins } from "@executor-js/sdk/client";
import { SecretId } from "@executor-js/sdk";
import { useActiveWriteScopeId } from "../hooks/use-scope";
import {
CredentialTargetSelector,
useCredentialTargetState,
} from "../plugins/credential-target-selector";
import {
Dialog,
DialogContent,
Expand Down Expand Up @@ -72,7 +76,11 @@ function AddSecretDialog(props: {
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);

const scopeId = useActiveWriteScopeId();
// The credential-target selector defaults to the URL context's active
// write scope (workspace in workspace context, org in global). Users
// can flip to "Only me here" / "Only me org-wide" without leaving the
// dialog.
const target = useCredentialTargetState();
const doSet = useAtomSet(setSecret, { mode: "promise" });

const reset = () => {
Expand All @@ -90,7 +98,7 @@ function AddSecretDialog(props: {
setError(null);
try {
await doSet({
params: { scopeId },
params: { scopeId: target.value },
payload: {
id: SecretId.make(id.trim()),
name: name.trim(),
Expand Down Expand Up @@ -174,6 +182,13 @@ function AddSecretDialog(props: {
/>
</div>

<CredentialTargetSelector
value={target.value}
onChange={target.setValue}
disabled={saving}
label="Save to"
/>

<div className="grid gap-3">
{props.storageOptions.length > 1 && (
<div className="grid gap-1.5">
Expand Down
158 changes: 158 additions & 0 deletions packages/react/src/plugins/credential-target-selector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import * as React from "react";
import { Label } from "../components/label";
import { NativeSelect, NativeSelectOption } from "../components/native-select";
import type { ScopeId } from "@executor-js/sdk";

import { useActiveWriteScopeId, useScopeStack } from "../api/scope-context";

// ---------------------------------------------------------------------------
// CredentialTargetSelector — visible chooser for the scope a credential
// (secret / connection / policy) write should land at. Unlike source
// definitions, credentials are valid at every scope in the URL context's
// stack, including the personal scopes. The plan in
// `notes/cloud-workspaces-and-global-sources-plan.md` calls out four
// labels:
//
// - Only me in this workspace → user-workspace
// - Everyone in this workspace → workspace
// - Only me across this org → user-org
// - Everyone in this org → org
//
// In global context only the latter two are visible. The default
// selection is the URL context's active write scope (`org` global,
// `workspace` workspace) — pre-fills a "team-wide" target while still
// letting the user opt into a personal override.
//
// Local CLI hosts have a single-scope stack; the selector renders a single
// option labeled with the scope's display name and disables the dropdown.
// ---------------------------------------------------------------------------

export interface CredentialTargetOption {
readonly scopeId: ScopeId;
readonly label: string;
/** Hint for ordering / grouping. Not used for matching. */
readonly kind: "user-workspace" | "workspace" | "user-org" | "org" | "other";
}

const kindFor = (id: string): CredentialTargetOption["kind"] => {
if (id.startsWith("user_workspace_")) return "user-workspace";
if (id.startsWith("workspace_")) return "workspace";
if (id.startsWith("user_org_")) return "user-org";
if (id.startsWith("org_")) return "org";
return "other";
};

const labelFor = (id: string, name: string, kind: CredentialTargetOption["kind"]): string => {
switch (kind) {
case "user-workspace":
return "Only me in this workspace";
case "workspace":
return `Everyone in ${name}`;
case "user-org":
return "Only me across this org";
case "org":
return `Everyone in ${name}`;
default:
return name;
}
};

/**
* Returns the legal credential targets for the current URL context, in
* display order: most personal → most shared. In a workspace context that
* is `[user-workspace, workspace, user-org, org]`; in global it is
* `[user-org, org]`. The cloud's executor stack already lists scopes
* innermost-first, so this is just a relabel + label-merge.
*/
export function useCredentialTargetOptions(): readonly CredentialTargetOption[] {
const stack = useScopeStack();
return React.useMemo(() => {
const options: CredentialTargetOption[] = [];
for (const entry of stack) {
const kind = kindFor(entry.id);
options.push({
scopeId: entry.id,
kind,
label: labelFor(entry.id, entry.name, kind),
});
}
return options;
}, [stack]);
}

export interface CredentialTargetSelectorProps {
readonly value: ScopeId;
readonly onChange: (next: ScopeId) => void;
readonly disabled?: boolean;
/** Override the default label "Save to". */
readonly label?: string;
readonly id?: string;
}

/**
* Visible target selector for credential write forms (secrets, connection
* tokens, policies). Always renders even when there's only one option —
* the selector documents the explicit target and matches the plan's
* "no hidden defaults" invariant.
*/
export function CredentialTargetSelector(props: CredentialTargetSelectorProps) {
const options = useCredentialTargetOptions();
const fallbackId = useId();
const id = props.id ?? fallbackId;

if (options.length === 0) {
return null;
}

return (
<div className="flex flex-col gap-1.5">
<Label htmlFor={id}>{props.label ?? "Save to"}</Label>
<NativeSelect
id={id}
value={props.value}
disabled={props.disabled || options.length === 1}
onChange={(e) => props.onChange(e.target.value as ScopeId)}
>
{options.map((opt) => (
<NativeSelectOption key={opt.scopeId} value={opt.scopeId}>
{opt.label}
</NativeSelectOption>
))}
</NativeSelect>
</div>
);
}

/**
* Hook for managed credential-target state. Returns the selected target
* plus a setter, defaulting to the URL context's active write scope. The
* default lines up with "team-wide" (workspace in workspace context, org
* global). Callers pass `value` into the API call's `params.scopeId` and
* render `<CredentialTargetSelector>` over `value` + `setValue`.
*/
export function useCredentialTargetState(): {
readonly value: ScopeId;
readonly setValue: (next: ScopeId) => void;
readonly options: readonly CredentialTargetOption[];
} {
const defaultId = useActiveWriteScopeId();
const options = useCredentialTargetOptions();
const [value, setValue] = React.useState<ScopeId>(defaultId);
React.useEffect(() => {
if (!options.some((o) => o.scopeId === value)) {
setValue(defaultId);
}
}, [defaultId, options, value]);
return { value, setValue, options };
}

function useId(): string {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any
const useIdImpl = (React as any).useId as (() => string) | undefined;
const ref = React.useRef<string | null>(null);
if (useIdImpl) return useIdImpl();
if (ref.current === null) {
ref.current = `credential-target-${Math.random().toString(36).slice(2)}`;
}
return ref.current;
}
Loading