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: