feat: convert secret-sources into a plugin (B20)

This commit is contained in:
LukeGus committed 2026-09-24 16:20:20 -05:00
1 parent 2ac7779eda
commit f4f26a9b8b
72 files changed
+18843 -933

No files matched your search

@@ -0,0 +1,114 @@
/**
* Moves each secret source's access token out of secret_sources.token,
* encrypted under its owner's data key, into a ctx.secrets row "source:<id>"
* for that owner, encrypted under the installation key - the same move the
* ai plugin made for provider API keys. The column is then emptied so no
* token material is left in the adopted table.
*
* The old column stays in the database, unused, until this runs: core's
* drizzle migrations run before this does, so dropping it there would lose
* the token first. The table is secret_sources until the plugin adopts it
* and p_secret_sources_sources after, so both names are tried.
*
* Idempotent: a source whose ctx.secrets row already exists is skipped, and
* a token is only cleared once its copy exists. Safe on every boot.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
} from "../../database/repositories/factory.js";
import { DataCrypto } from "../data-crypto.js";
import { LazyFieldEncryption } from "../lazy-field-encryption.js";
import { encryptSystemSecret } from "../system-secret-crypto.js";
import { runStatement, selectRows } from "./raw-rows.js";
const PLUGIN_ID = "secret-sources";
export interface SecretSourcesTokenMigrationResult {
moved: number;
}
async function tryRows<T>(query: ReturnType<typeof sql>): Promise<T[]> {
try {
return await selectRows<T>(query);
} catch {
// A fresh install never had the table or column.
return [];
}
}
export async function runSecretSourcesTokenMigration(): Promise<SecretSourcesTokenMigrationResult> {
const result: SecretSourcesTokenMigrationResult = { moved: 0 };
// plugin_settings has a foreign key to plugins: nothing to migrate into
// until the plugin row exists.
try {
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return result;
} catch {
return result;
}
const pluginSettings = createCurrentPluginSettingsRepository();
for (const table of ["secret_sources", "p_secret_sources_sources"]) {
const rows = await tryRows<{
id: string;
user_id: string;
token: string | null;
}>(
sql`SELECT id, user_id, token FROM ${sql.identifier(table)} WHERE token IS NOT NULL AND token <> ''`,
);
for (const row of rows) {
try {
const key = `source:${row.id}`;
const existing = await pluginSettings.get(
PLUGIN_ID,
"secret",
row.user_id,
key,
);
if (!existing) {
const dek = DataCrypto.getUserDataKey(row.user_id);
if (!dek) continue;
const plaintext = LazyFieldEncryption.safeGetFieldValue(
row.token as string,
dek,
row.id,
"token",
);
if (!plaintext) continue;
await pluginSettings.set(
PLUGIN_ID,
"secret",
row.user_id,
key,
JSON.stringify(await encryptSystemSecret(plaintext)),
true,
);
result.moved++;
}
await runStatement(
sql`UPDATE ${sql.identifier(table)} SET token = NULL WHERE id = ${row.id}`,
);
} catch (error) {
databaseLogger.warn("Secret source token migration failed for a row", {
operation: "secret_sources_token_migration",
error: error instanceof Error ? error.message : String(error),
});
}
}
}
if (result.moved > 0) {
databaseLogger.info(
`Moved ${result.moved} secret source token(s) into the secret-sources plugin`,
{ operation: "secret_sources_token_migration" },
);
}
return result;
}
-134
View File
@@ -1,134 +0,0 @@
import { safeOutboundFetch } from "./safe-outbound-fetch.js";
/**
* The slice of the 1Password Connect REST API needed to resolve a secret
* reference: find the vault, find the item, read one field.
* https://developer.1password.com/docs/connect/api-reference
*/
export interface SecretReference {
vault: string;
item: string;
field: string;
}
const FETCH_TIMEOUT_MS = 10_000;
/** op://<vault>/<item>/<field> (a trailing ?query, as in ?ssh-format=openssh, is ignored) */
export function parseSecretReference(raw: string): SecretReference | null {
const match = /^op:\/\/([^/]+)\/([^/]+)\/([^/?]+)(?:\?.*)?$/.exec(raw.trim());
if (!match) return null;
const [, vault, item, field] = match.map((part) => decodeURIComponent(part));
return { vault, item, field };
}
export function isSecretReference(value: unknown): value is string {
return typeof value === "string" && value.trimStart().startsWith("op://");
}
export interface ConnectSource {
baseUrl: string;
token: string;
allowedPrivateHosts: readonly string[];
}
async function connectGet<T>(source: ConnectSource, path: string): Promise<T> {
const base = source.baseUrl.replace(/\/+$/, "");
const response = await safeOutboundFetch(
`${base}${path}`,
{
method: "GET",
headers: { Authorization: `Bearer ${source.token}` },
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
},
source.allowedPrivateHosts,
);
if (!response.ok) {
throw new Error(
`1Password Connect ${path} failed: HTTP ${response.status}`,
);
}
return (await response.json()) as T;
}
function eqFilter(value: string): string {
return encodeURIComponent(`title eq "${value.replace(/"/g, '\\"')}"`);
}
interface ConnectVault {
id: string;
name: string;
}
interface ConnectItemSummary {
id: string;
title: string;
}
interface ConnectField {
id: string;
label?: string;
purpose?: string;
type?: string;
value?: string;
}
/** Reachability + token validity: the vault list needs a valid token. */
export async function testConnectSource(
source: ConnectSource,
): Promise<number> {
const vaults = await connectGet<ConnectVault[]>(source, "/v1/vaults");
return vaults.length;
}
const byIdOrName =
(name: string) =>
(candidate: { id: string; name?: string; title?: string }) =>
candidate.id === name ||
(candidate.name ?? candidate.title ?? "").toLowerCase() ===
name.toLowerCase();
export async function resolveConnectReference(
source: ConnectSource,
ref: SecretReference,
): Promise<string> {
const vaults = await connectGet<ConnectVault[]>(
source,
`/v1/vaults?filter=${eqFilter(ref.vault)}`,
);
const vault =
vaults.find(byIdOrName(ref.vault)) ??
(await connectGet<ConnectVault[]>(source, "/v1/vaults")).find(
byIdOrName(ref.vault),
);
if (!vault) throw new Error(`1Password vault "${ref.vault}" not found`);
const items = await connectGet<ConnectItemSummary[]>(
source,
`/v1/vaults/${vault.id}/items?filter=${eqFilter(ref.item)}`,
);
const summary =
items.find(byIdOrName(ref.item)) ??
(ref.item.length >= 26 ? { id: ref.item, title: ref.item } : undefined);
if (!summary) {
throw new Error(`1Password item "${ref.item}" not found in "${ref.vault}"`);
}
const item = await connectGet<{ fields?: ConnectField[] }>(
source,
`/v1/vaults/${vault.id}/items/${summary.id}`,
);
const wanted = ref.field.toLowerCase();
const field = (item.fields ?? []).find(
(f) =>
f.id === ref.field ||
f.label?.toLowerCase() === wanted ||
f.purpose?.toLowerCase() === wanted ||
(wanted === "private key" && f.id === "private_key") ||
(wanted === "private_key" && f.label?.toLowerCase() === "private key"),
);
if (!field || field.value === undefined) {
throw new Error(
`1Password field "${ref.field}" not found on "${ref.item}"`,
);
}
return field.value;
}
-13
View File
@@ -1,13 +0,0 @@
import { createCurrentSettingsRepository } from "../database/repositories/factory.js";
import { parseNotificationAllowlist } from "./notification-egress.js";
/** Private hosts (a self-hosted 1Password Connect server) secret sources may reach. */
export const SECRET_SOURCE_PRIVATE_ALLOWLIST_KEY =
"secret_source_private_endpoint_allowlist";
export async function readSecretSourcePrivateAllowlist(): Promise<string[]> {
const raw = await createCurrentSettingsRepository().get(
SECRET_SOURCE_PRIVATE_ALLOWLIST_KEY,
);
return parseNotificationAllowlist(raw);
}