History

Refactor secret storage to use providers

Rhys Sullivan committed 7 months ago

Commit 22e370c1 parent 6e204f010 files · +1066 −263Browse files

Unified · Split

10 files
  1. apps/web/src/views/add-source.tsx +58 −5
  2. apps/web/src/views/secrets.tsx +68 −7
  3. apps/web/src/views/source-editor.tsx +50 −7
  4. packages/control-plane/src/api/local/api.ts +3 −0
  5. packages/control-plane/src/api/local/http.ts +89 −48
  6. packages/control-plane/src/runtime/control-plane-runtime.test.ts +88 −0
  7. packages/control-plane/src/runtime/local-control-plane-store.test.ts +48 −2
  8. packages/control-plane/src/runtime/secret-material-providers.ts +621 −193
  9. packages/control-plane/src/runtime/store.ts +1 −0
  10. packages/control-plane/src/schema/models/secret-material.ts +40 −1
apps/web/src/views/add-source.tsx+58−5
@@ -1,10 +1,11 @@
1import { useState, type ReactNode } from "react";1import { useEffect, useState, type ReactNode } from "react";
2import { Link, useNavigate } from "@tanstack/react-router";2import { Link, useNavigate } from "@tanstack/react-router";
3import {3import {
4 type CompleteSourceOAuthResult,4 type CompleteSourceOAuthResult,
5 type ConnectSourcePayload,5 type ConnectSourcePayload,
6 type ConnectSourceResult,6 type ConnectSourceResult,
7 type DiscoverSourcePayload,7 type DiscoverSourcePayload,
8 type InstanceConfig,
8 type Loadable,9 type Loadable,
9 type SecretListItem,10 type SecretListItem,
10 type Source,11 type Source,
@@ -12,6 +13,7 @@ import { · 1 unmodified line
12 useConnectSource,13 useConnectSource,
13 useCreateSecret,14 useCreateSecret,
14 useDiscoverSource,15 useDiscoverSource,
16 useInstanceConfig,
15 useRefreshSecrets,17 useRefreshSecrets,
16 useSecrets,18 useSecrets,
17 useStartSourceOAuth,19 useStartSourceOAuth,
@@ -590,6 +592,7 @@ export function AddSourcePage() { · 572 unmodified lines
590 const navigate = useNavigate();592 const navigate = useNavigate();
591 const discoverSource = useDiscoverSource();593 const discoverSource = useDiscoverSource();
592 const connectSource = useConnectSource();594 const connectSource = useConnectSource();
595 const instanceConfig = useInstanceConfig();
593 const secrets = useSecrets();596 const secrets = useSecrets();
594 const refreshSecrets = useRefreshSecrets();597 const refreshSecrets = useRefreshSecrets();
595 const startSourceOAuth = useStartSourceOAuth();598 const startSourceOAuth = useStartSourceOAuth();
@@ -1209,6 +1212,7 @@ export function AddSourcePage() { · 613 unmodified lines
1209 {connectForm.authKind === "bearer" && (1212 {connectForm.authKind === "bearer" && (
1210 <Field label="Token" className="sm:col-span-2">1213 <Field label="Token" className="sm:col-span-2">
1211 <SecretOrTokenInput1214 <SecretOrTokenInput
1215 instanceConfig={instanceConfig}
1212 secrets={secrets}1216 secrets={secrets}
1213 providerId={connectForm.bearerProviderId}1217 providerId={connectForm.bearerProviderId}
1214 handle={connectForm.bearerHandle}1218 handle={connectForm.bearerHandle}
@@ -1408,6 +1412,7 @@ const CREATE_NEW_VALUE = "__create_new__"; · 193 unmodified lines
1408const INLINE_TOKEN_VALUE = "__inline_token__";1412const INLINE_TOKEN_VALUE = "__inline_token__";
1409 1413
1410function SecretOrTokenInput(props: {1414function SecretOrTokenInput(props: {
1415 instanceConfig: Loadable<InstanceConfig>;
1411 secrets: Loadable<ReadonlyArray<SecretListItem>>;1416 secrets: Loadable<ReadonlyArray<SecretListItem>>;
1412 providerId: string;1417 providerId: string;
1413 handle: string;1418 handle: string;
@@ -1415,13 +1420,35 @@ function SecretOrTokenInput(props: { · 1 unmodified line
1415 onSelectSecret: (providerId: string, handle: string) => void;1420 onSelectSecret: (providerId: string, handle: string) => void;
1416 onChangeToken: (token: string) => void;1421 onChangeToken: (token: string) => void;
1417}) {1422}) {
1418 const { secrets, providerId, handle, inlineToken, onSelectSecret, onChangeToken } = props;1423 const {
1424 instanceConfig,
1425 secrets,
1426 providerId,
1427 handle,
1428 inlineToken,
1429 onSelectSecret,
1430 onChangeToken,
1431 } = props;
1419 const createSecret = useCreateSecret();1432 const createSecret = useCreateSecret();
1420 const [showCreate, setShowCreate] = useState(false);1433 const [showCreate, setShowCreate] = useState(false);
1421 const [useInline, setUseInline] = useState(false);1434 const [useInline, setUseInline] = useState(false);
1422 const [newName, setNewName] = useState("");1435 const [newName, setNewName] = useState("");
1423 const [newValue, setNewValue] = useState("");1436 const [newValue, setNewValue] = useState("");
1437 const [newProviderId, setNewProviderId] = useState("");
1424 const [createError, setCreateError] = useState<string | null>(null);1438 const [createError, setCreateError] = useState<string | null>(null);
1439 const storableProviders =
1440 instanceConfig.status === "ready"
1441 ? instanceConfig.data.secretProviders.filter((provider) => provider.canStore)
1442 : [];
1443
1444 useEffect(() => {
1445 if (newProviderId.length > 0) {
1446 return;
1447 }
1448 if (instanceConfig.status === "ready") {
1449 setNewProviderId(instanceConfig.data.defaultSecretStoreProvider);
1450 }
1451 }, [instanceConfig, newProviderId]);
1425 1452
1426 const selectedValue = showCreate ? CREATE_NEW_VALUE : useInline ? INLINE_TOKEN_VALUE : (handle || "");1453 const selectedValue = showCreate ? CREATE_NEW_VALUE : useInline ? INLINE_TOKEN_VALUE : (handle || "");
1427 1454
@@ -1431,6 +1458,11 @@ function SecretOrTokenInput(props: { · 3 unmodified lines
1431 setUseInline(false);1458 setUseInline(false);
1432 setNewName("");1459 setNewName("");
1433 setNewValue("");1460 setNewValue("");
1461 setNewProviderId(
1462 instanceConfig.status === "ready"
1463 ? instanceConfig.data.defaultSecretStoreProvider
1464 : "",
1465 );
1434 setCreateError(null);1466 setCreateError(null);
1435 return;1467 return;
1436 }1468 }
@@ -1448,7 +1480,10 @@ function SecretOrTokenInput(props: { · 11 unmodified lines
1448 }1480 }
1449 setUseInline(false);1481 setUseInline(false);
1450 setShowCreate(false);1482 setShowCreate(false);
1451 onSelectSecret("postgres", value);1483 const matchedSecret = secrets.status === "ready"
1484 ? secrets.data.find((secret) => secret.id === value)
1485 : null;
1486 onSelectSecret(matchedSecret?.providerId ?? "local", value);
1452 };1487 };
1453 1488
1454 const handleCreate = async () => {1489 const handleCreate = async () => {
@@ -1464,7 +1499,11 @@ function SecretOrTokenInput(props: { · 9 unmodified lines
1464 }1499 }
1465 1500
1466 try {1501 try {
1467 const result = await createSecret.mutateAsync({ name: trimmedName, value: newValue });1502 const result = await createSecret.mutateAsync({
1503 name: trimmedName,
1504 value: newValue,
1505 ...(newProviderId ? { providerId: newProviderId } : {}),
1506 });
1468 onSelectSecret(result.providerId, result.id);1507 onSelectSecret(result.providerId, result.id);
1469 setShowCreate(false);1508 setShowCreate(false);
1470 } catch (err) {1509 } catch (err) {
@@ -1495,7 +1534,7 @@ function SecretOrTokenInput(props: { · 24 unmodified lines
1495 <option value="">Select a secret...</option>1534 <option value="">Select a secret...</option>
1496 {items.map((secret) => (1535 {items.map((secret) => (
1497 <option key={secret.id} value={secret.id}>1536 <option key={secret.id} value={secret.id}>
1498 {secret.name || secret.id}1537 {secret.name || secret.id} ({secret.providerId})
1499 </option>1538 </option>
1500 ))}1539 ))}
1501 <option value={INLINE_TOKEN_VALUE}>Paste token directly</option>1540 <option value={INLINE_TOKEN_VALUE}>Paste token directly</option>
@@ -1541,6 +1580,20 @@ function SecretOrTokenInput(props: { · 39 unmodified lines
1541 className="h-8 w-full rounded-lg border border-input bg-background px-3 font-mono text-[11px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25"1580 className="h-8 w-full rounded-lg border border-input bg-background px-3 font-mono text-[11px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25"
1542 />1581 />
1543 </label>1582 </label>
1583 <label className="block space-y-1">
1584 <span className="text-[11px] font-medium text-muted-foreground">Store in</span>
1585 <select
1586 value={newProviderId}
1587 onChange={(e) => setNewProviderId(e.target.value)}
1588 className="h-8 w-full rounded-lg border border-input bg-background px-3 text-[12px] text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
1589 >
1590 {storableProviders.map((provider) => (
1591 <option key={provider.id} value={provider.id}>
1592 {provider.name}
1593 </option>
1594 ))}
1595 </select>
1596 </label>
1544 </div>1597 </div>
1545 <div className="flex items-center justify-end gap-2">1598 <div className="flex items-center justify-end gap-2">
1546 <button1599 <button
apps/web/src/views/secrets.tsx+68−7
@@ -1,13 +1,14 @@
1import { useState, type ReactNode } from "react";1import { useEffect, useState, type ReactNode } from "react";
2import { Link } from "@tanstack/react-router";2import { Link } from "@tanstack/react-router";
3import {3import {
4 type InstanceConfig,
4 type SecretListItem,5 type SecretListItem,
5 type Loadable,6 type Loadable,
6 useSecrets,7 useSecrets,
7 useCreateSecret,8 useCreateSecret,
8 useUpdateSecret,9 useUpdateSecret,
9 useDeleteSecret,10 useDeleteSecret,
10 useRefreshSecrets,11 useInstanceConfig,
11} from "@executor/react";12} from "@executor/react";
12import { LoadableBlock } from "../components/loadable";13import { LoadableBlock } from "../components/loadable";
13import { Button } from "../components/ui/button";14import { Button } from "../components/ui/button";
@@ -19,10 +20,26 @@ import { cn } from "../lib/utils"; · 5 unmodified lines
19// Page20// Page
20// ---------------------------------------------------------------------------21// ---------------------------------------------------------------------------
21 22
23const storableProvidersFromConfig = (config: Loadable<InstanceConfig>) =>
24 config.status === "ready"
25 ? config.data.secretProviders.filter((provider) => provider.canStore)
26 : [];
27
28const providerLabelForId = (config: Loadable<InstanceConfig>, providerId: string): string =>
29 config.status === "ready"
30 ? config.data.secretProviders.find((provider) => provider.id === providerId)?.name ?? providerId
31 : providerId;
32
22export function SecretsPage() {33export function SecretsPage() {
34 const instanceConfig = useInstanceConfig();
23 const secrets = useSecrets();35 const secrets = useSecrets();
24 const [showCreate, setShowCreate] = useState(false);36 const [showCreate, setShowCreate] = useState(false);
25 const [editingId, setEditingId] = useState<string | null>(null);37 const [editingId, setEditingId] = useState<string | null>(null);
38 const storableProviders = storableProvidersFromConfig(instanceConfig);
39 const defaultProviderLabel =
40 instanceConfig.status === "ready"
41 ? providerLabelForId(instanceConfig, instanceConfig.data.defaultSecretStoreProvider)
42 : null;
26 43
27 return (44 return (
28 <div className="flex-1 overflow-y-auto">45 <div className="flex-1 overflow-y-auto">
@@ -34,7 +51,8 @@ export function SecretsPage() { · 5 unmodified lines
34 Secrets51 Secrets
35 </h1>52 </h1>
36 <p className="mt-1.5 text-[14px] text-muted-foreground">53 <p className="mt-1.5 text-[14px] text-muted-foreground">
37 Tokens and credentials stored in this instance.54 Tokens and credentials stored by executor.
55 {defaultProviderLabel ? ` New secrets default to ${defaultProviderLabel}.` : ""}
38 </p>56 </p>
39 </div>57 </div>
40 <Button size="sm" onClick={() => { setShowCreate(true); setEditingId(null); }}>58 <Button size="sm" onClick={() => { setShowCreate(true); setEditingId(null); }}>
@@ -48,6 +66,12 @@ export function SecretsPage() { · 7 unmodified lines
48 <CreateSecretForm66 <CreateSecretForm
49 onClose={() => setShowCreate(false)}67 onClose={() => setShowCreate(false)}
50 className="mb-6"68 className="mb-6"
69 providerOptions={storableProviders}
70 defaultProviderId={
71 instanceConfig.status === "ready"
72 ? instanceConfig.data.defaultSecretStoreProvider
73 : null
74 }
51 />75 />
52 )}76 )}
53 77
@@ -62,6 +86,7 @@ export function SecretsPage() { · 8 unmodified lines
62 <SecretRow86 <SecretRow
63 key={secret.id}87 key={secret.id}
64 secret={secret}88 secret={secret}
89 providerLabel={providerLabelForId(instanceConfig, secret.providerId)}
65 isEditing={editingId === secret.id}90 isEditing={editingId === secret.id}
66 onEdit={() => setEditingId(editingId === secret.id ? null : secret.id)}91 onEdit={() => setEditingId(editingId === secret.id ? null : secret.id)}
67 onCancelEdit={() => setEditingId(null)}92 onCancelEdit={() => setEditingId(null)}
@@ -95,7 +120,7 @@ function EmptyState(props: { onAdd: () => void }) { · 27 unmodified lines
95 No secrets stored120 No secrets stored
96 </p>121 </p>
97 <p className="text-[13px] text-muted-foreground/60 mb-5">122 <p className="text-[13px] text-muted-foreground/60 mb-5">
98 Secrets let you securely provide tokens for source authentication.123 Secrets let you securely provide tokens for source authentication without putting them in workspace config.
99 </p>124 </p>
100 <Button size="sm" onClick={props.onAdd}>125 <Button size="sm" onClick={props.onAdd}>
101 <IconPlus className="size-3.5" />126 <IconPlus className="size-3.5" />
@@ -109,12 +134,27 @@ function EmptyState(props: { onAdd: () => void }) { · 7 unmodified lines
109// Create form134// Create form
110// ---------------------------------------------------------------------------135// ---------------------------------------------------------------------------
111 136
112function CreateSecretForm(props: { onClose: () => void; className?: string }) {137function CreateSecretForm(props: {
138 onClose: () => void;
139 className?: string;
140 providerOptions: ReadonlyArray<{ id: string; name: string }>;
141 defaultProviderId: string | null;
142}) {
113 const createSecret = useCreateSecret();143 const createSecret = useCreateSecret();
114 const [name, setName] = useState("");144 const [name, setName] = useState("");
115 const [value, setValue] = useState("");145 const [value, setValue] = useState("");
146 const [providerId, setProviderId] = useState(props.defaultProviderId ?? "");
116 const [error, setError] = useState<string | null>(null);147 const [error, setError] = useState<string | null>(null);
117 148
149 useEffect(() => {
150 if (providerId.length > 0) {
151 return;
152 }
153 if (props.defaultProviderId) {
154 setProviderId(props.defaultProviderId);
155 }
156 }, [props.defaultProviderId, providerId]);
157
118 const handleSubmit = async () => {158 const handleSubmit = async () => {
119 setError(null);159 setError(null);
120 const trimmedName = name.trim();160 const trimmedName = name.trim();
@@ -128,7 +168,11 @@ function CreateSecretForm(props: { onClose: () => void; className?: string }) { · 7 unmodified lines
128 }168 }
129 169
130 try {170 try {
131 await createSecret.mutateAsync({ name: trimmedName, value });171 await createSecret.mutateAsync({
172 name: trimmedName,
173 value,
174 ...(providerId ? { providerId } : {}),
175 });
132 props.onClose();176 props.onClose();
133 } catch (err) {177 } catch (err) {
134 setError(err instanceof Error ? err.message : "Failed creating secret.");178 setError(err instanceof Error ? err.message : "Failed creating secret.");
@@ -172,6 +216,19 @@ function CreateSecretForm(props: { onClose: () => void; className?: string }) { · 37 unmodified lines
172 className="h-9 w-full rounded-lg border border-input bg-background px-3 font-mono text-[12px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25"216 className="h-9 w-full rounded-lg border border-input bg-background px-3 font-mono text-[12px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25"
173 />217 />
174 </FieldLabel>218 </FieldLabel>
219 <FieldLabel label="Store in">
220 <select
221 value={providerId}
222 onChange={(e) => setProviderId(e.target.value)}
223 className="h-9 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
224 >
225 {props.providerOptions.map((provider) => (
226 <option key={provider.id} value={provider.id}>
227 {provider.name}
228 </option>
229 ))}
230 </select>
231 </FieldLabel>
175 </div>232 </div>
176 <div className="flex justify-end">233 <div className="flex justify-end">
177 <Button234 <Button
@@ -194,11 +251,12 @@ function CreateSecretForm(props: { onClose: () => void; className?: string }) { · 16 unmodified lines
194 251
195function SecretRow(props: {252function SecretRow(props: {
196 secret: SecretListItem;253 secret: SecretListItem;
254 providerLabel: string;
197 isEditing: boolean;255 isEditing: boolean;
198 onEdit: () => void;256 onEdit: () => void;
199 onCancelEdit: () => void;257 onCancelEdit: () => void;
200}) {258}) {
201 const { secret, isEditing } = props;259 const { secret, providerLabel, isEditing } = props;
202 const deleteSecret = useDeleteSecret();260 const deleteSecret = useDeleteSecret();
203 const [isDeleting, setIsDeleting] = useState(false);261 const [isDeleting, setIsDeleting] = useState(false);
204 262
@@ -239,6 +297,9 @@ function SecretRow(props: { · 34 unmodified lines
239 <Badge variant="outline" className="text-[9px] shrink-0">297 <Badge variant="outline" className="text-[9px] shrink-0">
240 {secret.purpose.replace(/_/g, " ")}298 {secret.purpose.replace(/_/g, " ")}
241 </Badge>299 </Badge>
300 <Badge variant="outline" className="text-[9px] shrink-0">
301 {providerLabel}
302 </Badge>
242 </div>303 </div>
243 <div className="flex items-center gap-2 mt-0.5">304 <div className="flex items-center gap-2 mt-0.5">
244 <span className="text-[11px] font-mono text-muted-foreground/50 truncate">305 <span className="text-[11px] font-mono text-muted-foreground/50 truncate">
apps/web/src/views/source-editor.tsx+50−7
@@ -1,4 +1,4 @@
1import { useState, type ReactNode } from "react";1import { useEffect, useState, type ReactNode } from "react";
2import { Link, useNavigate } from "@tanstack/react-router";2import { Link, useNavigate } from "@tanstack/react-router";
3import {3import {
4 type CompleteSourceOAuthResult,4 type CompleteSourceOAuthResult,
@@ -1012,6 +1012,7 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 1007 unmodified lines
1012 {formState.authKind === "bearer" && (1012 {formState.authKind === "bearer" && (
1013 <Field label="Token" className="sm:col-span-2">1013 <Field label="Token" className="sm:col-span-2">
1014 <SecretPicker1014 <SecretPicker
1015 instanceConfig={instanceConfig}
1015 secrets={secrets}1016 secrets={secrets}
1016 providerId={formState.bearerProviderId}1017 providerId={formState.bearerProviderId}
1017 handle={formState.bearerHandle}1018 handle={formState.bearerHandle}
@@ -1030,6 +1031,7 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 12 unmodified lines
1030 <>1031 <>
1031 <Field label="Access token" className="sm:col-span-2">1032 <Field label="Access token" className="sm:col-span-2">
1032 <SecretPicker1033 <SecretPicker
1034 instanceConfig={instanceConfig}
1033 secrets={secrets}1035 secrets={secrets}
1034 providerId={formState.oauthAccessProviderId}1036 providerId={formState.oauthAccessProviderId}
1035 handle={formState.oauthAccessHandle}1037 handle={formState.oauthAccessHandle}
@@ -1041,6 +1043,7 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 5 unmodified lines
1041 </Field>1043 </Field>
1042 <Field label="Refresh token (optional)" className="sm:col-span-2">1044 <Field label="Refresh token (optional)" className="sm:col-span-2">
1043 <SecretPicker1045 <SecretPicker
1046 instanceConfig={instanceConfig}
1044 secrets={secrets}1047 secrets={secrets}
1045 providerId={formState.oauthRefreshProviderId}1048 providerId={formState.oauthRefreshProviderId}
1046 handle={formState.oauthRefreshHandle}1049 handle={formState.oauthRefreshHandle}
@@ -1218,18 +1221,33 @@ function StatusBanner(props: { state: StatusBannerState; className?: string }) { · 171 unmodified lines
1218const CREATE_NEW_VALUE = "__create_new__";1221const CREATE_NEW_VALUE = "__create_new__";
1219 1222
1220function SecretPicker(props: {1223function SecretPicker(props: {
1224 instanceConfig: Loadable<InstanceConfig>;
1221 secrets: Loadable<ReadonlyArray<SecretListItem>>;1225 secrets: Loadable<ReadonlyArray<SecretListItem>>;
1222 providerId: string;1226 providerId: string;
1223 handle: string;1227 handle: string;
1224 onSelect: (providerId: string, handle: string) => void;1228 onSelect: (providerId: string, handle: string) => void;
1225 allowEmpty?: boolean;1229 allowEmpty?: boolean;
1226}) {1230}) {
1227 const { secrets, providerId, handle, onSelect, allowEmpty } = props;1231 const { instanceConfig, secrets, providerId, handle, onSelect, allowEmpty } = props;
1228 const createSecret = useCreateSecret();1232 const createSecret = useCreateSecret();
1229 const [showCreate, setShowCreate] = useState(false);1233 const [showCreate, setShowCreate] = useState(false);
1230 const [newName, setNewName] = useState("");1234 const [newName, setNewName] = useState("");
1231 const [newValue, setNewValue] = useState("");1235 const [newValue, setNewValue] = useState("");
1236 const [newProviderId, setNewProviderId] = useState("");
1232 const [createError, setCreateError] = useState<string | null>(null);1237 const [createError, setCreateError] = useState<string | null>(null);
1238 const storableProviders =
1239 instanceConfig.status === "ready"
1240 ? instanceConfig.data.secretProviders.filter((provider) => provider.canStore)
1241 : [];
1242
1243 useEffect(() => {
1244 if (newProviderId.length > 0) {
1245 return;
1246 }
1247 if (instanceConfig.status === "ready") {
1248 setNewProviderId(instanceConfig.data.defaultSecretStoreProvider);
1249 }
1250 }, [instanceConfig, newProviderId]);
1233 1251
1234 // Build the selected value key: if handle is set, use it (it's the secret ID)1252 // Build the selected value key: if handle is set, use it (it's the secret ID)
1235 const selectedValue = handle || "";1253 const selectedValue = handle || "";
@@ -1239,6 +1257,11 @@ function SecretPicker(props: { · 3 unmodified lines
1239 setShowCreate(true);1257 setShowCreate(true);
1240 setNewName("");1258 setNewName("");
1241 setNewValue("");1259 setNewValue("");
1260 setNewProviderId(
1261 instanceConfig.status === "ready"
1262 ? instanceConfig.data.defaultSecretStoreProvider
1263 : "",
1264 );
1242 setCreateError(null);1265 setCreateError(null);
1243 return;1266 return;
1244 }1267 }
@@ -1246,8 +1269,10 @@ function SecretPicker(props: { · 1 unmodified line
1246 onSelect("", "");1269 onSelect("", "");
1247 return;1270 return;
1248 }1271 }
1249 // Selecting an existing secret: providerId is always "postgres"1272 const matchedSecret = secrets.status === "ready"
1250 onSelect("postgres", value);1273 ? secrets.data.find((secret) => secret.id === value)
1274 : null;
1275 onSelect(matchedSecret?.providerId ?? "local", value);
1251 };1276 };
1252 1277
1253 const handleCreate = async () => {1278 const handleCreate = async () => {
@@ -1263,7 +1288,11 @@ function SecretPicker(props: { · 9 unmodified lines
1263 }1288 }
1264 1289
1265 try {1290 try {
1266 const result = await createSecret.mutateAsync({ name: trimmedName, value: newValue });1291 const result = await createSecret.mutateAsync({
1292 name: trimmedName,
1293 value: newValue,
1294 ...(newProviderId ? { providerId: newProviderId } : {}),
1295 });
1267 onSelect(result.providerId, result.id);1296 onSelect(result.providerId, result.id);
1268 setShowCreate(false);1297 setShowCreate(false);
1269 } catch (err) {1298 } catch (err) {
@@ -1286,7 +1315,7 @@ function SecretPicker(props: { · 16 unmodified lines
1286 1315
1287 // Check if the current handle matches a known secret1316 // Check if the current handle matches a known secret
1288 const matchedSecret = items.find((s) => s.id === handle);1317 const matchedSecret = items.find((s) => s.id === handle);
1289 const isExternalRef = handle && !matchedSecret && providerId !== "postgres";1318 const isExternalRef = handle && !matchedSecret && providerId !== "local";
1290 1319
1291 return (1320 return (
1292 <div className="space-y-2">1321 <div className="space-y-2">
@@ -1299,7 +1328,7 @@ function SecretPicker(props: { · 6 unmodified lines
1299 {!allowEmpty && !selectedValue && <option value="">Select a secret…</option>}1328 {!allowEmpty && !selectedValue && <option value="">Select a secret…</option>}
1300 {items.map((secret) => (1329 {items.map((secret) => (
1301 <option key={secret.id} value={secret.id}>1330 <option key={secret.id} value={secret.id}>
1302 {secret.name || secret.id}1331 {secret.name || secret.id} ({secret.providerId})
1303 </option>1332 </option>
1304 ))}1333 ))}
1305 {isExternalRef && (1334 {isExternalRef && (
@@ -1338,6 +1367,20 @@ function SecretPicker(props: { · 32 unmodified lines
1338 className="h-8 w-full rounded-lg border border-input bg-background px-3 font-mono text-[11px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25"1367 className="h-8 w-full rounded-lg border border-input bg-background px-3 font-mono text-[11px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25"
1339 />1368 />
1340 </label>1369 </label>
1370 <label className="block space-y-1">
1371 <span className="text-[11px] font-medium text-muted-foreground">Store in</span>
1372 <select
1373 value={newProviderId}
1374 onChange={(e) => setNewProviderId(e.target.value)}
1375 className="h-8 w-full rounded-lg border border-input bg-background px-3 text-[12px] text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
1376 >
1377 {storableProviders.map((provider) => (
1378 <option key={provider.id} value={provider.id}>
1379 {provider.name}
1380 </option>
1381 ))}
1382 </select>
1383 </label>
1341 </div>1384 </div>
1342 <div className="flex items-center justify-end gap-2">1385 <div className="flex items-center justify-end gap-2">
1343 <button1386 <button
packages/control-plane/src/api/local/api.ts+3−0
@@ -34,6 +34,7 @@ export type SecretLinkedSource = typeof SecretLinkedSourceSchema.Type; · 33 unmodified lines
34 34
35export const SecretListItemSchema = Schema.Struct({35export const SecretListItemSchema = Schema.Struct({
36 id: Schema.String,36 id: Schema.String,
37 providerId: Schema.String,
37 name: Schema.NullOr(Schema.String),38 name: Schema.NullOr(Schema.String),
38 purpose: Schema.String,39 purpose: Schema.String,
39 createdAt: Schema.Number,40 createdAt: Schema.Number,
@@ -47,6 +48,7 @@ export const CreateSecretPayloadSchema = Schema.Struct({ · 7 unmodified lines
47 name: Schema.String,48 name: Schema.String,
48 value: Schema.String,49 value: Schema.String,
49 purpose: Schema.optional(SecretMaterialPurposeSchema),50 purpose: Schema.optional(SecretMaterialPurposeSchema),
51 providerId: Schema.optional(Schema.String),
50});52});
51 53
52export type CreateSecretPayload = typeof CreateSecretPayloadSchema.Type;54export type CreateSecretPayload = typeof CreateSecretPayloadSchema.Type;
@@ -71,6 +73,7 @@ export type UpdateSecretPayload = typeof UpdateSecretPayloadSchema.Type; · 18 unmodified lines
71 73
72export const UpdateSecretResultSchema = Schema.Struct({74export const UpdateSecretResultSchema = Schema.Struct({
73 id: Schema.String,75 id: Schema.String,
76 providerId: Schema.String,
74 name: Schema.NullOr(Schema.String),77 name: Schema.NullOr(Schema.String),
75 purpose: Schema.String,78 purpose: Schema.String,
76 createdAt: Schema.Number,79 createdAt: Schema.Number,
packages/control-plane/src/api/local/http.ts+89−48
@@ -3,7 +3,6 @@ import { · 2 unmodified lines
3} from "@effect/platform";3} from "@effect/platform";
4import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
5import * as Option from "effect/Option";5import * as Option from "effect/Option";
6import { randomUUID } from "node:crypto";
7 6
8import { SecretMaterialIdSchema } from "#schema";7import { SecretMaterialIdSchema } from "#schema";
9import {8import {
@@ -11,10 +10,14 @@ import { · 1 unmodified line
11} from "../../runtime/local-operations";10} from "../../runtime/local-operations";
12import { requireRuntimeLocalWorkspace } from "../../runtime/local-runtime-context";11import { requireRuntimeLocalWorkspace } from "../../runtime/local-runtime-context";
13import {12import {
13 createDefaultSecretMaterialDeleter,
14 createDefaultSecretMaterialStorer,
15 createDefaultSecretMaterialUpdater,
14 ENV_SECRET_PROVIDER_ID,16 ENV_SECRET_PROVIDER_ID,
15 KEYCHAIN_SECRET_PROVIDER_ID,17 KEYCHAIN_SECRET_PROVIDER_ID,
16 LOCAL_SECRET_PROVIDER_ID,18 LOCAL_SECRET_PROVIDER_ID,
17 parseSecretStoreProviderId,19 parseSecretStoreProviderId,
20 resolveDefaultSecretStoreProviderId,
18} from "../../runtime/secret-material-providers";21} from "../../runtime/secret-material-providers";
19import { RuntimeSourceStoreService } from "../../runtime/source-store";22import { RuntimeSourceStoreService } from "../../runtime/source-store";
20import { ControlPlaneStore } from "../../runtime/store";23import { ControlPlaneStore } from "../../runtime/store";
@@ -35,6 +38,8 @@ import { · 14 unmodified lines
35const SECRET_STORE_PROVIDER_ENV = "EXECUTOR_SECRET_STORE_PROVIDER";38const SECRET_STORE_PROVIDER_ENV = "EXECUTOR_SECRET_STORE_PROVIDER";
36 39
37const getInstanceConfig = (): Effect.Effect<InstanceConfig> => {40const getInstanceConfig = (): Effect.Effect<InstanceConfig> => {
41 const explicitDefaultStoreProvider =
42 parseSecretStoreProviderId(process.env[SECRET_STORE_PROVIDER_ENV]);
38 const providers: SecretProvider[] = [43 const providers: SecretProvider[] = [
39 {44 {
40 id: LOCAL_SECRET_PROVIDER_ID,45 id: LOCAL_SECRET_PROVIDER_ID,
@@ -47,7 +52,9 @@ const getInstanceConfig = (): Effect.Effect<InstanceConfig> => { · 6 unmodified lines
47 providers.push({52 providers.push({
48 id: KEYCHAIN_SECRET_PROVIDER_ID,53 id: KEYCHAIN_SECRET_PROVIDER_ID,
49 name: process.platform === "darwin" ? "macOS Keychain" : "Desktop Keyring",54 name: process.platform === "darwin" ? "macOS Keychain" : "Desktop Keyring",
50 canStore: true,55 canStore:
56 process.platform === "darwin"
57 || explicitDefaultStoreProvider === KEYCHAIN_SECRET_PROVIDER_ID,
51 });58 });
52 }59 }
53 60
@@ -57,15 +64,15 @@ const getInstanceConfig = (): Effect.Effect<InstanceConfig> => { · 3 unmodified lines
57 canStore: false,64 canStore: false,
58 });65 });
59 66
60 const defaultStoreProvider =67 return resolveDefaultSecretStoreProviderId({
61 parseSecretStoreProviderId(process.env[SECRET_STORE_PROVIDER_ENV])68 storeProviderId: explicitDefaultStoreProvider ?? undefined,
62 ?? LOCAL_SECRET_PROVIDER_ID;69 }).pipe(
63 70 Effect.map((resolvedDefaultStoreProvider) => ({
64 return Effect.succeed({71 platform: process.platform,
65 platform: process.platform,72 secretProviders: providers,
66 secretProviders: providers,73 defaultSecretStoreProvider: resolvedDefaultStoreProvider,
67 defaultSecretStoreProvider: defaultStoreProvider,74 })),
68 });75 );
69};76};
70 77
71const storageError = (message: string) =>78const storageError = (message: string) =>
@@ -105,16 +112,19 @@ export const ControlPlaneLocalLive = HttpApiBuilder.group( · 33 unmodified lines
105 Effect.mapError(() => storageError("Failed loading linked sources.")),112 Effect.mapError(() => storageError("Failed loading linked sources.")),
106 );113 );
107 return rows.map((row) => ({114 return rows.map((row) => ({
108 ...row,115 ...row,
109 linkedSources: linkedSourcesMap.get(row.id) ?? [],116 linkedSources: linkedSourcesMap.get(row.id) ?? [],
110 }));117 }));
111 }),118 }),
112 )119 )
113 .handle("createSecret", ({ payload }) =>120 .handle("createSecret", ({ payload }) =>
114 Effect.gen(function* () {121 Effect.gen(function* () {
115 const name = payload.name.trim();122 const name = payload.name.trim();
116 const value = payload.value;123 const value = payload.value;
117 const purpose = payload.purpose ?? "auth_material";124 const purpose = payload.purpose ?? "auth_material";
125 const requestedProviderId = payload.providerId === undefined
126 ? null
127 : parseSecretStoreProviderId(payload.providerId);
118 128
119 if (name.length === 0) {129 if (name.length === 0) {
120 return yield* Effect.fail(130 return yield* Effect.fail(
@@ -125,29 +135,46 @@ export const ControlPlaneLocalLive = HttpApiBuilder.group( · 4 unmodified lines
125 }),135 }),
126 );136 );
127 }137 }
138 if (payload.providerId !== undefined && requestedProviderId === null) {
139 return yield* Effect.fail(
140 new ControlPlaneBadRequestError({
141 operation: "secrets.create",
142 message: `Unsupported secret provider: ${payload.providerId}`,
143 details: `Unsupported secret provider: ${payload.providerId}`,
144 }),
145 );
146 }
128 147
129 const store = yield* ControlPlaneStore;148 const store = yield* ControlPlaneStore;
130 const now = Date.now();149 const storeSecretMaterial = createDefaultSecretMaterialStorer({
131 const id = SecretMaterialIdSchema.make(`sec_${randomUUID()}`);150 rows: store,
132 151 ...(requestedProviderId ? { storeProviderId: requestedProviderId } : {}),
133 yield* store.secretMaterials.upsert({152 });
134 id,153 const ref = yield* storeSecretMaterial({
135 name,154 name,
136 purpose,155 purpose,
137 value,156 value,
138 createdAt: now,
139 updatedAt: now,
140 }).pipe(157 }).pipe(
141 Effect.mapError(() => storageError("Failed creating secret.")),158 Effect.mapError((cause) => storageError(
159 cause instanceof Error ? cause.message : "Failed creating secret.",
160 )),
161 );
162 const secretId = SecretMaterialIdSchema.make(ref.handle);
163 const created = yield* store.secretMaterials.getById(secretId).pipe(
164 Effect.mapError(() => storageError("Failed loading created secret.")),
142 );165 );
143 166
167 if (Option.isNone(created)) {
168 return yield* Effect.fail(storageError(`Created secret not found: ${ref.handle}`));
169 }
170
144 return {171 return {
145 id,172 id: created.value.id,
146 name,173 name: created.value.name,
147 providerId: LOCAL_SECRET_PROVIDER_ID,174 providerId: created.value.providerId,
148 purpose,175 purpose: created.value.purpose,
149 createdAt: now,176 createdAt: created.value.createdAt,
150 updatedAt: now,177 updatedAt: created.value.updatedAt,
151 } satisfies CreateSecretResult;178 } satisfies CreateSecretResult;
152 }),179 }),
153 )180 )
@@ -174,26 +201,26 @@ export const ControlPlaneLocalLive = HttpApiBuilder.group( · 20 unmodified lines
174 if (payload.name !== undefined) update.name = payload.name.trim() || null;201 if (payload.name !== undefined) update.name = payload.name.trim() || null;
175 if (payload.value !== undefined) update.value = payload.value;202 if (payload.value !== undefined) update.value = payload.value;
176 203
177 const updated = yield* store.secretMaterials.updateById(secretId, update).pipe(204 const updateSecretMaterial = createDefaultSecretMaterialUpdater({
205 rows: store,
206 });
207 const updated = yield* updateSecretMaterial({
208 ref: {
209 providerId: existing.value.providerId,
210 handle: existing.value.id,
211 },
212 ...update,
213 }).pipe(
178 Effect.mapError(() => storageError("Failed updating secret.")),214 Effect.mapError(() => storageError("Failed updating secret.")),
179 );215 );
180 216
181 if (Option.isNone(updated)) {
182 return yield* Effect.fail(
183 new ControlPlaneNotFoundError({
184 operation: "secrets.update",
185 message: `Secret not found after update: ${path.secretId}`,
186 details: `Secret not found after update: ${path.secretId}`,
187 }),
188 );
189 }
190
191 return {217 return {
192 id: updated.value.id,218 id: updated.id,
193 name: updated.value.name,219 providerId: updated.providerId,
194 purpose: updated.value.purpose,220 name: updated.name,
195 createdAt: updated.value.createdAt,221 purpose: updated.purpose,
196 updatedAt: updated.value.updatedAt,222 createdAt: updated.createdAt,
223 updatedAt: updated.updatedAt,
197 } satisfies UpdateSecretResult;224 } satisfies UpdateSecretResult;
198 }),225 }),
199 )226 )
@@ -202,11 +229,11 @@ export const ControlPlaneLocalLive = HttpApiBuilder.group( · 2 unmodified lines
202 const secretId = SecretMaterialIdSchema.make(path.secretId);229 const secretId = SecretMaterialIdSchema.make(path.secretId);
203 const store = yield* ControlPlaneStore;230 const store = yield* ControlPlaneStore;
204 231
205 const removed = yield* store.secretMaterials.removeById(secretId).pipe(232 const existing = yield* store.secretMaterials.getById(secretId).pipe(
206 Effect.mapError(() => storageError("Failed removing secret.")),233 Effect.mapError(() => storageError("Failed looking up secret.")),
207 );234 );
208 235
209 if (!removed) {236 if (Option.isNone(existing)) {
210 return yield* Effect.fail(237 return yield* Effect.fail(
211 new ControlPlaneNotFoundError({238 new ControlPlaneNotFoundError({
212 operation: "secrets.delete",239 operation: "secrets.delete",
@@ -216,6 +243,20 @@ export const ControlPlaneLocalLive = HttpApiBuilder.group( · 3 unmodified lines
216 );243 );
217 }244 }
218 245
246 const deleteSecretMaterial = createDefaultSecretMaterialDeleter({
247 rows: store,
248 });
249 const removed = yield* deleteSecretMaterial({
250 providerId: existing.value.providerId,
251 handle: existing.value.id,
252 }).pipe(
253 Effect.mapError(() => storageError("Failed removing secret.")),
254 );
255
256 if (!removed) {
257 return yield* Effect.fail(storageError(`Failed removing secret: ${path.secretId}`));
258 }
259
219 return { removed: true };260 return { removed: true };
220 }),261 }),
221 ),262 ),
packages/control-plane/src/runtime/control-plane-runtime.test.ts+88−0
@@ -197,6 +197,94 @@ describe("control-plane-runtime", () => { · 196 unmodified lines
197 60_000,197 60_000,
198 );198 );
199 199
200 it.scoped("manages local secrets through provider-backed metadata rows", () =>
201 Effect.gen(function* () {
202 const runtime = yield* makeRuntime;
203
204 const created = yield* withControlPlaneClient(
205 { runtime },
206 (client) =>
207 client.local.createSecret({
208 payload: {
209 name: "GitHub PAT",
210 value: "ghp_test_token",
211 providerId: "local",
212 },
213 }),
214 );
215
216 expect(created.providerId).toBe("local");
217
218 const listed = yield* withControlPlaneClient(
219 { runtime },
220 (client) => client.local.listSecrets({}),
221 );
222 expect(listed).toEqual([
223 expect.objectContaining({
224 id: created.id,
225 providerId: "local",
226 name: "GitHub PAT",
227 }),
228 ]);
229
230 const updated = yield* withControlPlaneClient(
231 { runtime },
232 (client) =>
233 client.local.updateSecret({
234 path: { secretId: created.id },
235 payload: {
236 name: "GitHub PAT Updated",
237 value: "ghp_test_token_v2",
238 },
239 }),
240 );
241 expect(updated.providerId).toBe("local");
242 expect(updated.name).toBe("GitHub PAT Updated");
243
244 const storedSecret = yield* runtime.persistence.rows.secretMaterials.getById(
245 SecretMaterialIdSchema.make(created.id),
246 );
247 assertTrue(Option.isSome(storedSecret));
248 expect(storedSecret.value.providerId).toBe("local");
249 expect(storedSecret.value.value).toBe("ghp_test_token_v2");
250
251 const removed = yield* withControlPlaneClient(
252 { runtime },
253 (client) =>
254 client.local.deleteSecret({
255 path: { secretId: created.id },
256 }),
257 );
258 expect(removed.removed).toBe(true);
259 }),
260 );
261
262 it.scoped("reports safe secret storage options for the current platform", () =>
263 Effect.gen(function* () {
264 const runtime = yield* makeRuntime;
265
266 const config = yield* withControlPlaneClient(
267 { runtime },
268 (client) => client.local.config({}),
269 );
270
271 const keychainProvider =
272 config.secretProviders.find((provider) => provider.id === "keychain") ?? null;
273
274 if (process.platform === "linux") {
275 expect(config.defaultSecretStoreProvider).toBe("local");
276 expect(keychainProvider?.canStore).toBe(false);
277 return;
278 }
279
280 if (process.platform === "darwin") {
281 expect(keychainProvider?.canStore).toBe(true);
282 return;
283 }
284
285 expect(keychainProvider).toBeNull();
286 }),
287 );
200 288
201 it.scoped("captures credential requests through the local HTML flow without persisting raw tokens", () =>289 it.scoped("captures credential requests through the local HTML flow without persisting raw tokens", () =>
202 Effect.gen(function* () {290 Effect.gen(function* () {
packages/control-plane/src/runtime/local-control-plane-store.test.ts+48−2
@@ -1,6 +1,6 @@
1import { existsSync, mkdtempSync, statSync } from "node:fs";1import { existsSync, mkdtempSync, mkdirSync, statSync, writeFileSync } from "node:fs";
2import { tmpdir } from "node:os";2import { tmpdir } from "node:os";
3import { join } from "node:path";3import { dirname, join } from "node:path";
4import { describe, expect, it } from "@effect/vitest";4import { describe, expect, it } from "@effect/vitest";
5import * as Effect from "effect/Effect";5import * as Effect from "effect/Effect";
6 6
@@ -62,4 +62,50 @@ describe("local-control-plane-store", () => { · 55 unmodified lines
62 }62 }
63 }),63 }),
64 );64 );
65
66 it.effect("decodes legacy local secret rows without provider metadata", () =>
67 Effect.gen(function* () {
68 const context = makeContext();
69 const expectedPath = localControlPlaneStatePath(context);
70 mkdirSync(dirname(expectedPath), { recursive: true });
71
72 writeFileSync(
73 expectedPath,
74 JSON.stringify({
75 version: 1,
76 authArtifacts: [],
77 authLeases: [],
78 sourceOauthClients: [],
79 sourceAuthSessions: [],
80 secretMaterials: [
81 {
82 id: "sec_legacy",
83 name: "Legacy token",
84 purpose: "auth_material",
85 value: "secret-value",
86 createdAt: 1,
87 updatedAt: 2,
88 },
89 ],
90 executions: [],
91 executionInteractions: [],
92 executionSteps: [],
93 }),
94 );
95
96 const loaded = yield* loadLocalControlPlaneState(context);
97 expect(loaded.secretMaterials).toEqual([
98 {
99 id: "sec_legacy",
100 providerId: "local",
101 handle: "sec_legacy",
102 name: "Legacy token",
103 purpose: "auth_material",
104 value: "secret-value",
105 createdAt: 1,
106 updatedAt: 2,
107 },
108 ]);
109 }),
110 );
65});111});
packages/control-plane/src/runtime/secret-material-providers.ts+621−193
@@ -7,6 +7,7 @@ import { isAbsolute } from "node:path"; · 6 unmodified lines
7import {7import {
8 type LocalConfigSecretProvider,8 type LocalConfigSecretProvider,
9 type LocalExecutorConfig,9 type LocalExecutorConfig,
10 type SecretMaterial,
10 type SecretMaterialPurpose,11 type SecretMaterialPurpose,
11 SecretMaterialIdSchema,12 SecretMaterialIdSchema,
12 type SecretRef,13 type SecretRef,
@@ -50,6 +51,19 @@ export type DeleteSecretMaterial = ( · 37 unmodified lines
50 ref: SecretRef,51 ref: SecretRef,
51) => Effect.Effect<boolean, Error, never>;52) => Effect.Effect<boolean, Error, never>;
52 53
54export type UpdateSecretMaterial = (input: {
55 ref: SecretRef;
56 name?: string | null;
57 value?: string;
58}) => Effect.Effect<{
59 id: string;
60 providerId: string;
61 name: string | null;
62 purpose: string;
63 createdAt: number;
64 updatedAt: number;
65}, Error, never>;
66
53export class SecretMaterialResolverService extends Context.Tag(67export class SecretMaterialResolverService extends Context.Tag(
54 "#runtime/SecretMaterialResolverService",68 "#runtime/SecretMaterialResolverService",
55)<SecretMaterialResolverService, ResolveSecretMaterial>() {}69)<SecretMaterialResolverService, ResolveSecretMaterial>() {}
@@ -62,6 +76,10 @@ export class SecretMaterialDeleterService extends Context.Tag( · 6 unmodified lines
62 "#runtime/SecretMaterialDeleterService",76 "#runtime/SecretMaterialDeleterService",
63)<SecretMaterialDeleterService, DeleteSecretMaterial>() {}77)<SecretMaterialDeleterService, DeleteSecretMaterial>() {}
64 78
79export class SecretMaterialUpdaterService extends Context.Tag(
80 "#runtime/SecretMaterialUpdaterService",
81)<SecretMaterialUpdaterService, UpdateSecretMaterial>() {}
82
65type SecretMaterialProviderRuntime = {83type SecretMaterialProviderRuntime = {
66 rows: ControlPlaneStoreShape;84 rows: ControlPlaneStoreShape;
67 env: NodeJS.ProcessEnv;85 env: NodeJS.ProcessEnv;
@@ -83,6 +101,19 @@ type SecretMaterialProvider = { · 15 unmodified lines
83 name?: string | null;101 name?: string | null;
84 runtime: SecretMaterialProviderRuntime;102 runtime: SecretMaterialProviderRuntime;
85 }) => Effect.Effect<SecretRef, Error, never>;103 }) => Effect.Effect<SecretRef, Error, never>;
104 update?: (input: {
105 ref: SecretRef;
106 name?: string | null;
107 value?: string;
108 runtime: SecretMaterialProviderRuntime;
109 }) => Effect.Effect<{
110 id: string;
111 providerId: string;
112 name: string | null;
113 purpose: string;
114 createdAt: number;
115 updatedAt: number;
116 }, Error, never>;
86 remove?: (input: {117 remove?: (input: {
87 ref: SecretRef;118 ref: SecretRef;
88 runtime: SecretMaterialProviderRuntime;119 runtime: SecretMaterialProviderRuntime;
@@ -98,10 +129,20 @@ type SpawnResult = { · 9 unmodified lines
98};129};
99 130
100const DEFAULT_KEYCHAIN_SERVICE_NAME = "executor";131const DEFAULT_KEYCHAIN_SERVICE_NAME = "executor";
132const KEYCHAIN_COMMAND_TIMEOUT_MS = 5_000;
101const DANGEROUSLY_ALLOW_ENV_SECRETS_ENV = "DANGEROUSLY_ALLOW_ENV_SECRETS";133const DANGEROUSLY_ALLOW_ENV_SECRETS_ENV = "DANGEROUSLY_ALLOW_ENV_SECRETS";
102const SECRET_STORE_PROVIDER_ENV = "EXECUTOR_SECRET_STORE_PROVIDER";134const SECRET_STORE_PROVIDER_ENV = "EXECUTOR_SECRET_STORE_PROVIDER";
103const KEYCHAIN_SERVICE_NAME_ENV = "EXECUTOR_KEYCHAIN_SERVICE_NAME";135const KEYCHAIN_SERVICE_NAME_ENV = "EXECUTOR_KEYCHAIN_SERVICE_NAME";
104 136
137type SecretMaterialSummary = {
138 id: string;
139 providerId: string;
140 name: string | null;
141 purpose: string;
142 createdAt: number;
143 updatedAt: number;
144};
145
105const trimOrNull = (value: string | null | undefined): string | null => {146const trimOrNull = (value: string | null | undefined): string | null => {
106 if (value === null || value === undefined) {147 if (value === null || value === undefined) {
107 return null;148 return null;
@@ -134,11 +175,6 @@ export const parseSecretStoreProviderId = (value: string | undefined): SecretSto · 26 unmodified lines
134const resolveDangerouslyAllowEnvSecrets = (value: boolean | undefined): boolean =>175const resolveDangerouslyAllowEnvSecrets = (value: boolean | undefined): boolean =>
135 value ?? parseBooleanEnv(process.env[DANGEROUSLY_ALLOW_ENV_SECRETS_ENV]);176 value ?? parseBooleanEnv(process.env[DANGEROUSLY_ALLOW_ENV_SECRETS_ENV]);
136 177
137const resolveSecretStoreProviderId = (value: SecretStoreProviderId | undefined): SecretStoreProviderId =>
138 value
139 ?? parseSecretStoreProviderId(process.env[SECRET_STORE_PROVIDER_ENV])
140 ?? LOCAL_SECRET_PROVIDER_ID;
141
142const resolveKeychainServiceName = (value: string | undefined): string =>178const resolveKeychainServiceName = (value: string | undefined): string =>
143 trimOrNull(value)179 trimOrNull(value)
144 ?? trimOrNull(process.env[KEYCHAIN_SERVICE_NAME_ENV])180 ?? trimOrNull(process.env[KEYCHAIN_SERVICE_NAME_ENV])
@@ -149,12 +185,38 @@ const ensureNonEmptyString = (value: string | undefined): string | null => { · 4 unmodified lines
149 return trimmed && trimmed.length > 0 ? trimmed : null;185 return trimmed && trimmed.length > 0 ? trimmed : null;
150};186};
151 187
188const toSecretMaterialSummary = (
189 material: Pick<SecretMaterial, "id" | "providerId" | "name" | "purpose" | "createdAt" | "updatedAt">,
190): SecretMaterialSummary => ({
191 id: material.id,
192 providerId: material.providerId,
193 name: material.name,
194 purpose: material.purpose,
195 createdAt: material.createdAt,
196 updatedAt: material.updatedAt,
197});
198
199const keychainCommandForPlatform = (
200 platform: NodeJS.Platform = process.platform,
201): string | null => {
202 if (platform === "darwin") {
203 return "security";
204 }
205
206 if (platform === "linux") {
207 return "secret-tool";
208 }
209
210 return null;
211};
212
152const runCommand = (input: {213const runCommand = (input: {
153 command: string;214 command: string;
154 args: ReadonlyArray<string>;215 args: ReadonlyArray<string>;
155 stdin?: string;216 stdin?: string;
156 env?: NodeJS.ProcessEnv;217 env?: NodeJS.ProcessEnv;
157 operation: string;218 operation: string;
219 timeoutMs?: number;
158}): Effect.Effect<SpawnResult, Error, never> =>220}): Effect.Effect<SpawnResult, Error, never> =>
159 Effect.tryPromise({221 Effect.tryPromise({
160 try: () =>222 try: () =>
@@ -166,6 +228,22 @@ const runCommand = (input: { · 5 unmodified lines
166 228
167 let stdout = "";229 let stdout = "";
168 let stderr = "";230 let stderr = "";
231 let settled = false;
232 const timeout = input.timeoutMs === undefined
233 ? null
234 : setTimeout(() => {
235 if (settled) {
236 return;
237 }
238
239 settled = true;
240 child.kill("SIGKILL");
241 reject(
242 new Error(
243 `${input.operation}: '${input.command}' timed out after ${input.timeoutMs}ms`,
244 ),
245 );
246 }, input.timeoutMs);
169 247
170 child.stdout.on("data", (chunk) => {248 child.stdout.on("data", (chunk) => {
171 stdout += chunk.toString("utf8");249 stdout += chunk.toString("utf8");
@@ -176,10 +254,24 @@ const runCommand = (input: { · 4 unmodified lines
176 });254 });
177 255
178 child.on("error", (error) => {256 child.on("error", (error) => {
257 if (settled) {
258 return;
259 }
260 settled = true;
261 if (timeout) {
262 clearTimeout(timeout);
263 }
179 reject(new Error(`${input.operation}: failed spawning '${input.command}': ${error.message}`));264 reject(new Error(`${input.operation}: failed spawning '${input.command}': ${error.message}`));
180 });265 });
181 266
182 child.on("close", (code) => {267 child.on("close", (code) => {
268 if (settled) {
269 return;
270 }
271 settled = true;
272 if (timeout) {
273 clearTimeout(timeout);
274 }
183 resolve({275 resolve({
184 exitCode: code ?? 0,276 exitCode: code ?? 0,
185 stdout,277 stdout,
@@ -215,6 +307,79 @@ const ensureCommandSuccess = (input: { · 29 unmodified lines
215 return Effect.fail(new Error(`${input.operation}: ${input.message}: ${details}`));307 return Effect.fail(new Error(`${input.operation}: ${input.message}: ${details}`));
216};308};
217 309
310const commandAvailabilityCache = new Map<string, Promise<boolean>>();
311
312const commandExists = (command: string): Effect.Effect<boolean, Error, never> =>
313 Effect.tryPromise({
314 try: async () => {
315 const cached = commandAvailabilityCache.get(command);
316 if (cached) {
317 return cached;
318 }
319
320 const probe = new Promise<boolean>((resolve) => {
321 const child = spawn(command, ["--help"], {
322 stdio: "ignore",
323 });
324 const timeout = setTimeout(() => {
325 child.kill("SIGKILL");
326 resolve(false);
327 }, 2_000);
328
329 child.on("error", () => {
330 clearTimeout(timeout);
331 resolve(false);
332 });
333 child.on("close", () => {
334 clearTimeout(timeout);
335 resolve(true);
336 });
337 });
338
339 commandAvailabilityCache.set(command, probe);
340 const available = await probe;
341 if (!available) {
342 commandAvailabilityCache.delete(command);
343 }
344 return available;
345 },
346 catch: (cause) =>
347 cause instanceof Error
348 ? cause
349 : new Error(`command.exists: failed checking '${command}': ${String(cause)}`),
350 });
351
352const isKeychainProviderAvailable = (
353 platform: NodeJS.Platform = process.platform,
354): Effect.Effect<boolean, Error, never> => {
355 const command = keychainCommandForPlatform(platform);
356 return command === null ? Effect.succeed(false) : commandExists(command);
357};
358
359export const resolveDefaultSecretStoreProviderId = (input: {
360 storeProviderId?: SecretStoreProviderId;
361 env?: NodeJS.ProcessEnv;
362 platform?: NodeJS.Platform;
363} = {}): Effect.Effect<SecretStoreProviderId, never, never> => {
364 const localProviderId = LOCAL_SECRET_PROVIDER_ID as SecretStoreProviderId;
365 const explicit =
366 input.storeProviderId
367 ?? parseSecretStoreProviderId((input.env ?? process.env)[SECRET_STORE_PROVIDER_ENV]);
368 if (explicit) {
369 return Effect.succeed(explicit);
370 }
371
372 if ((input.platform ?? process.platform) !== "darwin") {
373 return Effect.succeed(localProviderId);
374 }
375
376 return isKeychainProviderAvailable(input.platform).pipe(
377 Effect.map((available): SecretStoreProviderId =>
378 available ? KEYCHAIN_SECRET_PROVIDER_ID : localProviderId),
379 Effect.catchAll(() => Effect.succeed(localProviderId)),
380 ) as Effect.Effect<SecretStoreProviderId, never, never>;
381};
382
218const parseKeychainHandle = (handle: string): string | null => {383const parseKeychainHandle = (handle: string): string | null => {
219 if (!handle.startsWith(`${KEYCHAIN_SECRET_PROVIDER_ID}:`)) {384 if (!handle.startsWith(`${KEYCHAIN_SECRET_PROVIDER_ID}:`)) {
220 return null;385 return null;
@@ -223,6 +388,248 @@ const parseKeychainHandle = (handle: string): string | null => { · 2 unmodified lines
223 return trimOrNull(handle.slice(`${KEYCHAIN_SECRET_PROVIDER_ID}:`.length));388 return trimOrNull(handle.slice(`${KEYCHAIN_SECRET_PROVIDER_ID}:`.length));
224};389};
225 390
391const loadStoredSecretMaterial = (input: {
392 id: string;
393 runtime: SecretMaterialProviderRuntime;
394 operation: string;
395}) =>
396 Effect.gen(function* () {
397 const materialId = SecretMaterialIdSchema.make(input.id);
398 const stored = yield* input.runtime.rows.secretMaterials.getById(materialId);
399 if (Option.isNone(stored)) {
400 return yield* Effect.fail(
401 new Error(`${input.operation}: secret material not found: ${input.id}`),
402 );
403 }
404
405 return stored.value;
406 });
407
408const createSecretMaterialMetadata = (input: {
409 providerId: SecretStoreProviderId;
410 providerHandle: string;
411 purpose: SecretMaterialPurpose;
412 value: string | null;
413 name?: string | null;
414 runtime: SecretMaterialProviderRuntime;
415}) =>
416 Effect.gen(function* () {
417 const now = Date.now();
418 const id = SecretMaterialIdSchema.make(`sec_${randomUUID()}`);
419 yield* input.runtime.rows.secretMaterials.upsert({
420 id,
421 providerId: input.providerId,
422 handle: input.providerHandle,
423 name: trimOrNull(input.name),
424 purpose: input.purpose,
425 value: input.value,
426 createdAt: now,
427 updatedAt: now,
428 });
429
430 return {
431 providerId: input.providerId,
432 handle: id,
433 } satisfies SecretRef;
434 });
435
436const loadManagedKeychainRef = (input: {
437 ref: SecretRef;
438 runtime: SecretMaterialProviderRuntime;
439 operation: string;
440}) =>
441 Effect.gen(function* () {
442 const legacyHandle = parseKeychainHandle(input.ref.handle);
443 if (legacyHandle !== null) {
444 return {
445 providerHandle: legacyHandle,
446 material: null,
447 };
448 }
449
450 const material = yield* loadStoredSecretMaterial({
451 id: input.ref.handle,
452 runtime: input.runtime,
453 operation: input.operation,
454 });
455 if (material.providerId !== KEYCHAIN_SECRET_PROVIDER_ID) {
456 return yield* Effect.fail(
457 new Error(
458 `${input.operation}: secret ${material.id} is stored in provider '${material.providerId}', not '${KEYCHAIN_SECRET_PROVIDER_ID}'`,
459 ),
460 );
461 }
462
463 return {
464 providerHandle: material.handle,
465 material,
466 };
467 });
468
469const readKeychainSecretValue = (input: {
470 providerHandle: string;
471 runtime: SecretMaterialProviderRuntime;
472}) => {
473 switch (process.platform) {
474 case "darwin":
475 return runCommand({
476 command: "security",
477 args: [
478 "find-generic-password",
479 "-a",
480 input.providerHandle,
481 "-s",
482 input.runtime.keychainServiceName,
483 "-w",
484 ],
485 operation: "keychain.get",
486 timeoutMs: KEYCHAIN_COMMAND_TIMEOUT_MS,
487 }).pipe(
488 Effect.flatMap((result) =>
489 ensureCommandSuccess({
490 result,
491 operation: "keychain.get",
492 message: "Failed loading secret from macOS keychain",
493 }),
494 ),
495 Effect.map((result) => result.stdout.trimEnd()),
496 );
497 case "linux":
498 return runCommand({
499 command: "secret-tool",
500 args: [
501 "lookup",
502 "service",
503 input.runtime.keychainServiceName,
504 "account",
505 input.providerHandle,
506 ],
507 operation: "keychain.get",
508 timeoutMs: KEYCHAIN_COMMAND_TIMEOUT_MS,
509 }).pipe(
510 Effect.flatMap((result) =>
511 ensureCommandSuccess({
512 result,
513 operation: "keychain.get",
514 message: "Failed loading secret from desktop keyring",
515 }),
516 ),
517 Effect.map((result) => result.stdout.trimEnd()),
518 );
519 default:
520 return Effect.fail(
521 new Error(`keychain.get: keychain provider is unsupported on platform '${process.platform}'`),
522 );
523 }
524};
525
526const writeKeychainSecretValue = (input: {
527 providerHandle: string;
528 name?: string | null;
529 value: string;
530 runtime: SecretMaterialProviderRuntime;
531}) => {
532 const secretName = trimOrNull(input.name);
533
534 switch (process.platform) {
535 case "darwin":
536 return runCommand({
537 command: "security",
538 args: [
539 "add-generic-password",
540 "-a",
541 input.providerHandle,
542 "-s",
543 input.runtime.keychainServiceName,
544 "-w",
545 input.value,
546 ...(secretName ? ["-l", secretName] : []),
547 "-U",
548 ],
549 operation: "keychain.put",
550 timeoutMs: KEYCHAIN_COMMAND_TIMEOUT_MS,
551 }).pipe(
552 Effect.flatMap((result) =>
553 ensureCommandSuccess({
554 result,
555 operation: "keychain.put",
556 message: "Failed storing secret in macOS keychain",
557 }),
558 ),
559 );
560 case "linux":
561 return runCommand({
562 command: "secret-tool",
563 args: [
564 "store",
565 "--label",
566 secretName ?? input.runtime.keychainServiceName,
567 "service",
568 input.runtime.keychainServiceName,
569 "account",
570 input.providerHandle,
571 ],
572 stdin: input.value,
573 operation: "keychain.put",
574 timeoutMs: KEYCHAIN_COMMAND_TIMEOUT_MS,
575 }).pipe(
576 Effect.flatMap((result) =>
577 ensureCommandSuccess({
578 result,
579 operation: "keychain.put",
580 message: "Failed storing secret in desktop keyring",
581 }),
582 ),
583 );
584 default:
585 return Effect.fail(
586 new Error(`keychain.put: keychain provider is unsupported on platform '${process.platform}'`),
587 );
588 }
589};
590
591const deleteKeychainSecretValue = (input: {
592 providerHandle: string;
593 runtime: SecretMaterialProviderRuntime;
594}) => {
595 switch (process.platform) {
596 case "darwin":
597 return runCommand({
598 command: "security",
599 args: [
600 "delete-generic-password",
601 "-a",
602 input.providerHandle,
603 "-s",
604 input.runtime.keychainServiceName,
605 ],
606 operation: "keychain.delete",
607 timeoutMs: KEYCHAIN_COMMAND_TIMEOUT_MS,
608 }).pipe(
609 Effect.map((result) => result.exitCode === 0),
610 );
611 case "linux":
612 return runCommand({
613 command: "secret-tool",
614 args: [
615 "clear",
616 "service",
617 input.runtime.keychainServiceName,
618 "account",
619 input.providerHandle,
620 ],
621 operation: "keychain.delete",
622 timeoutMs: KEYCHAIN_COMMAND_TIMEOUT_MS,
623 }).pipe(
624 Effect.map((result) => result.exitCode === 0),
625 );
626 default:
627 return Effect.fail(
628 new Error(`keychain.delete: keychain provider is unsupported on platform '${process.platform}'`),
629 );
630 }
631};
632
226const createParamsSecretMaterialProvider = (): SecretMaterialProvider => ({633const createParamsSecretMaterialProvider = (): SecretMaterialProvider => ({
227 resolve: ({ ref, context }) => {634 resolve: ({ ref, context }) => {
228 const value = ensureNonEmptyString(context.params?.[ref.handle]);635 const value = ensureNonEmptyString(context.params?.[ref.handle]);
@@ -260,32 +667,75 @@ const createEnvSecretMaterialProvider = (): SecretMaterialProvider => ({ · 31 unmodified lines
260const createLocalSecretMaterialProvider = (): SecretMaterialProvider => ({667const createLocalSecretMaterialProvider = (): SecretMaterialProvider => ({
261 resolve: ({ ref, runtime }) =>668 resolve: ({ ref, runtime }) =>
262 Effect.gen(function* () {669 Effect.gen(function* () {
263 const materialId = SecretMaterialIdSchema.make(ref.handle);670 const stored = yield* loadStoredSecretMaterial({
264 const stored = yield* runtime.rows.secretMaterials.getById(materialId);671 id: ref.handle,
265 if (Option.isNone(stored)) {672 runtime,
266 return yield* Effect.fail(new Error(`Secret material not found: ${ref.handle}`));673 operation: "local.get",
674 });
675 if (stored.providerId !== LOCAL_SECRET_PROVIDER_ID) {
676 return yield* Effect.fail(
677 new Error(
678 `local.get: secret ${stored.id} is stored in provider '${stored.providerId}', not '${LOCAL_SECRET_PROVIDER_ID}'`,
679 ),
680 );
681 }
682 if (stored.value === null) {
683 return yield* Effect.fail(
684 new Error(`local.get: secret ${stored.id} does not have a local value`),
685 );
267 }686 }
268 687
269 return stored.value.value;688 return stored.value;
270 }),689 }),
271 690
272 store: ({ purpose, value, name, runtime }) =>691 store: ({ purpose, value, name, runtime }) =>
692 createSecretMaterialMetadata({
693 providerId: LOCAL_SECRET_PROVIDER_ID,
694 providerHandle: `local:${randomUUID()}`,
695 purpose,
696 value,
697 name,
698 runtime,
699 }),
700
701 update: ({ ref, name, value, runtime }) =>
273 Effect.gen(function* () {702 Effect.gen(function* () {
274 const now = Date.now();703 const stored = yield* loadStoredSecretMaterial({
275 const id = SecretMaterialIdSchema.make(`sec_${randomUUID()}`);704 id: ref.handle,
276 yield* runtime.rows.secretMaterials.upsert({705 runtime,
277 id,706 operation: "local.update",
278 name: trimOrNull(name),
279 purpose,
280 value,
281 createdAt: now,
282 updatedAt: now,
283 });707 });
708 if (stored.providerId !== LOCAL_SECRET_PROVIDER_ID) {
709 return yield* Effect.fail(
710 new Error(
711 `local.update: secret ${stored.id} is stored in provider '${stored.providerId}', not '${LOCAL_SECRET_PROVIDER_ID}'`,
712 ),
713 );
714 }
715
716 if (name === undefined && value === undefined) {
717 return toSecretMaterialSummary(stored);
718 }
719
720 const updated = yield* runtime.rows.secretMaterials.updateById(
721 stored.id,
722 {
723 ...(name !== undefined ? { name } : {}),
724 ...(value !== undefined ? { value } : {}),
725 },
726 );
727 if (Option.isNone(updated)) {
728 return yield* Effect.fail(new Error(`local.update: secret material not found: ${stored.id}`));
729 }
284 730
285 return {731 return {
286 providerId: LOCAL_SECRET_PROVIDER_ID,732 id: updated.value.id,
287 handle: id,733 providerId: updated.value.providerId,
288 } satisfies SecretRef;734 name: updated.value.name,
735 purpose: updated.value.purpose,
736 createdAt: updated.value.createdAt,
737 updatedAt: updated.value.updatedAt,
738 } satisfies SecretMaterialSummary;
289 }),739 }),
290 740
291 remove: ({ ref, runtime }) =>741 remove: ({ ref, runtime }) =>
@@ -295,190 +745,117 @@ const createLocalSecretMaterialProvider = (): SecretMaterialProvider => ({ · 3 unmodified lines
295 }),745 }),
296});746});
297 747
298const keychainStoreWithSecurityCli = (): SecretMaterialProvider => ({748const createKeychainSecretMaterialProvider = (): SecretMaterialProvider => ({
299 resolve: ({ ref, runtime }) => {749 resolve: ({ ref, runtime }) =>
300 const id = parseKeychainHandle(ref.handle);750 Effect.gen(function* () {
301 if (id === null) {751 const loaded = yield* loadManagedKeychainRef({
302 return Effect.fail(new Error(`Invalid keychain secret handle: ${ref.handle}`));752 ref,
303 }753 runtime,
754 operation: "keychain.get",
755 });
304 756
305 return runCommand({757 return yield* readKeychainSecretValue({
306 command: "security",758 providerHandle: loaded.providerHandle,
307 args: [759 runtime,
308 "find-generic-password",760 });
309 "-a",761 }),
310 id,
311 "-s",
312 runtime.keychainServiceName,
313 "-w",
314 ],
315 operation: "keychain.get",
316 }).pipe(
317 Effect.flatMap((result) =>
318 ensureCommandSuccess({
319 result,
320 operation: "keychain.get",
321 message: "Failed loading secret from macOS keychain",
322 }),
323 ),
324 Effect.map((result) => result.stdout.trimEnd()),
325 );
326 },
327 762
328 store: ({ name, value, runtime }) => {763 store: ({ purpose, value, name, runtime }) =>
329 const id = randomUUID();764 Effect.gen(function* () {
330 765 const providerHandle = randomUUID();
331 return runCommand({766 yield* writeKeychainSecretValue({
332 command: "security",767 providerHandle,
333 args: [768 name,
334 "add-generic-password",
335 "-a",
336 id,
337 "-s",
338 runtime.keychainServiceName,
339 "-w",
340 value,769 value,
341 ...(trimOrNull(name) ? ["-l", trimOrNull(name)!] : []),770 runtime,
342 "-U",771 });
343 ],
344 operation: "keychain.put",
345 }).pipe(
346 Effect.flatMap((result) =>
347 ensureCommandSuccess({
348 result,
349 operation: "keychain.put",
350 message: "Failed storing secret in macOS keychain",
351 }),
352 ),
353 Effect.as({
354 providerId: KEYCHAIN_SECRET_PROVIDER_ID,
355 handle: `${KEYCHAIN_SECRET_PROVIDER_ID}:${id}`,
356 } satisfies SecretRef),
357 );
358 },
359 772
360 remove: ({ ref, runtime }) => {773 return yield* createSecretMaterialMetadata({
361 const id = parseKeychainHandle(ref.handle);774 providerId: KEYCHAIN_SECRET_PROVIDER_ID,
362 if (id === null) {775 providerHandle,
363 return Effect.fail(new Error(`Invalid keychain secret handle: ${ref.handle}`));776 purpose,
364 }777 value: null,
778 name,
779 runtime,
780 });
781 }),
365 782
366 return runCommand({783 update: ({ ref, name, value, runtime }) =>
367 command: "security",784 Effect.gen(function* () {
368 args: [785 const loaded = yield* loadManagedKeychainRef({
369 "delete-generic-password",786 ref,
370 "-a",787 runtime,
371 id,788 operation: "keychain.update",
372 "-s",789 });
373 runtime.keychainServiceName,790 if (loaded.material === null) {
374 ],791 return yield* Effect.fail(
375 operation: "keychain.delete",792 new Error(`keychain.update: legacy keychain refs cannot be updated: ${ref.handle}`),
376 }).pipe(793 );
377 Effect.map((result) => result.exitCode === 0),794 }
378 );
379 },
380});
381 795
382const keychainStoreWithSecretTool = (): SecretMaterialProvider => ({796 if (name === undefined && value === undefined) {
383 resolve: ({ ref, runtime }) => {797 return toSecretMaterialSummary(loaded.material);
384 const id = parseKeychainHandle(ref.handle);798 }
385 if (id === null) {
386 return Effect.fail(new Error(`Invalid keychain secret handle: ${ref.handle}`));
387 }
388 799
389 return runCommand({800 const nextName = name ?? loaded.material.name;
390 command: "secret-tool",801 const nextValue = value
391 args: [802 ?? (yield* readKeychainSecretValue({
392 "lookup",803 providerHandle: loaded.providerHandle,
393 "service",804 runtime,
394 runtime.keychainServiceName,805 }));
395 "account",
396 id,
397 ],
398 operation: "keychain.get",
399 }).pipe(
400 Effect.flatMap((result) =>
401 ensureCommandSuccess({
402 result,
403 operation: "keychain.get",
404 message: "Failed loading secret from desktop keyring",
405 }),
406 ),
407 Effect.map((result) => result.stdout.trimEnd()),
408 );
409 },
410 806
411 store: ({ name, value, runtime }) => {807 yield* writeKeychainSecretValue({
412 const id = randomUUID();808 providerHandle: loaded.providerHandle,
413 809 name: nextName,
414 return runCommand({810 value: nextValue,
415 command: "secret-tool",811 runtime,
416 args: [812 });
417 "store",
418 "--label",
419 trimOrNull(name) ?? runtime.keychainServiceName,
420 "service",
421 runtime.keychainServiceName,
422 "account",
423 id,
424 ],
425 stdin: value,
426 operation: "keychain.put",
427 }).pipe(
428 Effect.flatMap((result) =>
429 ensureCommandSuccess({
430 result,
431 operation: "keychain.put",
432 message: "Failed storing secret in desktop keyring",
433 }),
434 ),
435 Effect.as({
436 providerId: KEYCHAIN_SECRET_PROVIDER_ID,
437 handle: `${KEYCHAIN_SECRET_PROVIDER_ID}:${id}`,
438 } satisfies SecretRef),
439 );
440 },
441 813
442 remove: ({ ref, runtime }) => {814 const updated = yield* runtime.rows.secretMaterials.updateById(
443 const id = parseKeychainHandle(ref.handle);815 loaded.material.id,
444 if (id === null) {816 {
445 return Effect.fail(new Error(`Invalid keychain secret handle: ${ref.handle}`));817 name: nextName,
446 }818 },
819 );
820 if (Option.isNone(updated)) {
821 return yield* Effect.fail(
822 new Error(`keychain.update: secret material not found: ${loaded.material.id}`),
823 );
824 }
447 825
448 return runCommand({826 return {
449 command: "secret-tool",827 id: updated.value.id,
450 args: [828 providerId: updated.value.providerId,
451 "clear",829 name: updated.value.name,
452 "service",830 purpose: updated.value.purpose,
453 runtime.keychainServiceName,831 createdAt: updated.value.createdAt,
454 "account",832 updatedAt: updated.value.updatedAt,
455 id,833 } satisfies SecretMaterialSummary;
456 ],834 }),
457 operation: "keychain.delete",
458 }).pipe(
459 Effect.map((result) => result.exitCode === 0),
460 );
461 },
462});
463 835
464const createKeychainSecretMaterialProvider = (): SecretMaterialProvider => {836 remove: ({ ref, runtime }) =>
465 if (process.platform === "darwin") {837 Effect.gen(function* () {
466 return keychainStoreWithSecurityCli();838 const loaded = yield* loadManagedKeychainRef({
467 }839 ref,
840 runtime,
841 operation: "keychain.delete",
842 });
843 const deleted = yield* deleteKeychainSecretValue({
844 providerHandle: loaded.providerHandle,
845 runtime,
846 });
468 847
469 if (process.platform === "linux") {848 if (loaded.material === null) {
470 return keychainStoreWithSecretTool();849 return deleted;
471 }850 }
472 851
473 const unsupported = (operation: string) =>852 if (!deleted) {
474 Effect.fail(new Error(`${operation}: keychain provider is unsupported on platform '${process.platform}'`));853 return false;
854 }
475 855
476 return {856 return yield* runtime.rows.secretMaterials.removeById(loaded.material.id);
477 resolve: () => unsupported("keychain.get"),857 }),
478 store: () => unsupported("keychain.put"),858});
479 remove: () => Effect.succeed(false),
480 } satisfies SecretMaterialProvider;
481};
482 859
483const createSecretMaterialProviderRegistry = (): SecretMaterialProviderRegistry =>860const createSecretMaterialProviderRegistry = (): SecretMaterialProviderRegistry =>
484 new Map([861 new Map([
@@ -707,10 +1084,13 @@ export const createDefaultSecretMaterialStorer = (input: { · 222 unmodified lines
707}): StoreSecretMaterial => {1084}): StoreSecretMaterial => {
708 const providers = createSecretMaterialProviderRegistry();1085 const providers = createSecretMaterialProviderRegistry();
709 const runtime = createSecretMaterialProviderRuntime(input);1086 const runtime = createSecretMaterialProviderRuntime(input);
710 const defaultStoreProviderId = resolveSecretStoreProviderId(input.storeProviderId);
711 1087
712 return ({ purpose, value, name }) =>1088 return ({ purpose, value, name }) =>
713 Effect.gen(function* () {1089 Effect.gen(function* () {
1090 const defaultStoreProviderId = yield* resolveDefaultSecretStoreProviderId({
1091 storeProviderId: input.storeProviderId,
1092 env: runtime.env,
1093 });
714 const provider = yield* getSecretMaterialProvider({1094 const provider = yield* getSecretMaterialProvider({
715 providers,1095 providers,
716 providerId: defaultStoreProviderId,1096 providerId: defaultStoreProviderId,
@@ -731,6 +1111,36 @@ export const createDefaultSecretMaterialStorer = (input: { · 14 unmodified lines
731 });1111 });
732};1112};
733 1113
1114export const createDefaultSecretMaterialUpdater = (input: {
1115 rows: ControlPlaneStoreShape;
1116 dangerouslyAllowEnvSecrets?: boolean;
1117 keychainServiceName?: string;
1118}): UpdateSecretMaterial => {
1119 const providers = createSecretMaterialProviderRegistry();
1120 const runtime = createSecretMaterialProviderRuntime(input);
1121
1122 return ({ ref, name, value }) =>
1123 Effect.gen(function* () {
1124 const provider = yield* getSecretMaterialProvider({
1125 providers,
1126 providerId: ref.providerId,
1127 });
1128
1129 if (!provider.update) {
1130 return yield* Effect.fail(
1131 new Error(`Secret provider ${ref.providerId} does not support updating secret material`),
1132 );
1133 }
1134
1135 return yield* provider.update({
1136 ref,
1137 name,
1138 value,
1139 runtime,
1140 });
1141 });
1142};
1143
734export const createDefaultSecretMaterialDeleter = (input: {1144export const createDefaultSecretMaterialDeleter = (input: {
735 rows: ControlPlaneStoreShape;1145 rows: ControlPlaneStoreShape;
736 dangerouslyAllowEnvSecrets?: boolean;1146 dangerouslyAllowEnvSecrets?: boolean;
@@ -835,6 +1245,23 @@ export const SecretMaterialDeleterLive = (input: { · 98 unmodified lines
835 }),1245 }),
836 );1246 );
837 1247
1248export const SecretMaterialUpdaterLive = (input: {
1249 dangerouslyAllowEnvSecrets?: boolean;
1250 keychainServiceName?: string;
1251} = {}) =>
1252 Layer.effect(
1253 SecretMaterialUpdaterService,
1254 Effect.gen(function* () {
1255 const rows = yield* ControlPlaneStore;
1256
1257 return createDefaultSecretMaterialUpdater({
1258 rows,
1259 dangerouslyAllowEnvSecrets: input.dangerouslyAllowEnvSecrets,
1260 keychainServiceName: input.keychainServiceName,
1261 });
1262 }),
1263 );
1264
838export const SecretMaterialLive = (input: {1265export const SecretMaterialLive = (input: {
839 resolveSecretMaterial?: ResolveSecretMaterial;1266 resolveSecretMaterial?: ResolveSecretMaterial;
840 storeProviderId?: SecretStoreProviderId;1267 storeProviderId?: SecretStoreProviderId;
@@ -847,4 +1274,5 @@ export const SecretMaterialLive = (input: { · 6 unmodified lines
847 SecretMaterialResolverLive(input),1274 SecretMaterialResolverLive(input),
848 SecretMaterialStorerLive(input),1275 SecretMaterialStorerLive(input),
849 SecretMaterialDeleterLive(input),1276 SecretMaterialDeleterLive(input),
1277 SecretMaterialUpdaterLive(input),
850 );1278 );
packages/control-plane/src/runtime/store.ts+1−0
@@ -14,6 +14,7 @@ import type { · 13 unmodified lines
14 14
15type SecretMaterialSummary = {15type SecretMaterialSummary = {
16 id: string;16 id: string;
17 providerId: string;
17 name: string | null;18 name: string | null;
18 purpose: string;19 purpose: string;
19 createdAt: number;20 createdAt: number;
packages/control-plane/src/schema/models/secret-material.ts+40−1
@@ -10,7 +10,7 @@ export const SecretMaterialPurposeSchema = Schema.Literal( · 9 unmodified lines
10 "oauth_client_info",10 "oauth_client_info",
11);11);
12 12
13export const SecretMaterialSchema = Schema.Struct({13const LegacySecretMaterialSchema = Schema.Struct({
14 id: SecretMaterialIdSchema,14 id: SecretMaterialIdSchema,
15 name: Schema.NullOr(Schema.String),15 name: Schema.NullOr(Schema.String),
16 purpose: SecretMaterialPurposeSchema,16 purpose: SecretMaterialPurposeSchema,
@@ -19,5 +19,44 @@ export const SecretMaterialSchema = Schema.Struct({ · 2 unmodified lines
19 updatedAt: TimestampMsSchema,19 updatedAt: TimestampMsSchema,
20});20});
21 21
22const SecretMaterialStorageSchema = Schema.Struct({
23 id: SecretMaterialIdSchema,
24 name: Schema.NullOr(Schema.String),
25 purpose: SecretMaterialPurposeSchema,
26 providerId: Schema.String,
27 handle: Schema.String,
28 value: Schema.NullOr(Schema.String),
29 createdAt: TimestampMsSchema,
30 updatedAt: TimestampMsSchema,
31});
32
33export const SecretMaterialSchema = Schema.transform(
34 Schema.Union(LegacySecretMaterialSchema, SecretMaterialStorageSchema),
35 SecretMaterialStorageSchema,
36 {
37 strict: false,
38 decode: (row) => ({
39 id: row.id,
40 name: row.name,
41 purpose: row.purpose,
42 providerId: "providerId" in row ? row.providerId : "local",
43 handle: "handle" in row ? row.handle : row.id,
44 value: row.value,
45 createdAt: row.createdAt,
46 updatedAt: row.updatedAt,
47 }),
48 encode: (material) => ({
49 id: material.id,
50 name: material.name,
51 purpose: material.purpose,
52 providerId: material.providerId,
53 handle: material.handle,
54 value: material.value,
55 createdAt: material.createdAt,
56 updatedAt: material.updatedAt,
57 }),
58 },
59);
60
22export type SecretMaterialPurpose = typeof SecretMaterialPurposeSchema.Type;61export type SecretMaterialPurpose = typeof SecretMaterialPurposeSchema.Type;
23export type SecretMaterial = typeof SecretMaterialSchema.Type;62export type SecretMaterial = typeof SecretMaterialSchema.Type;