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

-2
View File
@@ -19,7 +19,6 @@ import uiPreferencesRoutes from "./routes/ui-preferences.js";
import termixIdRoutes from "./routes/termix-id.js";
import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
import vaultRoutes from "./routes/vault.js";
import secretSourceRoutes from "./routes/secret-sources.js";
import notificationChannelsRoutes from "./routes/notification-channels-routes.js";
import syncRoutes from "./routes/sync.js";
import dashboardRoutes from "./routes/dashboard-routes.js";
@@ -1803,7 +1802,6 @@ app.use("/ui-preferences", uiPreferencesRoutes);
app.use("/termix-id", termixIdRoutes);
registerAuditLogRoutes(app, authenticateJWT);
app.use("/vault", vaultRoutes);
app.use("/secret-sources", secretSourceRoutes);
app.use("/", notificationChannelsRoutes);
app.use("/sync", syncRoutes);
app.use("/dashboard", dashboardRoutes);
-13
View File
@@ -477,19 +477,6 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS secret_sources (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'onepassword-connect',
base_url TEXT NOT NULL,
token TEXT NOT NULL,
shared INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS credential_access (
id INTEGER PRIMARY KEY AUTOINCREMENT,
credential_id INTEGER NOT NULL,
-32
View File
@@ -1030,38 +1030,6 @@ export const syncTombstones = mysqlTable("sync_tombstones", {
// --- collab rooms ---
// --- secret sources ---
/**
* An external password manager Termix pulls secrets from at connect time,
* instead of storing them. Only the access token is secret; it is encrypted
* with the owner's data key under the row id. Hosts and credentials refer to
* entries by reference ("op://vault/item/field") in their secret fields.
*/
export const secretSources = mysqlTable(
"secret_sources",
{
id: varchar("id", { length: 255 }).primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
// "onepassword-connect" for now; the reference syntax is per kind.
kind: text("kind").notNull().default("onepassword-connect"),
baseUrl: text("base_url").notNull(),
token: text("token").notNull(),
// Visible to every user; secrets still decrypt with the owner's key.
shared: boolean("shared").notNull().default(false),
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: text("updated_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [index("idx_secret_sources_user").on(table.userId)],
);
// --- credential sharing ---
/**
-32
View File
@@ -1031,38 +1031,6 @@ export const syncTombstones = pgTable("sync_tombstones", {
// --- collab rooms ---
// --- secret sources ---
/**
* An external password manager Termix pulls secrets from at connect time,
* instead of storing them. Only the access token is secret; it is encrypted
* with the owner's data key under the row id. Hosts and credentials refer to
* entries by reference ("op://vault/item/field") in their secret fields.
*/
export const secretSources = pgTable(
"secret_sources",
{
id: varchar("id", { length: 255 }).primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
// "onepassword-connect" for now; the reference syntax is per kind.
kind: text("kind").notNull().default("onepassword-connect"),
baseUrl: text("base_url").notNull(),
token: text("token").notNull(),
// Visible to every user; secrets still decrypt with the owner's key.
shared: boolean("shared").notNull().default(false),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [index("idx_secret_sources_user").on(table.userId)],
);
// --- credential sharing ---
/**
-32
View File
@@ -1027,38 +1027,6 @@ export const syncTombstones = sqliteTable("sync_tombstones", {
// --- collab rooms ---
// --- secret sources ---
/**
* An external password manager Termix pulls secrets from at connect time,
* instead of storing them. Only the access token is secret; it is encrypted
* with the owner's data key under the row id. Hosts and credentials refer to
* entries by reference ("op://vault/item/field") in their secret fields.
*/
export const secretSources = sqliteTable(
"secret_sources",
{
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(),
// "onepassword-connect" for now; the reference syntax is per kind.
kind: text("kind").notNull().default("onepassword-connect"),
baseUrl: text("base_url").notNull(),
token: text("token").notNull(),
// Visible to every user; secrets still decrypt with the owner's key.
shared: integer("shared", { mode: "boolean" }).notNull().default(false),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [index("idx_secret_sources_user").on(table.userId)],
);
// --- credential sharing ---
/**
@@ -30,7 +30,6 @@ import { RbacPermissionRepository } from "./rbac-permission-repository.js";
import { RecentActivityRepository } from "./recent-activity-repository.js";
import { RoleRepository } from "./role-repository.js";
import { SessionRepository } from "./session-repository.js";
import { SecretSourceRepository } from "./secret-source-repository.js";
import { CredentialAccessRepository } from "./credential-access-repository.js";
import { SharedCredentialSecretsRepository } from "./shared-credential-secrets-repository.js";
import { FolderAccessRepository } from "./folder-access-repository.js";
@@ -441,13 +440,6 @@ export function createCurrentSharedCredentialSecretsRepository(): SharedCredenti
);
}
export function createCurrentSecretSourceRepository(): SecretSourceRepository {
return new SecretSourceRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("secret_source_repository_write"),
);
}
export function createCurrentVaultProfileRepository(): VaultProfileRepository {
return new VaultProfileRepository(
createCurrentRepositoryContext(),
@@ -1,135 +0,0 @@
import { desc, eq, or } from "drizzle-orm";
import { secretSources } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { insertReturning } from "./returning.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { FieldCrypto } from "../../utils/field-crypto.js";
export type SecretSourceRecord = typeof secretSources.$inferSelect;
export type SecretSourceKind = "onepassword-connect";
/** A row with the token still encrypted - safe to hand to the API. */
export type SecretSourcePublic = Omit<SecretSourceRecord, "token"> & {
hasToken: boolean;
};
export interface SecretSourceCreateInput {
id: string;
userId: string;
name: string;
kind: SecretSourceKind;
baseUrl: string;
token: string;
shared: boolean;
}
export interface SecretSourceUpdateInput {
name?: string;
baseUrl?: string;
token?: string;
shared?: boolean;
}
export function toPublicSecretSource(
row: SecretSourceRecord,
): SecretSourcePublic {
const { token, ...rest } = row;
return { ...rest, hasToken: token.length > 0 };
}
/**
* The token is encrypted with the owner's data key under the row id, like
* vault_tokens - so a shared source only decrypts while its owner's key is
* loaded, which the resolver reports as a clear error.
*/
export class SecretSourceRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
private encryptToken(id: string, ownerId: string, token: string): string {
const key = DataCrypto.validateUserAccess(ownerId);
return FieldCrypto.encryptField(token, key, id, "token");
}
decryptToken(row: SecretSourceRecord): string {
const key = DataCrypto.getUserDataKey(row.userId);
if (!key) {
throw new Error(
"The secret source owner's data is locked; they need to sign in first",
);
}
return FieldCrypto.decryptField(row.token, key, row.id, "token");
}
async listVisibleToUser(userId: string): Promise<SecretSourceRecord[]> {
return this.context.drizzle
.select()
.from(secretSources)
.where(
or(eq(secretSources.userId, userId), eq(secretSources.shared, true)),
)
.orderBy(desc(secretSources.updatedAt));
}
async findById(id: string): Promise<SecretSourceRecord | null> {
const rows = await this.context.drizzle
.select()
.from(secretSources)
.where(eq(secretSources.id, id))
.limit(1);
return rows[0] ?? null;
}
async create(input: SecretSourceCreateInput): Promise<SecretSourceRecord> {
const [created] = await insertReturning(this.context, secretSources, {
id: input.id,
userId: input.userId,
name: input.name,
kind: input.kind,
baseUrl: input.baseUrl,
token: this.encryptToken(input.id, input.userId, input.token),
shared: input.shared,
});
await this.afterWrite();
return created;
}
async update(
row: SecretSourceRecord,
input: SecretSourceUpdateInput,
): Promise<void> {
await this.context.drizzle
.update(secretSources)
.set({
...(input.name !== undefined ? { name: input.name } : {}),
...(input.baseUrl !== undefined ? { baseUrl: input.baseUrl } : {}),
...(input.shared !== undefined ? { shared: input.shared } : {}),
...(input.token !== undefined
? { token: this.encryptToken(row.id, row.userId, input.token) }
: {}),
updatedAt: new Date().toISOString(),
})
.where(eq(secretSources.id, row.id));
await this.afterWrite();
}
async deleteById(id: string): Promise<void> {
await this.context.drizzle
.delete(secretSources)
.where(eq(secretSources.id, id));
await this.afterWrite();
}
async deleteByUserId(userId: string): Promise<void> {
await this.context.drizzle
.delete(secretSources)
.where(eq(secretSources.userId, userId));
await this.afterWrite();
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -25,7 +25,6 @@ import {
createCurrentUserPreferenceRepository,
createCurrentUserRepository,
createCurrentVaultProfileRepository,
createCurrentSecretSourceRepository,
createCurrentSharedCredentialSecretsRepository,
createCurrentCredentialAccessRepository,
createCurrentVaultTokenRepository,
@@ -94,14 +93,15 @@ export async function deleteUserAndRelatedData(
await createCurrentHostRepository().deleteByUserId(userId);
await createCurrentCredentialRepository().deleteByUserId(userId);
// homepage_items, homepage_layouts and dashboard_service_links cascade on
// the user's refUser() foreign key, as the homepage plugin's adopted
// tables.
// homepage_items, homepage_layouts, dashboard_service_links and
// secret_sources cascade on the user's refUser() foreign key, as the
// homepage and secret-sources plugins' adopted tables. The secret
// source's token in ctx.secrets is cleaned up generically below, with
// every other plugin_settings row for this user.
await createCurrentOpksshTokenRepository().deleteByUserId(userId);
await createCurrentVaultTokenRepository().deleteByUserId(userId);
await createCurrentVaultProfileRepository().deleteByUserId(userId);
await createCurrentSecretSourceRepository().deleteByUserId(userId);
await createCurrentTermixIdentityCaRepository().deleteByUserId(userId);
await createCurrentTermixIdentityRepository().deleteByUserId(userId);
await createCurrentOpenTabRepository().deleteByUserId(userId);
@@ -1,278 +0,0 @@
import crypto from "crypto";
import express, { type Request, type Response } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import { authLogger } from "../../utils/logger.js";
import { getErrorMessage } from "../../utils/error-message.js";
import {
logAudit,
getAuditUsername,
getRequestMeta,
} from "../../utils/audit-logger.js";
import { testConnectSource } from "../../utils/onepassword-connect.js";
import { readSecretSourcePrivateAllowlist } from "../../utils/secret-source-egress.js";
import { clearExternalSecretCache } from "../../hosts/external-secrets.js";
import { createCurrentSecretSourceRepository } from "../repositories/factory.js";
import {
toPublicSecretSource,
type SecretSourceRecord,
} from "../repositories/secret-source-repository.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware();
const permissionManager = PermissionManager.getInstance();
const KINDS = ["onepassword-connect"] as const;
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function validBaseUrl(raw: unknown): string | null {
if (!isNonEmptyString(raw)) return null;
try {
const url = new URL(raw.trim());
if (!["http:", "https:"].includes(url.protocol)) return null;
return url.toString().replace(/\/+$/, "");
} catch {
return null;
}
}
async function loadOwned(
id: string,
userId: string,
res: Response,
): Promise<SecretSourceRecord | null> {
const source = await createCurrentSecretSourceRepository().findById(id);
if (!source) {
res.status(404).json({ error: "Secret source not found" });
return null;
}
if (source.userId !== userId) {
res.status(403).json({ error: "Only the owner can change this source" });
return null;
}
return source;
}
/**
* @openapi
* /secret-sources:
* get:
* summary: List secret sources visible to the caller (own + shared)
* tags:
* - Secret Sources
*/
router.get(
"/",
authenticateJWT,
permissionManager.requirePermission("credentials.view"),
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId!;
try {
const rows =
await createCurrentSecretSourceRepository().listVisibleToUser(userId);
res.json({
sources: rows.map((row) => ({
...toPublicSecretSource(row),
owned: row.userId === userId,
})),
});
} catch (error) {
authLogger.error("Failed to list secret sources", error);
res.status(500).json({ error: "Failed to list secret sources" });
}
},
);
/**
* @openapi
* /secret-sources:
* post:
* summary: Create a secret source (1Password Connect)
* description: Sharing a source with every user requires admin. The token is encrypted with the owner's data key.
* tags:
* - Secret Sources
*/
router.post(
"/",
authenticateJWT,
permissionManager.requirePermission("credentials.create"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId!;
const {
name,
kind = "onepassword-connect",
baseUrl,
token,
shared,
} = req.body ?? {};
const url = validBaseUrl(baseUrl);
if (!isNonEmptyString(name) || !url || !isNonEmptyString(token)) {
return res.status(400).json({
error: "name, a valid http(s) baseUrl and token are required",
});
}
if (!KINDS.includes(kind)) {
return res.status(400).json({ error: "Unsupported secret source kind" });
}
if (shared === true && !(await permissionManager.isAdmin(userId))) {
return res
.status(403)
.json({ error: "Only admins can share a secret source" });
}
try {
const created = await createCurrentSecretSourceRepository().create({
id: crypto.randomUUID(),
userId,
name: name.trim(),
kind,
baseUrl: url,
token: token.trim(),
shared: shared === true,
});
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getAuditUsername(userId),
action: "secret_source_create",
resourceType: "secret_source",
resourceId: created.id,
resourceName: created.name,
ipAddress,
userAgent,
success: true,
});
res.json({ source: { ...toPublicSecretSource(created), owned: true } });
} catch (error) {
authLogger.error("Failed to create secret source", error);
res.status(500).json({ error: "Failed to create secret source" });
}
},
);
/**
* @openapi
* /secret-sources/{id}:
* put:
* summary: Update a secret source (owner only; omit token to keep it)
* tags:
* - Secret Sources
*/
router.put(
"/:id",
authenticateJWT,
permissionManager.requirePermission("credentials.edit"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId!;
const { name, baseUrl, token, shared } = req.body ?? {};
try {
const source = await loadOwned(String(req.params.id), userId, res);
if (!source) return;
const url = baseUrl === undefined ? undefined : validBaseUrl(baseUrl);
if (baseUrl !== undefined && !url) {
return res.status(400).json({ error: "baseUrl must be http(s)" });
}
if (
shared === true &&
!source.shared &&
!(await permissionManager.isAdmin(userId))
) {
return res
.status(403)
.json({ error: "Only admins can share a secret source" });
}
await createCurrentSecretSourceRepository().update(source, {
...(isNonEmptyString(name) ? { name: name.trim() } : {}),
...(url ? { baseUrl: url } : {}),
...(isNonEmptyString(token) ? { token: token.trim() } : {}),
...(typeof shared === "boolean" ? { shared } : {}),
});
clearExternalSecretCache();
res.json({ success: true });
} catch (error) {
authLogger.error("Failed to update secret source", error);
res.status(500).json({ error: "Failed to update secret source" });
}
},
);
/**
* @openapi
* /secret-sources/{id}:
* delete:
* summary: Delete a secret source (owner only)
* tags:
* - Secret Sources
*/
router.delete(
"/:id",
authenticateJWT,
permissionManager.requirePermission("credentials.delete"),
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId!;
try {
const source = await loadOwned(String(req.params.id), userId, res);
if (!source) return;
await createCurrentSecretSourceRepository().deleteById(source.id);
clearExternalSecretCache();
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getAuditUsername(userId),
action: "secret_source_delete",
resourceType: "secret_source",
resourceId: source.id,
resourceName: source.name,
ipAddress,
userAgent,
success: true,
});
res.json({ success: true });
} catch (error) {
authLogger.error("Failed to delete secret source", error);
res.status(500).json({ error: "Failed to delete secret source" });
}
},
);
/**
* @openapi
* /secret-sources/{id}/test:
* post:
* summary: Check that the source is reachable and the token is accepted
* tags:
* - Secret Sources
*/
router.post(
"/:id/test",
authenticateJWT,
permissionManager.requirePermission("credentials.view"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId!;
try {
const repository = createCurrentSecretSourceRepository();
const source = await repository.findById(String(req.params.id));
if (!source || (source.userId !== userId && !source.shared)) {
return res.status(404).json({ error: "Secret source not found" });
}
const vaults = await testConnectSource({
baseUrl: source.baseUrl,
token: repository.decryptToken(source),
allowedPrivateHosts: await readSecretSourcePrivateAllowlist(),
});
res.json({ ok: true, vaults });
} catch (error) {
res.status(200).json({ ok: false, error: getErrorMessage(error) });
}
},
);
export default router;
@@ -14,7 +14,6 @@ import {
} from "../../utils/audit-forwarder.js";
import { getTelemetryEnvOverride } from "../../utils/analytics.js";
import { STEP_CA_PRIVATE_ALLOWLIST_KEY } from "../../utils/step-ca-egress.js";
import { SECRET_SOURCE_PRIVATE_ALLOWLIST_KEY } from "../../utils/secret-source-egress.js";
import {
NOTIFICATION_PRIVATE_ALLOWLIST_KEY,
parseNotificationAllowlist,
@@ -582,25 +581,6 @@ export function registerUserSettingsRoutes(
"Step CA endpoint",
);
/**
* @openapi
* /users/secret-source-private-endpoints:
* get:
* summary: Get the private hosts secret sources (1Password Connect) may contact (admin only)
* tags:
* - Users
* patch:
* summary: Replace that allowlist (admin only)
* tags:
* - Users
*/
registerPrivateEndpointAllowlist(
"/secret-source-private-endpoints",
SECRET_SOURCE_PRIVATE_ALLOWLIST_KEY,
"update_secret_source_private_endpoints",
"secret source endpoint",
);
/**
* @openapi
* /users/step-ca-settings:
@@ -0,0 +1,104 @@
/**
* Which plugin resolves a "<scheme>://..." secret reference in a host's
* secret fields ("op://vault/item/field" for 1Password Connect, and so on).
*
* Core registers nothing here: every scheme comes from a plugin through
* ctx.credentials.registerSecretResolver. A reference whose scheme has no
* resolver fails the connect with a message naming the plugin that would
* provide it, the same shape as SshAuthProviderMissingError.
*/
export interface SecretResolverOwner {
pluginId: string;
pluginName: string;
}
export type SecretResolveFn = (
userId: string,
reference: string,
) => Promise<string>;
interface SecretResolverRegistration extends SecretResolverOwner {
resolve: SecretResolveFn;
}
const resolvers = new Map<string, SecretResolverRegistration>();
/**
* Which plugin declares which secret scheme, from every manifest on disk
* including disabled ones. Set by the plugin runtime, mirroring
* setSshAuthTypeOwnerSource.
*/
let ownerSource: () => Array<
SecretResolverOwner & { scheme: string }
> = () => [];
export function setSecretResolverOwnerSource(
source: () => Array<SecretResolverOwner & { scheme: string }>,
): void {
ownerSource = source;
}
function findSchemeOwner(scheme: string): SecretResolverOwner | null {
const owner = ownerSource().find((entry) => entry.scheme === scheme);
return owner
? { pluginId: owner.pluginId, pluginName: owner.pluginName }
: null;
}
export class SecretResolverMissingError extends Error {
readonly code = "SECRET_RESOLVER_MISSING";
constructor(
readonly scheme: string,
readonly owner: SecretResolverOwner | null,
) {
super(
owner
? `This host uses a "${scheme}://" secret reference, which needs the ${owner.pluginName} plugin`
: `This host uses a "${scheme}://" secret reference, which no enabled plugin resolves`,
);
this.name = "SecretResolverMissingError";
}
}
export function registerSecretResolver(
scheme: string,
owner: SecretResolverOwner,
resolve: SecretResolveFn,
): () => void {
const existing = resolvers.get(scheme);
if (existing && existing.pluginId !== owner.pluginId) {
throw new Error(
`Secret scheme "${scheme}" is already resolved by ${existing.pluginId}`,
);
}
const registration = { ...owner, resolve };
resolvers.set(scheme, registration);
return () => {
if (resolvers.get(scheme) === registration) {
resolvers.delete(scheme);
}
};
}
export function getSecretResolver(
scheme: string,
): SecretResolverRegistration | undefined {
return resolvers.get(scheme);
}
export function requireSecretResolver(
scheme: string,
): SecretResolverRegistration {
const resolver = resolvers.get(scheme);
if (!resolver) {
throw new SecretResolverMissingError(scheme, findSchemeOwner(scheme));
}
return resolver;
}
/** Test seam. */
export function resetSecretResolverRegistryForTests(): void {
resolvers.clear();
ownerSource = () => [];
}
+30 -42
View File
@@ -1,15 +1,13 @@
import { createCurrentSecretSourceRepository } from "../database/repositories/factory.js";
import type { SecretSourceRecord } from "../database/repositories/secret-source-repository.js";
import {
isSecretReference,
parseSecretReference,
resolveConnectReference,
} from "../utils/onepassword-connect.js";
import { readSecretSourcePrivateAllowlist } from "../utils/secret-source-egress.js";
getSecretResolver,
requireSecretResolver,
} from "./connect/secret-resolver-registry.js";
/**
* Expands "op://vault/item/field" references in a resolved host's secret
* fields into the actual secrets, fetched from the user's secret source.
* Expands "<scheme>://..." references ("op://vault/item/field" for 1Password
* Connect, and so on) in a resolved host's secret fields into the actual
* secrets, resolved by whichever plugin registered that scheme through
* ctx.credentials.registerSecretResolver.
*
* Runs once per host resolution, at the single point where every subsystem
* gets its plaintext credentials - so terminal, SFTP, Docker, metrics and
@@ -30,6 +28,8 @@ export const SECRET_FIELDS = [
"telnetPassword",
] as const;
const REFERENCE_PATTERN = /^([a-z][a-z0-9+.-]*):\/\//i;
const CACHE_TTL_MS = 60_000;
const cache = new Map<string, { value: string; expiresAt: number }>();
@@ -38,37 +38,27 @@ export function clearExternalSecretCache(): void {
cache.clear();
}
export function isSecretReference(value: unknown): value is string {
return typeof value === "string" && REFERENCE_PATTERN.test(value.trim());
}
function referenceScheme(reference: string): string {
const match = REFERENCE_PATTERN.exec(reference.trim());
return (match?.[1] ?? "").toLowerCase();
}
export type SecretResolver = (
source: SecretSourceRecord,
userId: string,
reference: string,
) => Promise<string>;
async function defaultResolver(
source: SecretSourceRecord,
userId: string,
reference: string,
): Promise<string> {
const ref = parseSecretReference(reference);
if (!ref) throw new Error(`Invalid secret reference: ${reference}`);
const repository = createCurrentSecretSourceRepository();
return resolveConnectReference(
{
baseUrl: source.baseUrl,
token: repository.decryptToken(source),
allowedPrivateHosts: await readSecretSourcePrivateAllowlist(),
},
ref,
);
}
/** The user's own source first, else a shared one. */
export async function pickSecretSource(
userId: string,
): Promise<SecretSourceRecord | null> {
const sources =
await createCurrentSecretSourceRepository().listVisibleToUser(userId);
return (
sources.find((source) => source.userId === userId) ?? sources[0] ?? null
);
const scheme = referenceScheme(reference);
const resolver = requireSecretResolver(scheme);
return resolver.resolve(userId, reference);
}
export async function resolveSecretReference(
@@ -76,22 +66,15 @@ export async function resolveSecretReference(
reference: string,
deps: {
resolver?: SecretResolver;
pickSource?: (userId: string) => Promise<SecretSourceRecord | null>;
now?: () => number;
} = {},
): Promise<string> {
const now = deps.now ?? Date.now;
const source = await (deps.pickSource ?? pickSecretSource)(userId);
if (!source) {
throw new Error(
"This host uses a secret reference but no secret source is configured",
);
}
const cacheKey = `${source.id}:${reference.trim()}`;
const cacheKey = `${userId}:${reference.trim()}`;
const cached = cache.get(cacheKey);
if (cached && cached.expiresAt > now()) return cached.value;
const value = await (deps.resolver ?? defaultResolver)(source, reference);
const value = await (deps.resolver ?? defaultResolver)(userId, reference);
cache.set(cacheKey, { value, expiresAt: now() + CACHE_TTL_MS });
return value;
}
@@ -108,3 +91,8 @@ export async function resolveExternalSecretRefs(
host[field] = await resolveSecretReference(userId, value, deps);
}
}
/** Whether any plugin currently resolves this reference's scheme. */
export function hasSecretResolverFor(reference: string): boolean {
return !!getSecretResolver(referenceScheme(reference));
}
+43
View File
@@ -7,6 +7,12 @@
* The protocol credentials themselves stay in core, next to the host, because
* core's sharing model (per-recipient secret snapshots, personal overrides)
* is what decides which ones a shared recipient may use.
*
* registerSecretResolver is the other half: a plugin that resolves
* "<scheme>://..." references in a host's secret fields (secret-sources and
* "op://") instead of handing over a host's own stored secret. Needs
* auth:provide, like ctx.auth's registrations, and the scheme must be listed
* in contributes.auth.secretSchemes.
*/
import type {
@@ -14,9 +20,12 @@ import type {
PluginHostProtocol,
PluginProtocolTarget,
} from "@termix/plugin-sdk/backend";
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import { assertCapability } from "./permissions.js";
import { getActor } from "./actor.js";
import type { DisposableBag } from "./disposables.js";
import { registerSecretResolver } from "../hosts/connect/secret-resolver-registry.js";
type AuditFn = (
action: string,
@@ -26,6 +35,7 @@ type AuditFn = (
interface Deps {
manifest: PluginManifest;
bag: DisposableBag;
audit: AuditFn;
}
@@ -149,12 +159,45 @@ async function resolveRecipientAuth(
export function createPluginCredentials({
manifest,
bag,
audit,
}: Deps): PluginCredentials {
const pluginId = manifest.id;
const declared = manifest.capabilities;
return {
registerSecretResolver: (scheme, resolve) => {
if (!declared.includes("auth:provide")) {
throw new PluginCapabilityError(pluginId, "auth:provide");
}
if (!manifest.contributes?.auth?.secretSchemes?.includes(scheme)) {
throw new Error(
`Plugin ${pluginId} cannot register secret scheme "${scheme}": it is not listed in contributes.auth.secretSchemes`,
);
}
const dispose = registerSecretResolver(
scheme,
{ pluginId, pluginName: manifest.name },
async (userId, reference) => {
await assertCapability(pluginId, "auth:provide", declared);
const details = `secret reference for ${userId}`;
try {
const value = await resolve(userId, reference);
await audit("secret_resolve", details, { success: true });
return value;
} catch (error) {
await audit("secret_resolve", details, {
success: false,
errorMessage:
error instanceof Error ? error.message : String(error),
});
throw error;
}
},
);
bag.add(dispose, `secret scheme "${scheme}"`);
},
resolveHostProtocol: async (hostId, protocol) => {
const details = `${protocol} credentials for host ${hostId}`;
try {
+5 -1
View File
@@ -804,7 +804,11 @@ export function createPluginContext(
available: () => isElectronIpcAvailable(),
},
credentials: createPluginCredentials({ manifest, audit: auditCall }),
credentials: createPluginCredentials({
manifest,
bag: handle.bag,
audit: auditCall,
}),
audit: {
// Attribution comes from the runtime: the actor, never a plugin value.
+13
View File
@@ -15,6 +15,7 @@ import { PluginLoader, type LoadedPlugin } from "./loader.js";
import type { PluginPermissionContribution } from "./manifest.js";
import { invalidatePluginPermissionCache } from "./permissions.js";
import { setSshAuthTypeOwnerSource } from "../hosts/connect/auth-provider-registry.js";
import { setSecretResolverOwnerSource } from "../hosts/connect/secret-resolver-registry.js";
let loader: PluginLoader | null = null;
@@ -37,6 +38,18 @@ export function getPluginRuntime(): { loader: PluginLoader } {
})),
),
);
// Lets a secret reference whose scheme belongs to a disabled plugin name it.
setSecretResolverOwnerSource(() =>
(loader?.list() ?? []).flatMap((plugin) =>
(plugin.manifest.contributes?.auth?.secretSchemes ?? []).map(
(scheme) => ({
scheme,
pluginId: plugin.id,
pluginName: plugin.manifest.name,
}),
),
),
);
}
return { loader };
}
+4
View File
@@ -405,6 +405,10 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
const { runWakeOnLanSettingsMigration } =
await import("./utils/crypto-migration/wake-on-lan-settings-migration.js");
await runWakeOnLanSettingsMigration();
const { runSecretSourcesTokenMigration } =
await import("./utils/crypto-migration/secret-sources-token-migration.js");
await runSecretSourcesTokenMigration();
} catch (error) {
systemLogger.warn("Plugin runtime failed to initialize", {
operation: "plugin_init",
@@ -1,46 +1,38 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentSecretSourceRepository: () => ({
listVisibleToUser: async () => [],
decryptToken: () => "",
}),
createCurrentSettingsRepository: () => ({ get: async () => null }),
}));
const {
clearExternalSecretCache,
isSecretReference,
resolveExternalSecretRefs,
resolveSecretReference,
} = await import("../../hosts/external-secrets.js");
const { setSecretResolverOwnerSource, resetSecretResolverRegistryForTests } =
await import("../../hosts/connect/secret-resolver-registry.js");
const source = {
id: "src-1",
userId: "alice",
name: "1P",
kind: "onepassword-connect",
baseUrl: "https://connect.internal",
token: "enc",
shared: false,
createdAt: "",
updatedAt: "",
};
describe("isSecretReference", () => {
it("recognises a scheme:// reference without confusing it with a secret", () => {
expect(isSecretReference("op://v/i/f")).toBe(true);
expect(isSecretReference(" op://v/i/f")).toBe(true);
expect(isSecretReference("vault+https://x")).toBe(true);
expect(isSecretReference("hunter2")).toBe(false);
expect(isSecretReference(undefined)).toBe(false);
});
});
describe("external secret references", () => {
beforeEach(() => clearExternalSecretCache());
it("replaces references in the host's secret fields and leaves plain secrets alone", async () => {
const resolver = vi.fn(async (_s, ref: string) => `resolved:${ref}`);
const resolver = vi.fn(
async (_userId: string, ref: string) => `resolved:${ref}`,
);
const host: Record<string, unknown> = {
password: "op://Infra/box/password",
key: "-----BEGIN OPENSSH PRIVATE KEY-----",
sudoPassword: "op://Infra/box/sudo",
username: "op://not-a-secret-field",
};
await resolveExternalSecretRefs(host, "alice", {
resolver,
pickSource: async () => source,
});
await resolveExternalSecretRefs(host, "alice", { resolver });
expect(host.password).toBe("resolved:op://Infra/box/password");
expect(host.sudoPassword).toBe("resolved:op://Infra/box/sudo");
expect(host.key).toBe("-----BEGIN OPENSSH PRIVATE KEY-----");
@@ -48,10 +40,10 @@ describe("external secret references", () => {
expect(resolver).toHaveBeenCalledTimes(2);
});
it("caches a resolved reference per source for a minute", async () => {
it("caches a resolved reference per user for a minute", async () => {
const resolver = vi.fn(async () => "s3cret");
let clock = 1_000_000;
const deps = { resolver, pickSource: async () => source, now: () => clock };
const deps = { resolver, now: () => clock };
await resolveSecretReference("alice", "op://v/i/f", deps);
await resolveSecretReference("alice", "op://v/i/f", deps);
expect(resolver).toHaveBeenCalledTimes(1);
@@ -60,12 +52,26 @@ describe("external secret references", () => {
expect(resolver).toHaveBeenCalledTimes(2);
});
it("fails clearly when the user has no secret source", async () => {
it("fails clearly when no plugin resolves the scheme", async () => {
await expect(
resolveSecretReference("bob", "op://v/i/f", {
resolver: async () => "x",
pickSource: async () => null,
}),
).rejects.toThrow(/no secret source/);
resolveSecretReference("bob", "op://v/i/f", {}),
).rejects.toThrow(/needs the .* plugin|no enabled plugin resolves/);
});
it("names the plugin that would resolve a disabled scheme", async () => {
setSecretResolverOwnerSource(() => [
{
scheme: "op",
pluginId: "secret-sources",
pluginName: "Secret Sources",
},
]);
try {
await expect(
resolveSecretReference("bob", "op://v/i/f", {}),
).rejects.toThrow("needs the Secret Sources plugin");
} finally {
resetSecretResolverRegistryForTests();
}
});
});
@@ -60,9 +60,17 @@ vi.mock("../../utils/shared-host-auth-resolver.js", () => ({
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import { runAsActor } from "../../plugins/actor.js";
import {
getSecretResolver,
requireSecretResolver,
resetSecretResolverRegistryForTests,
} from "../../hosts/connect/secret-resolver-registry.js";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
function contextFor(capabilities: string[]) {
function contextFor(
capabilities: string[],
contributes?: PluginManifest["contributes"],
) {
const manifest = {
id: "demo",
name: "demo",
@@ -73,6 +81,7 @@ function contextFor(capabilities: string[]) {
category: "Productivity",
engine: { termix: ">=2.9.0", api: "1" },
capabilities,
contributes,
} as PluginManifest;
const handle = createPluginHandle("demo", { activate: () => {} });
return createPluginContext(manifest, handle);
@@ -104,6 +113,7 @@ beforeEach(() => {
state.resolution = null;
state.resolverCalls.length = 0;
state.hosts.set(7, { ...baseHost });
resetSecretResolverRegistryForTests();
});
describe("ctx.credentials.resolveHostProtocol", () => {
@@ -226,3 +236,72 @@ describe("ctx.credentials.resolveHostProtocol", () => {
expect(auditEntries.at(-1)).toMatchObject({ success: false });
});
});
describe("ctx.credentials.registerSecretResolver", () => {
it("refuses without auth:provide", () => {
grants.set("demo", []);
const ctx = contextFor([], { auth: { secretSchemes: ["op"] } });
expect(() =>
ctx.credentials.registerSecretResolver("op", async () => ""),
).toThrow(/auth:provide/);
});
it("refuses a scheme not listed in contributes.auth.secretSchemes", () => {
grants.set("demo", ["auth:provide"]);
const ctx = contextFor(["auth:provide"], { auth: {} });
expect(() =>
ctx.credentials.registerSecretResolver("op", async () => ""),
).toThrow(/secretSchemes/);
});
it("resolves through the registered scheme and audits the call", async () => {
grants.set("demo", ["auth:provide"]);
const ctx = contextFor(["auth:provide"], {
auth: { secretSchemes: ["op"] },
});
ctx.credentials.registerSecretResolver("op", async (userId, ref) => {
state.resolverCalls.push({ userId, ref });
return `resolved:${ref}`;
});
const registration = requireSecretResolver("op");
expect(registration.pluginId).toBe("demo");
const value = await registration.resolve("alice", "op://v/i/f");
expect(value).toBe("resolved:op://v/i/f");
expect(state.resolverCalls[0]).toMatchObject({
userId: "alice",
ref: "op://v/i/f",
});
expect(auditEntries.at(-1)).toMatchObject({
action: "plugin_secret_resolve",
success: true,
});
});
it("is removed when the plugin is deactivated", async () => {
grants.set("demo", ["auth:provide"]);
const manifest = {
id: "demo",
name: "demo",
version: "1.0.0",
description: "",
author: { name: "test" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: "1" },
capabilities: ["auth:provide"],
contributes: { auth: { secretSchemes: ["op"] } },
} as PluginManifest;
const handle = createPluginHandle("demo", { activate: () => {} });
const ctx = createPluginContext(manifest, handle);
ctx.credentials.registerSecretResolver("op", async () => "x");
await handle.bag.disposeAll();
expect(getSecretResolver("op")).toBeUndefined();
});
});
@@ -1,29 +0,0 @@
import { describe, expect, it } from "vitest";
import {
isSecretReference,
parseSecretReference,
} from "../../utils/onepassword-connect.js";
describe("1Password secret references", () => {
it("parses op://vault/item/field, ignoring a query suffix", () => {
expect(parseSecretReference("op://Infra/prod-db/password")).toEqual({
vault: "Infra",
item: "prod-db",
field: "password",
});
expect(
parseSecretReference(
"op://Infra/deploy key/private key?ssh-format=openssh",
),
).toEqual({ vault: "Infra", item: "deploy key", field: "private key" });
expect(parseSecretReference("op://Infra/only-two")).toBeNull();
expect(parseSecretReference("https://x")).toBeNull();
});
it("recognises references without confusing them with secrets", () => {
expect(isSecretReference("op://v/i/f")).toBe(true);
expect(isSecretReference(" op://v/i/f")).toBe(true);
expect(isSecretReference("hunter2")).toBe(false);
expect(isSecretReference(undefined)).toBe(false);
});
});
@@ -0,0 +1,206 @@
/**
* The secret source token migration.
*
* An upgrade must be lossless: every source's access token has to reach the
* secret-sources plugin's ctx.secrets, encrypted, and be gone from the
* table. Running it twice must not duplicate or clobber anything.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface Row {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
encrypted: boolean;
}
const state = vi.hoisted(() => ({
pluginRows: [] as Row[],
sources: new Map<string, Array<Record<string, unknown>>>(),
pluginInstalled: true,
deks: new Set<string>(),
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "secret-sources" ? { id } : null,
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
_pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
state.pluginRows.find(
(row) =>
row.scope === scope && row.scopeId === scopeId && row.key === key,
) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
encrypted = false,
) => {
state.pluginRows.push({
pluginId,
scope,
scopeId,
key,
value,
encrypted,
});
},
}),
}));
/** The SQL text of a drizzle sql`` query, identifiers included. */
function text(query: { queryChunks: unknown[] }): string {
return query.queryChunks
.map((chunk) => {
if (typeof chunk === "string") return chunk;
const value = (chunk as { value?: unknown; name?: unknown }) ?? {};
if (Array.isArray(value.value)) return value.value.join("");
if (typeof value.value === "string") return value.value;
if (typeof value.name === "string") return value.name;
return String(chunk);
})
.join("");
}
vi.mock("../../utils/crypto-migration/raw-rows.js", () => ({
selectRows: async (query: { queryChunks: unknown[] }) => {
const sqlText = text(query);
for (const [table, rows] of state.sources) {
if (new RegExp(`FROM ${table} `).test(sqlText)) {
return rows.filter((row) => row.token);
}
}
throw new Error(`no such table in: ${sqlText}`);
},
runStatement: async (query: { queryChunks: unknown[] }) => {
const sqlText = text(query);
for (const [table, rows] of state.sources) {
if (!sqlText.includes(`UPDATE ${table} `)) continue;
const id = query.queryChunks.find((chunk) => typeof chunk === "string");
for (const row of rows) if (row.id === id) row.token = null;
}
},
}));
vi.mock("../../utils/data-crypto.js", () => ({
DataCrypto: {
getUserDataKey: (userId: string) =>
state.deks.has(userId) ? Buffer.from(userId) : null,
},
}));
vi.mock("../../utils/lazy-field-encryption.js", () => ({
LazyFieldEncryption: {
// Stands in for FieldCrypto: "enc:<plain>" decrypts, anything else is a
// legacy plaintext value.
safeGetFieldValue: (value: string) => value.replace(/^enc:/, ""),
},
}));
vi.mock("../../utils/system-secret-crypto.js", () => ({
encryptSystemSecret: async (value: string) => `sysenc:${value}`,
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import { runSecretSourcesTokenMigration } from "../../utils/crypto-migration/secret-sources-token-migration.js";
const value = (scopeId: string | null, key: string) => {
const row = state.pluginRows.find(
(entry) =>
entry.scope === "secret" &&
entry.scopeId === scopeId &&
entry.key === key,
);
return row ? JSON.parse(row.value ?? "null") : undefined;
};
beforeEach(() => {
state.pluginRows = [];
state.sources = new Map([
[
"secret_sources",
[
{ id: "src-1", user_id: "u1", token: "enc:tok-one" },
{ id: "src-2", user_id: "u2", token: "tok-plain" },
],
],
]);
state.pluginInstalled = true;
state.deks = new Set(["u1", "u2"]);
});
describe("runSecretSourcesTokenMigration", () => {
it("moves each source's token into its owner's secrets, encrypted, and clears the column", async () => {
const result = await runSecretSourcesTokenMigration();
expect(result).toEqual({ moved: 2 });
const secret = state.pluginRows.find(
(row) => row.scope === "secret" && row.key === "source:src-1",
);
expect(secret).toMatchObject({
scopeId: "u1",
encrypted: true,
value: JSON.stringify("sysenc:tok-one"),
});
expect(value("u2", "source:src-2")).toBe("sysenc:tok-plain");
expect(
state.sources.get("secret_sources")!.every((row) => row.token === null),
).toBe(true);
});
it("leaves a token alone while its owner's data key is unavailable", async () => {
state.deks = new Set(["u1"]);
const result = await runSecretSourcesTokenMigration();
expect(result).toEqual({ moved: 1 });
expect(value("u2", "source:src-2")).toBeUndefined();
const row = state.sources
.get("secret_sources")!
.find((r) => r.id === "src-2");
expect(row?.token).toBe("tok-plain");
});
it("reads the adopted table once the plugin has renamed it", async () => {
state.sources = new Map([
[
"p_secret_sources_sources",
[{ id: "src-9", user_id: "u1", token: "enc:tok-late" }],
],
]);
await runSecretSourcesTokenMigration();
expect(value("u1", "source:src-9")).toBe("sysenc:tok-late");
});
it("changes nothing on a second run", async () => {
await runSecretSourcesTokenMigration();
const first = JSON.stringify(state.pluginRows);
const second = await runSecretSourcesTokenMigration();
expect(second).toEqual({ moved: 0 });
expect(JSON.stringify(state.pluginRows)).toBe(first);
});
it("does nothing until the plugin row exists", async () => {
state.pluginInstalled = false;
const result = await runSecretSourcesTokenMigration();
expect(result).toEqual({ moved: 0 });
expect(state.pluginRows).toEqual([]);
});
});
@@ -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);
}