mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55:24 +00:00
feat: convert secret-sources into a plugin (B20)
This commit is contained in:
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user