mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25: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
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
/**
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user