mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
feat: 1Password Connect secret sources for SSH credentials (#1341)
* feat: 1Password Connect secret sources for SSH credentials Hosts and credentials can hold op://vault/item/field references instead of secrets; they are resolved at connect time from the user's secret source (1Password Connect) at the single point where every subsystem receives plaintext credentials, so terminal, SFTP, Docker, metrics and tunnels all work without per-subsystem changes. Sources are per user, optionally shared, with the access token encrypted under the owner's data key; resolved values are cached briefly in memory. * style: format secret source changes
This commit is contained in:
@@ -30,6 +30,7 @@ import termixIdRoutes from "./routes/termix-id.js";
|
||||
import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
|
||||
import { registerTailscaleRoutes } from "./routes/tailscale-routes.js";
|
||||
import vaultRoutes from "./routes/vault.js";
|
||||
import secretSourceRoutes from "./routes/secret-sources.js";
|
||||
import alertRulesRoutes from "./routes/alert-rules-routes.js";
|
||||
import aiRoutes from "../ai/index.js";
|
||||
import automationsRoutes from "./routes/automations.js";
|
||||
@@ -1766,6 +1767,7 @@ app.use("/termix-id", termixIdRoutes);
|
||||
registerAuditLogRoutes(app, authenticateJWT);
|
||||
registerTailscaleRoutes(app, authenticateJWT);
|
||||
app.use("/vault", vaultRoutes);
|
||||
app.use("/secret-sources", secretSourceRoutes);
|
||||
// Before the alert routes, which are mounted at the root and would otherwise
|
||||
// have first claim on the path.
|
||||
app.use("/automations", automationsRoutes);
|
||||
|
||||
@@ -609,6 +609,19 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
FOREIGN KEY (added_by) 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 api_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
|
||||
@@ -2016,3 +2016,35 @@ export const collabRoomMembers = mysqlTable(
|
||||
index("idx_collab_room_members_user").on(table.userId),
|
||||
],
|
||||
);
|
||||
|
||||
// --- 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: varchar("created_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
(table) => [index("idx_secret_sources_user").on(table.userId)],
|
||||
);
|
||||
|
||||
@@ -2017,3 +2017,35 @@ export const collabRoomMembers = pgTable(
|
||||
index("idx_collab_room_members_user").on(table.userId),
|
||||
],
|
||||
);
|
||||
|
||||
// --- 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: varchar("created_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [index("idx_secret_sources_user").on(table.userId)],
|
||||
);
|
||||
|
||||
@@ -2013,3 +2013,35 @@ export const collabRoomMembers = sqliteTable(
|
||||
index("idx_collab_room_members_user").on(table.userId),
|
||||
],
|
||||
);
|
||||
|
||||
// --- 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)],
|
||||
);
|
||||
|
||||
@@ -39,6 +39,7 @@ import { SessionRecordingRepository } from "./session-recording-repository.js";
|
||||
import { SessionRepository } from "./session-repository.js";
|
||||
import { SessionShareRepository } from "./session-share-repository.js";
|
||||
import { CollabRoomRepository } from "./collab-room-repository.js";
|
||||
import { SecretSourceRepository } from "./secret-source-repository.js";
|
||||
import { SettingsRepository } from "./settings-repository.js";
|
||||
import { SharedHostAuthOverrideRepository } from "./shared-host-auth-override-repository.js";
|
||||
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
|
||||
@@ -507,6 +508,13 @@ export function createCurrentUserRepository(): UserRepository {
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentSecretSourceRepository(): SecretSourceRepository {
|
||||
return new SecretSourceRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("secret_source_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentVaultProfileRepository(): VaultProfileRepository {
|
||||
return new VaultProfileRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
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?.();
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
createCurrentUserRepository,
|
||||
createCurrentTransferRecentRepository,
|
||||
createCurrentVaultProfileRepository,
|
||||
createCurrentSecretSourceRepository,
|
||||
createCurrentVaultTokenRepository,
|
||||
} from "../repositories/factory.js";
|
||||
|
||||
@@ -98,6 +99,7 @@ export async function deleteUserAndRelatedData(userId: string): Promise<void> {
|
||||
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 createCurrentTmuxSessionTagRepository().deleteByUserId(userId);
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
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;
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { getTelemetryEnvOverride } from "../../utils/analytics.js";
|
||||
import { AI_PRIVATE_ALLOWLIST_KEY, parseAllowlist } from "../../ai/egress.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,
|
||||
@@ -1204,6 +1205,25 @@ 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:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { WebSocket } from "ws";
|
||||
import { resolveExternalSecretRefs } from "./external-secrets.js";
|
||||
import { sshLogger, authLogger } from "../utils/logger.js";
|
||||
import { createCurrentHostResolutionRepository } from "../database/repositories/factory.js";
|
||||
interface ResolvedCredentials {
|
||||
@@ -63,6 +64,11 @@ export class SSHAuthManager {
|
||||
);
|
||||
|
||||
if (cred) {
|
||||
// Credentials may hold secret references instead of secrets.
|
||||
await resolveExternalSecretRefs(
|
||||
cred as unknown as Record<string, unknown>,
|
||||
this.context.userId,
|
||||
);
|
||||
resolvedCredentials = {
|
||||
username: (cred.username as string) || hostConfig.username,
|
||||
password: (cred.password as string) || undefined,
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
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";
|
||||
|
||||
/**
|
||||
* Expands "op://vault/item/field" references in a resolved host's secret
|
||||
* fields into the actual secrets, fetched from the user's secret source.
|
||||
*
|
||||
* Runs once per host resolution, at the single point where every subsystem
|
||||
* gets its plaintext credentials - so terminal, SFTP, Docker, metrics and
|
||||
* tunnels all see real secrets without knowing references exist.
|
||||
*
|
||||
* Host resolution is hot (status polls, fleets), so resolved values are
|
||||
* cached briefly in memory; a rotated secret shows up within CACHE_TTL_MS.
|
||||
*/
|
||||
|
||||
export const SECRET_FIELDS = [
|
||||
"password",
|
||||
"key",
|
||||
"keyPassword",
|
||||
"sudoPassword",
|
||||
"socks5Password",
|
||||
"rdpPassword",
|
||||
"vncPassword",
|
||||
"telnetPassword",
|
||||
] as const;
|
||||
|
||||
const CACHE_TTL_MS = 60_000;
|
||||
const cache = new Map<string, { value: string; expiresAt: number }>();
|
||||
|
||||
/** Test seam. */
|
||||
export function clearExternalSecretCache(): void {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
export type SecretResolver = (
|
||||
source: SecretSourceRecord,
|
||||
reference: string,
|
||||
) => Promise<string>;
|
||||
|
||||
async function defaultResolver(
|
||||
source: SecretSourceRecord,
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
export async function resolveSecretReference(
|
||||
userId: string,
|
||||
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 cached = cache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > now()) return cached.value;
|
||||
|
||||
const value = await (deps.resolver ?? defaultResolver)(source, reference);
|
||||
cache.set(cacheKey, { value, expiresAt: now() + CACHE_TTL_MS });
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Replaces every reference in the host's secret fields, in place. */
|
||||
export async function resolveExternalSecretRefs(
|
||||
host: Record<string, unknown>,
|
||||
userId: string,
|
||||
deps?: Parameters<typeof resolveSecretReference>[2],
|
||||
): Promise<void> {
|
||||
for (const field of SECRET_FIELDS) {
|
||||
const value = host[field];
|
||||
if (!isSecretReference(value)) continue;
|
||||
host[field] = await resolveSecretReference(userId, value, deps);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getErrorMessage } from "../utils/error-message.js";
|
||||
import { resolveExternalSecretRefs } from "./external-secrets.js";
|
||||
import {
|
||||
createCurrentHostResolutionRepository,
|
||||
createCurrentVaultProfileRepository,
|
||||
@@ -229,6 +230,14 @@ export async function resolveHostById(
|
||||
ownerEquivalent ? ownerId : userId,
|
||||
);
|
||||
|
||||
// "op://..." references become real secrets here, once, for everyone
|
||||
// downstream. They resolve in the context of whoever owns the secret
|
||||
// fields: the owner for their own host, the recipient for an override.
|
||||
await resolveExternalSecretRefs(
|
||||
host as Record<string, unknown>,
|
||||
sharedAuthResolution === "recipient-override" ? userId : ownerId,
|
||||
);
|
||||
|
||||
// Resolve a Vault SSH signer profile (shared settings, no secrets). The
|
||||
// certificate itself is obtained per-user at connect time via Vault OIDC.
|
||||
if (host.vaultProfileId && sharedAuthResolution !== "recipient-override") {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentSecretSourceRepository: () => ({
|
||||
listVisibleToUser: async () => [],
|
||||
decryptToken: () => "",
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({ get: async () => null }),
|
||||
}));
|
||||
|
||||
const {
|
||||
clearExternalSecretCache,
|
||||
resolveExternalSecretRefs,
|
||||
resolveSecretReference,
|
||||
} = await import("../../hosts/external-secrets.js");
|
||||
|
||||
const source = {
|
||||
id: "src-1",
|
||||
userId: "alice",
|
||||
name: "1P",
|
||||
kind: "onepassword-connect",
|
||||
baseUrl: "https://connect.internal",
|
||||
token: "enc",
|
||||
shared: false,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
};
|
||||
|
||||
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 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,
|
||||
});
|
||||
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-----");
|
||||
expect(host.username).toBe("op://not-a-secret-field");
|
||||
expect(resolver).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("caches a resolved reference per source for a minute", async () => {
|
||||
const resolver = vi.fn(async () => "s3cret");
|
||||
let clock = 1_000_000;
|
||||
const deps = { resolver, pickSource: async () => source, now: () => clock };
|
||||
await resolveSecretReference("alice", "op://v/i/f", deps);
|
||||
await resolveSecretReference("alice", "op://v/i/f", deps);
|
||||
expect(resolver).toHaveBeenCalledTimes(1);
|
||||
clock += 61_000;
|
||||
await resolveSecretReference("alice", "op://v/i/f", deps);
|
||||
expect(resolver).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("fails clearly when the user has no secret source", async () => {
|
||||
await expect(
|
||||
resolveSecretReference("bob", "op://v/i/f", {
|
||||
resolver: async () => "x",
|
||||
pickSource: async () => null,
|
||||
}),
|
||||
).rejects.toThrow(/no secret source/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
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,134 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
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);
|
||||
}
|
||||
@@ -245,6 +245,27 @@ export async function setStepCaPrivateEndpoints(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSecretSourcePrivateEndpoints(): Promise<string[]> {
|
||||
try {
|
||||
return (await authApi.get("/users/secret-source-private-endpoints")).data
|
||||
.hosts;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get secret source endpoint allowlist");
|
||||
}
|
||||
}
|
||||
|
||||
export async function setSecretSourcePrivateEndpoints(
|
||||
hosts: string[],
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
return (
|
||||
await authApi.patch("/users/secret-source-private-endpoints", { hosts })
|
||||
).data.hosts;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update secret source endpoint allowlist");
|
||||
}
|
||||
}
|
||||
|
||||
export async function setNotificationPrivateEndpoints(
|
||||
hosts: string[],
|
||||
): Promise<string[]> {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
|
||||
export interface SecretSource {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
kind: "onepassword-connect";
|
||||
baseUrl: string;
|
||||
shared: boolean;
|
||||
hasToken: boolean;
|
||||
owned: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SecretSourcePayload {
|
||||
name: string;
|
||||
kind?: "onepassword-connect";
|
||||
baseUrl: string;
|
||||
token?: string;
|
||||
shared?: boolean;
|
||||
}
|
||||
|
||||
export async function listSecretSources(): Promise<SecretSource[]> {
|
||||
try {
|
||||
return (await authApi.get("/secret-sources")).data.sources;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "list secret sources");
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSecretSource(
|
||||
payload: SecretSourcePayload,
|
||||
): Promise<SecretSource> {
|
||||
try {
|
||||
return (await authApi.post("/secret-sources", payload)).data.source;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "create secret source");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSecretSource(
|
||||
id: string,
|
||||
payload: Partial<SecretSourcePayload>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await authApi.put(`/secret-sources/${id}`, payload);
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update secret source");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSecretSource(id: string): Promise<void> {
|
||||
try {
|
||||
await authApi.delete(`/secret-sources/${id}`);
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "delete secret source");
|
||||
}
|
||||
}
|
||||
|
||||
export async function testSecretSource(
|
||||
id: string,
|
||||
): Promise<{ ok: boolean; vaults?: number; error?: string }> {
|
||||
try {
|
||||
return (await authApi.post(`/secret-sources/${id}/test`)).data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "test secret source");
|
||||
}
|
||||
}
|
||||
@@ -888,6 +888,22 @@
|
||||
"selectAVaultProfile": "Select a Vault profile...",
|
||||
"vaultProfileHint": "Settings come from the shared profile; you'll sign in to Vault via OIDC when you connect. No secrets are stored.",
|
||||
"vaultNewProfile": "New profile",
|
||||
"secretRefHint": "You can paste a 1Password reference (op://vault/item/field) instead of the secret; it is fetched from your secret source when connecting.",
|
||||
"secretSourcesManage": "Manage secret sources",
|
||||
"secretSourcesTitle": "Secret sources",
|
||||
"secretSourcesDesc": "External password managers Termix reads secrets from at connect time. Currently 1Password Connect (self-hosted). The access token is stored encrypted with your data key.",
|
||||
"secretSourceNew": "New source",
|
||||
"secretSourceUrlLabel": "Connect server URL",
|
||||
"secretSourceTokenLabel": "Connect access token",
|
||||
"secretSourceTokenKeep": "Leave empty to keep the current token",
|
||||
"secretSourceSharedLabel": "Share with all users (admins only; resolves while you are signed in)",
|
||||
"secretSourceShared": "shared",
|
||||
"secretSourceTest": "Test",
|
||||
"secretSourceTestOk": "Connected — {{count}} vault(s) visible",
|
||||
"secretSourceTestFailed": "Connection test failed",
|
||||
"secretSourceRequired": "Name, server URL and token are required",
|
||||
"secretSourceSaved": "Secret source saved",
|
||||
"secretSourceDeleted": "Secret source deleted",
|
||||
"vaultManageProfiles": "Manage Vault profiles",
|
||||
"vaultAddrLabel": "Vault Address",
|
||||
"vaultNamespaceLabel": "Namespace",
|
||||
@@ -3700,6 +3716,9 @@
|
||||
"stepCaProvisioner": "OIDC provisioner name",
|
||||
"stepCaSaved": "Step CA settings saved",
|
||||
"stepCaSaveFailed": "Failed to save Step CA settings",
|
||||
"secretSourcePrivateEndpoints": "Allowed private secret source hosts",
|
||||
"secretSourcePrivateEndpointsDesc": "Private hosts that secret sources (1Password Connect) may contact. Separate them with commas.",
|
||||
"updateSecretSourceEndpointsFailed": "Failed to update the secret source endpoint allowlist",
|
||||
"stepCaPrivateEndpoints": "Allowed private Step CA hosts",
|
||||
"stepCaPrivateEndpointsDesc": "Private hosts the Step CA certificate flow may contact: the CA itself and, if internal, your identity provider. Separate them with commas.",
|
||||
"updateStepCaEndpointsFailed": "Failed to update the Step CA endpoint allowlist",
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
getNotificationPrivateEndpoints,
|
||||
getStepCaPrivateEndpoints,
|
||||
setStepCaPrivateEndpoints as setStepCaPrivateEndpointsApi,
|
||||
getSecretSourcePrivateEndpoints,
|
||||
setSecretSourcePrivateEndpoints as setSecretSourcePrivateEndpointsApi,
|
||||
setAiGloballyEnabled as setAiGloballyEnabledApi,
|
||||
setAiPrivateEndpoints as setAiPrivateEndpointsApi,
|
||||
setNotificationPrivateEndpoints as setNotificationPrivateEndpointsApi,
|
||||
@@ -188,6 +190,8 @@ export function AdminSettingsPanel({
|
||||
const [stepCaPrivateEndpoints, setStepCaPrivateEndpoints] = useState<
|
||||
string[]
|
||||
>([]);
|
||||
const [secretSourcePrivateEndpoints, setSecretSourcePrivateEndpoints] =
|
||||
useState<string[]>([]);
|
||||
const [stepCaSettings, setStepCaSettings] = useState({
|
||||
caUrl: "",
|
||||
fingerprint: "",
|
||||
@@ -404,6 +408,7 @@ export function AdminSettingsPanel({
|
||||
aiEndpoints,
|
||||
notificationEndpoints,
|
||||
stepCaEndpoints,
|
||||
secretSourceEndpoints,
|
||||
imageStorage,
|
||||
] = await Promise.allSettled([
|
||||
getRegistrationAllowed(),
|
||||
@@ -424,6 +429,7 @@ export function AdminSettingsPanel({
|
||||
getAiPrivateEndpoints(),
|
||||
getNotificationPrivateEndpoints(),
|
||||
getStepCaPrivateEndpoints(),
|
||||
getSecretSourcePrivateEndpoints(),
|
||||
getTerminalImageStorageSettings(),
|
||||
]);
|
||||
|
||||
@@ -479,6 +485,9 @@ export function AdminSettingsPanel({
|
||||
if (stepCaEndpoints.status === "fulfilled") {
|
||||
setStepCaPrivateEndpoints(stepCaEndpoints.value);
|
||||
}
|
||||
if (secretSourceEndpoints.status === "fulfilled") {
|
||||
setSecretSourcePrivateEndpoints(secretSourceEndpoints.value);
|
||||
}
|
||||
if (notificationEndpoints.status === "fulfilled") {
|
||||
setNotificationPrivateEndpoints(notificationEndpoints.value);
|
||||
}
|
||||
@@ -652,6 +661,19 @@ export function AdminSettingsPanel({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveSecretSourcePrivateEndpoints(hosts: string[]) {
|
||||
const previous = secretSourcePrivateEndpoints;
|
||||
setSecretSourcePrivateEndpoints(hosts);
|
||||
try {
|
||||
setSecretSourcePrivateEndpoints(
|
||||
await setSecretSourcePrivateEndpointsApi(hosts),
|
||||
);
|
||||
} catch {
|
||||
setSecretSourcePrivateEndpoints(previous);
|
||||
toast.error(t("admin.updateSecretSourceEndpointsFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveStepCaPrivateEndpoints(hosts: string[]) {
|
||||
const previous = stepCaPrivateEndpoints;
|
||||
setStepCaPrivateEndpoints(hosts);
|
||||
@@ -1218,6 +1240,10 @@ export function AdminSettingsPanel({
|
||||
notificationPrivateEndpoints={notificationPrivateEndpoints}
|
||||
stepCaPrivateEndpoints={stepCaPrivateEndpoints}
|
||||
onSaveStepCaPrivateEndpoints={handleSaveStepCaPrivateEndpoints}
|
||||
secretSourcePrivateEndpoints={secretSourcePrivateEndpoints}
|
||||
onSaveSecretSourcePrivateEndpoints={
|
||||
handleSaveSecretSourcePrivateEndpoints
|
||||
}
|
||||
stepCaSettings={stepCaSettings}
|
||||
setStepCaSettings={setStepCaSettings}
|
||||
handleSaveStepCaSettings={handleSaveStepCaSettings}
|
||||
|
||||
@@ -35,6 +35,8 @@ type GeneralSettingsSectionProps = {
|
||||
onSaveNotificationPrivateEndpoints: (hosts: string[]) => void;
|
||||
stepCaPrivateEndpoints: string[];
|
||||
onSaveStepCaPrivateEndpoints: (hosts: string[]) => void;
|
||||
secretSourcePrivateEndpoints: string[];
|
||||
onSaveSecretSourcePrivateEndpoints: (hosts: string[]) => void;
|
||||
stepCaSettings: { caUrl: string; fingerprint: string; provisioner: string };
|
||||
setStepCaSettings: Dispatch<
|
||||
SetStateAction<{ caUrl: string; fingerprint: string; provisioner: string }>
|
||||
@@ -96,6 +98,8 @@ export function AdminGeneralSettingsSection({
|
||||
onSaveNotificationPrivateEndpoints,
|
||||
stepCaPrivateEndpoints,
|
||||
onSaveStepCaPrivateEndpoints,
|
||||
secretSourcePrivateEndpoints,
|
||||
onSaveSecretSourcePrivateEndpoints,
|
||||
stepCaSettings,
|
||||
setStepCaSettings,
|
||||
handleSaveStepCaSettings,
|
||||
@@ -249,6 +253,28 @@ export function AdminGeneralSettingsSection({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5 py-2">
|
||||
<span className="text-xs font-medium">
|
||||
{t("admin.secretSourcePrivateEndpoints")}
|
||||
</span>
|
||||
<span className="text-[11px] leading-snug text-muted-foreground">
|
||||
{t("admin.secretSourcePrivateEndpointsDesc")}
|
||||
</span>
|
||||
<Input
|
||||
className="rounded-none"
|
||||
defaultValue={secretSourcePrivateEndpoints.join(", ")}
|
||||
placeholder="connect.internal, 10.0.0.5"
|
||||
onBlur={(event) =>
|
||||
onSaveSecretSourcePrivateEndpoints(
|
||||
event.target.value
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 border-t border-border pt-3 mt-2">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("admin.stepCa")}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { useRef, useState } from "react";
|
||||
import {
|
||||
SecretReferenceHint,
|
||||
SecretSourceManager,
|
||||
} from "./SecretSourceManager";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
import { Copy, Info, Lock, Upload, X } from "lucide-react";
|
||||
@@ -42,6 +46,7 @@ export function CredentialEditorView({
|
||||
// shows a "Save as New" action that clones it and reassigns the host.
|
||||
saveAsNewHost?: Host | "new";
|
||||
}) {
|
||||
const [showSecretSources, setShowSecretSources] = useState(false);
|
||||
const [credForm, setCredForm] = useState(() => ({
|
||||
name: credential?.name ?? "",
|
||||
username: credential?.username ?? "",
|
||||
@@ -291,7 +296,15 @@ export function CredentialEditorView({
|
||||
value={credForm.password}
|
||||
onChange={(e) => setCredField("password", e.target.value)}
|
||||
/>
|
||||
<SecretReferenceHint
|
||||
onManage={() => setShowSecretSources((v) => !v)}
|
||||
/>
|
||||
</div>
|
||||
{showSecretSources && (
|
||||
<SecretSourceManager
|
||||
onClose={() => setShowSecretSources(false)}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="p-3 border border-border bg-muted/20">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-2">
|
||||
|
||||
@@ -94,6 +94,10 @@ import {
|
||||
} from "./HostEditorGuacamoleTabs";
|
||||
import { HostStatsTab } from "./HostEditorStatsTab";
|
||||
import { VaultProfileManager } from "./VaultProfileManager";
|
||||
import {
|
||||
SecretReferenceHint,
|
||||
SecretSourceManager,
|
||||
} from "./SecretSourceManager";
|
||||
import { findHostByTunnelEndpoint } from "@/features/tunnel/tunnel-endpoints";
|
||||
import {
|
||||
toCredentialOption,
|
||||
@@ -190,6 +194,7 @@ export function HostEditor({
|
||||
const [isOidcUser, setIsOidcUser] = useState(false);
|
||||
const [vaultProfiles, setVaultProfiles] = useState<VaultProfile[]>([]);
|
||||
const [showVaultManager, setShowVaultManager] = useState(false);
|
||||
const [showSecretSources, setShowSecretSources] = useState(false);
|
||||
const [quickCredentialName, setQuickCredentialName] = useState("");
|
||||
const [creatingQuickCredential, setCreatingQuickCredential] = useState(false);
|
||||
const [showQuickCredentialDialog, setShowQuickCredentialDialog] =
|
||||
@@ -697,8 +702,17 @@ export function HostEditor({
|
||||
}}
|
||||
onChange={(e) => setField("password", e.target.value)}
|
||||
/>
|
||||
<SecretReferenceHint
|
||||
onManage={() => setShowSecretSources((v) => !v)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(authMethod === "password" || authMethod === "key") &&
|
||||
showSecretSources && (
|
||||
<SecretSourceManager
|
||||
onClose={() => setShowSecretSources(false)}
|
||||
/>
|
||||
)}
|
||||
{authMethod === "key" && (
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5 col-span-2">
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { KeyRound, Pencil, Plus, Trash2, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { PasswordInput } from "@/components/password-input";
|
||||
import { getErrorMessage } from "@/lib/error-message";
|
||||
import {
|
||||
createSecretSource,
|
||||
deleteSecretSource,
|
||||
listSecretSources,
|
||||
testSecretSource,
|
||||
updateSecretSource,
|
||||
type SecretSource,
|
||||
} from "@/api/secret-sources-api";
|
||||
|
||||
/** One line under a secret field: references are allowed, here is where to set them up. */
|
||||
export function SecretReferenceHint({ onManage }: { onManage: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.secretRefHint")}{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="text-accent-brand hover:underline"
|
||||
onClick={onManage}
|
||||
>
|
||||
{t("hosts.secretSourcesManage")}
|
||||
</button>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
type FormState = {
|
||||
id?: string;
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
shared: boolean;
|
||||
};
|
||||
|
||||
const emptyForm: FormState = {
|
||||
name: "",
|
||||
baseUrl: "",
|
||||
token: "",
|
||||
shared: false,
|
||||
};
|
||||
|
||||
export function SecretSourceManager({ onClose }: { onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [sources, setSources] = useState<SecretSource[]>([]);
|
||||
const [form, setForm] = useState<FormState | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState<string | null>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try {
|
||||
setSources(await listSecretSources());
|
||||
} catch (e) {
|
||||
toast.error(getErrorMessage(e));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
const setField = <K extends keyof FormState>(key: K, value: FormState[K]) =>
|
||||
setForm((prev) => (prev ? { ...prev, [key]: value } : prev));
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form) return;
|
||||
if (
|
||||
!form.name.trim() ||
|
||||
!form.baseUrl.trim() ||
|
||||
(!form.id && !form.token)
|
||||
) {
|
||||
toast.error(t("hosts.secretSourceRequired"));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (form.id) {
|
||||
await updateSecretSource(form.id, {
|
||||
name: form.name,
|
||||
baseUrl: form.baseUrl,
|
||||
shared: form.shared,
|
||||
...(form.token ? { token: form.token } : {}),
|
||||
});
|
||||
} else {
|
||||
await createSecretSource({
|
||||
name: form.name,
|
||||
baseUrl: form.baseUrl,
|
||||
token: form.token,
|
||||
shared: form.shared,
|
||||
});
|
||||
}
|
||||
toast.success(t("hosts.secretSourceSaved"));
|
||||
setForm(null);
|
||||
await reload();
|
||||
} catch (e) {
|
||||
toast.error(getErrorMessage(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (source: SecretSource) => {
|
||||
try {
|
||||
await deleteSecretSource(source.id);
|
||||
toast.success(t("hosts.secretSourceDeleted"));
|
||||
await reload();
|
||||
} catch (e) {
|
||||
toast.error(getErrorMessage(e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async (source: SecretSource) => {
|
||||
setTesting(source.id);
|
||||
try {
|
||||
const result = await testSecretSource(source.id);
|
||||
if (result.ok) {
|
||||
toast.success(
|
||||
t("hosts.secretSourceTestOk", { count: result.vaults ?? 0 }),
|
||||
);
|
||||
} else {
|
||||
toast.error(result.error ?? t("hosts.secretSourceTestFailed"));
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(getErrorMessage(e));
|
||||
} finally {
|
||||
setTesting(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 col-span-2 border border-border bg-muted/20 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.secretSourcesTitle")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.secretSourcesDesc")}
|
||||
</p>
|
||||
|
||||
{!form && (
|
||||
<>
|
||||
{sources.map((source) => (
|
||||
<div
|
||||
key={source.id}
|
||||
className="flex items-center gap-2 border border-border bg-background px-2 py-1.5 text-xs"
|
||||
>
|
||||
<KeyRound className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="truncate">
|
||||
{source.name}
|
||||
{source.shared && (
|
||||
<span className="ml-1 text-[9px] uppercase text-muted-foreground">
|
||||
{t("hosts.secretSourceShared")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="truncate text-[10px] text-muted-foreground">
|
||||
{source.baseUrl}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 text-[10px]"
|
||||
disabled={testing === source.id}
|
||||
onClick={() => void handleTest(source)}
|
||||
>
|
||||
{t("hosts.secretSourceTest")}
|
||||
</Button>
|
||||
{source.owned && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() =>
|
||||
setForm({
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
baseUrl: source.baseUrl,
|
||||
token: "",
|
||||
shared: source.shared,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
onClick={() => void handleDelete(source)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="self-start border-accent-brand/40 text-accent-brand"
|
||||
onClick={() => setForm(emptyForm)}
|
||||
>
|
||||
<Plus className="size-3 mr-1" /> {t("hosts.secretSourceNew")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{form && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.friendlyNameLabel")}
|
||||
</label>
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
placeholder="Team 1Password"
|
||||
value={form.name}
|
||||
onChange={(e) => setField("name", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.secretSourceUrlLabel")}
|
||||
</label>
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
placeholder="https://connect.internal:8080"
|
||||
value={form.baseUrl}
|
||||
onChange={(e) => setField("baseUrl", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.secretSourceTokenLabel")}
|
||||
</label>
|
||||
<PasswordInput
|
||||
className="h-8 text-xs pr-8"
|
||||
placeholder={
|
||||
form.id ? t("hosts.secretSourceTokenKeep") : "eyJhbGciOi..."
|
||||
}
|
||||
value={form.token}
|
||||
onChange={(e) => setField("token", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.shared}
|
||||
onChange={(e) => setField("shared", e.target.checked)}
|
||||
/>
|
||||
{t("hosts.secretSourceSharedLabel")}
|
||||
</label>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setForm(null)}
|
||||
disabled={saving}
|
||||
>
|
||||
{t("hosts.cancelBtn")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-accent-brand/40 text-accent-brand"
|
||||
onClick={() => void handleSave()}
|
||||
disabled={saving}
|
||||
>
|
||||
{form.id ? t("common.save") : t("common.create")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user