mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: give plugins their own settings and one settings screen
This commit is contained in:
1 parent
cc15559ea1
commit
49f0286a14
66 files changed
+36497
-451
No files matched your search
@@ -751,6 +751,21 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
FOREIGN KEY (plugin_id) REFERENCES plugins (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plugin_settings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
plugin_id TEXT NOT NULL,
|
||||
scope TEXT NOT NULL,
|
||||
scope_id TEXT,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT,
|
||||
encrypted INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (plugin_id, scope, scope_id, key),
|
||||
FOREIGN KEY (plugin_id) REFERENCES plugins (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_settings_plugin_scope ON plugin_settings (plugin_id, scope);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rbac_known_permissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
permission TEXT NOT NULL,
|
||||
|
||||
@@ -2252,6 +2252,49 @@ export const pluginStorage = mysqlTable(
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Values for the settings fields a plugin declares in contributes.settings.
|
||||
*
|
||||
* scope_id is polymorphic: null for admin scope, a user id for user scope, a
|
||||
* host id rendered as text for host scope. That is why it carries no foreign
|
||||
* key - one column cannot point at two tables - so the user and host delete
|
||||
* paths remove these rows explicitly. Cascading with the plugin is a real FK,
|
||||
* because uninstalling should leave nothing behind.
|
||||
*
|
||||
* Secret fields are encrypted with the system key before they land here, and
|
||||
* `encrypted` records which rows that applies to so a read knows to decrypt.
|
||||
*/
|
||||
export const pluginSettings = mysqlTable(
|
||||
"plugin_settings",
|
||||
{
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
pluginId: varchar("plugin_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||
/** admin | user | host */
|
||||
scope: varchar("scope", { length: 255 }).notNull(),
|
||||
scopeId: varchar("scope_id", { length: 255 }),
|
||||
key: varchar("key", { length: 255 }).notNull(),
|
||||
/** JSON-encoded, so a field keeps its declared type across a round trip. */
|
||||
value: text("value"),
|
||||
encrypted: boolean("encrypted")
|
||||
.notNull()
|
||||
.default(false),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("idx_plugin_settings_scope_key").on(
|
||||
table.pluginId,
|
||||
table.scope,
|
||||
table.scopeId,
|
||||
table.key,
|
||||
),
|
||||
index("idx_plugin_settings_plugin_scope").on(table.pluginId, table.scope),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Which of a plugin's migrations have been applied.
|
||||
*
|
||||
|
||||
@@ -2253,6 +2253,49 @@ export const pluginStorage = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Values for the settings fields a plugin declares in contributes.settings.
|
||||
*
|
||||
* scope_id is polymorphic: null for admin scope, a user id for user scope, a
|
||||
* host id rendered as text for host scope. That is why it carries no foreign
|
||||
* key - one column cannot point at two tables - so the user and host delete
|
||||
* paths remove these rows explicitly. Cascading with the plugin is a real FK,
|
||||
* because uninstalling should leave nothing behind.
|
||||
*
|
||||
* Secret fields are encrypted with the system key before they land here, and
|
||||
* `encrypted` records which rows that applies to so a read knows to decrypt.
|
||||
*/
|
||||
export const pluginSettings = pgTable(
|
||||
"plugin_settings",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
pluginId: varchar("plugin_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||
/** admin | user | host */
|
||||
scope: varchar("scope", { length: 255 }).notNull(),
|
||||
scopeId: varchar("scope_id", { length: 255 }),
|
||||
key: varchar("key", { length: 255 }).notNull(),
|
||||
/** JSON-encoded, so a field keeps its declared type across a round trip. */
|
||||
value: text("value"),
|
||||
encrypted: boolean("encrypted")
|
||||
.notNull()
|
||||
.default(false),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("idx_plugin_settings_scope_key").on(
|
||||
table.pluginId,
|
||||
table.scope,
|
||||
table.scopeId,
|
||||
table.key,
|
||||
),
|
||||
index("idx_plugin_settings_plugin_scope").on(table.pluginId, table.scope),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Which of a plugin's migrations have been applied.
|
||||
*
|
||||
|
||||
@@ -2251,6 +2251,49 @@ export const pluginStorage = sqliteTable(
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Values for the settings fields a plugin declares in contributes.settings.
|
||||
*
|
||||
* scope_id is polymorphic: null for admin scope, a user id for user scope, a
|
||||
* host id rendered as text for host scope. That is why it carries no foreign
|
||||
* key - one column cannot point at two tables - so the user and host delete
|
||||
* paths remove these rows explicitly. Cascading with the plugin is a real FK,
|
||||
* because uninstalling should leave nothing behind.
|
||||
*
|
||||
* Secret fields are encrypted with the system key before they land here, and
|
||||
* `encrypted` records which rows that applies to so a read knows to decrypt.
|
||||
*/
|
||||
export const pluginSettings = sqliteTable(
|
||||
"plugin_settings",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
pluginId: text("plugin_id")
|
||||
.notNull()
|
||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||
/** admin | user | host */
|
||||
scope: text("scope").notNull(),
|
||||
scopeId: text("scope_id"),
|
||||
key: text("key").notNull(),
|
||||
/** JSON-encoded, so a field keeps its declared type across a round trip. */
|
||||
value: text("value"),
|
||||
encrypted: integer("encrypted", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("idx_plugin_settings_scope_key").on(
|
||||
table.pluginId,
|
||||
table.scope,
|
||||
table.scopeId,
|
||||
table.key,
|
||||
),
|
||||
index("idx_plugin_settings_plugin_scope").on(table.pluginId, table.scope),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Which of a plugin's migrations have been applied.
|
||||
*
|
||||
|
||||
@@ -34,6 +34,7 @@ import { OpenTabRepository } from "./open-tab-repository.js";
|
||||
import { OpksshTokenRepository } from "./opkssh-token-repository.js";
|
||||
import { PluginRepository } from "./plugin-repository.js";
|
||||
import { PluginStorageRepository } from "./plugin-storage-repository.js";
|
||||
import { PluginSettingsRepository } from "./plugin-settings-repository.js";
|
||||
import { PluginMigrationRepository } from "./plugin-migration-repository.js";
|
||||
import { PluginPermissionGrantRepository } from "./plugin-permission-grant-repository.js";
|
||||
import { PluginRegistryRepository } from "./plugin-registry-repository.js";
|
||||
@@ -395,6 +396,13 @@ export function createCurrentPluginStorageRepository(): PluginStorageRepository
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentPluginSettingsRepository(): PluginSettingsRepository {
|
||||
return new PluginSettingsRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("plugin_settings_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentPluginMigrationRepository(): PluginMigrationRepository {
|
||||
return new PluginMigrationRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import { randomUUID } from "crypto";
|
||||
import { hostAccess, hosts } from "../db/schema.js";
|
||||
import { hostAccess, hosts, pluginSettings } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
@@ -338,6 +338,25 @@ export class HostRepository {
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
// Bulk path, so it never passes through deleteAccessForHost: clear the
|
||||
// host-scope plugin settings for these hosts before the rows go.
|
||||
const owned = await this.context.drizzle
|
||||
.select({ id: hosts.id })
|
||||
.from(hosts)
|
||||
.where(eq(hosts.userId, userId));
|
||||
|
||||
if (owned.length > 0) {
|
||||
await this.context.drizzle.delete(pluginSettings).where(
|
||||
and(
|
||||
eq(pluginSettings.scope, "host"),
|
||||
inArray(
|
||||
pluginSettings.scopeId,
|
||||
owned.map((row) => String(row.id)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const result = await this.context.drizzle
|
||||
.delete(hosts)
|
||||
.where(eq(hosts.userId, userId));
|
||||
@@ -350,6 +369,18 @@ export class HostRepository {
|
||||
}
|
||||
|
||||
async deleteAccessForHost(hostId: number): Promise<number> {
|
||||
// plugin_settings.scope_id is polymorphic, so it has no foreign key to
|
||||
// ssh_data and the engine will not cascade it. Cleared alongside the
|
||||
// access rows, which every host delete path already goes through.
|
||||
await this.context.drizzle
|
||||
.delete(pluginSettings)
|
||||
.where(
|
||||
and(
|
||||
eq(pluginSettings.scope, "host"),
|
||||
eq(pluginSettings.scopeId, String(hostId)),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await this.context.drizzle
|
||||
.delete(hostAccess)
|
||||
.where(eq(hostAccess.hostId, hostId));
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { and, eq, inArray, isNull } from "drizzle-orm";
|
||||
import { pluginSettings } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import { insertReturning, updateReturning } from "./returning.js";
|
||||
|
||||
export type PluginSettingsRecord = typeof pluginSettings.$inferSelect;
|
||||
|
||||
export type PluginSettingsScope = "admin" | "user" | "host";
|
||||
|
||||
/**
|
||||
* Values behind ctx.settings and the /plugins/:id/settings routes.
|
||||
*
|
||||
* scope_id is null for admin scope, so every lookup has to use IS NULL rather
|
||||
* than = NULL: SQL equality against null never matches, which would make an
|
||||
* admin setting look unset no matter how often it was written.
|
||||
*/
|
||||
export class PluginSettingsRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
private scopeMatch(
|
||||
pluginId: string,
|
||||
scope: PluginSettingsScope,
|
||||
scopeId: string | null,
|
||||
) {
|
||||
return and(
|
||||
eq(pluginSettings.pluginId, pluginId),
|
||||
eq(pluginSettings.scope, scope),
|
||||
scopeId === null
|
||||
? isNull(pluginSettings.scopeId)
|
||||
: eq(pluginSettings.scopeId, scopeId),
|
||||
);
|
||||
}
|
||||
|
||||
async get(
|
||||
pluginId: string,
|
||||
scope: PluginSettingsScope,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
): Promise<PluginSettingsRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(pluginSettings)
|
||||
.where(
|
||||
and(
|
||||
this.scopeMatch(pluginId, scope, scopeId),
|
||||
eq(pluginSettings.key, key),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async getAll(
|
||||
pluginId: string,
|
||||
scope: PluginSettingsScope,
|
||||
scopeId: string | null,
|
||||
): Promise<PluginSettingsRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(pluginSettings)
|
||||
.where(this.scopeMatch(pluginId, scope, scopeId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Every row for a set of scope ids, in one query.
|
||||
*
|
||||
* The host list would otherwise run one query per host, which is the shape
|
||||
* that made other list endpoints slow at a few hundred hosts.
|
||||
*/
|
||||
async getAllForScopeIds(
|
||||
scope: PluginSettingsScope,
|
||||
scopeIds: string[],
|
||||
): Promise<PluginSettingsRecord[]> {
|
||||
if (scopeIds.length === 0) return [];
|
||||
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(pluginSettings)
|
||||
.where(
|
||||
and(
|
||||
eq(pluginSettings.scope, scope),
|
||||
inArray(pluginSettings.scopeId, scopeIds),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async set(
|
||||
pluginId: string,
|
||||
scope: PluginSettingsScope,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
encrypted = false,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<void> {
|
||||
const where = and(
|
||||
this.scopeMatch(pluginId, scope, scopeId),
|
||||
eq(pluginSettings.key, key),
|
||||
)!;
|
||||
|
||||
const existing = await this.context.drizzle
|
||||
.select()
|
||||
.from(pluginSettings)
|
||||
.where(where)
|
||||
.limit(1);
|
||||
|
||||
if (existing[0]) {
|
||||
await updateReturning(
|
||||
this.context,
|
||||
pluginSettings,
|
||||
{ value, encrypted, updatedAt: now },
|
||||
where,
|
||||
);
|
||||
} else {
|
||||
await insertReturning(this.context, pluginSettings, {
|
||||
pluginId,
|
||||
scope,
|
||||
scopeId,
|
||||
key,
|
||||
value,
|
||||
encrypted,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async delete(
|
||||
pluginId: string,
|
||||
scope: PluginSettingsScope,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(pluginSettings)
|
||||
.where(
|
||||
and(
|
||||
this.scopeMatch(pluginId, scope, scopeId),
|
||||
eq(pluginSettings.key, key),
|
||||
),
|
||||
);
|
||||
|
||||
const affected = rowsAffected(result) > 0;
|
||||
if (affected) await this.afterWrite();
|
||||
return affected;
|
||||
}
|
||||
|
||||
async deleteByPlugin(pluginId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(pluginSettings)
|
||||
.where(eq(pluginSettings.pluginId, pluginId));
|
||||
|
||||
const affected = rowsAffected(result);
|
||||
if (affected > 0) await this.afterWrite();
|
||||
return affected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes every plugin's rows for one user or host.
|
||||
*
|
||||
* scope_id carries no foreign key, so the user and host delete paths call
|
||||
* this instead of relying on the engine to cascade.
|
||||
*/
|
||||
async deleteByScope(
|
||||
scope: PluginSettingsScope,
|
||||
scopeId: string,
|
||||
): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(pluginSettings)
|
||||
.where(
|
||||
and(
|
||||
eq(pluginSettings.scope, scope),
|
||||
eq(pluginSettings.scopeId, scopeId),
|
||||
),
|
||||
);
|
||||
|
||||
const affected = rowsAffected(result);
|
||||
if (affected > 0) await this.afterWrite();
|
||||
return affected;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { asc, eq, inArray, like, sql } from "drizzle-orm";
|
||||
import { users } from "../db/schema.js";
|
||||
import { and, asc, eq, inArray, like, sql } from "drizzle-orm";
|
||||
import { pluginSettings, users } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import {
|
||||
countValue,
|
||||
@@ -199,6 +199,15 @@ export class UserRepository {
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<boolean> {
|
||||
// plugin_settings.scope_id is polymorphic, so it carries no foreign key to
|
||||
// users and the engine will not cascade it. Removed here rather than in a
|
||||
// route so every caller that deletes a user is covered.
|
||||
await this.context.drizzle
|
||||
.delete(pluginSettings)
|
||||
.where(
|
||||
and(eq(pluginSettings.scope, "user"), eq(pluginSettings.scopeId, id)),
|
||||
);
|
||||
|
||||
const result = await this.context.drizzle
|
||||
.delete(users)
|
||||
.where(eq(users.id, id));
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* The pluginSettings map attached to a host on its way out.
|
||||
*
|
||||
* Without this the host editor would need one request per plugin per host,
|
||||
* which is the shape that makes a list of a few hundred hosts slow. One query
|
||||
* covers the whole list instead.
|
||||
*
|
||||
* Kept out of transformHostResponse because that function is synchronous and
|
||||
* shared by every host read path. Secrets are always redacted here, so nothing
|
||||
* on this path has to decrypt and the async work stays in one place.
|
||||
*/
|
||||
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import { createCurrentPluginSettingsRepository } from "../repositories/factory.js";
|
||||
import type { PluginSettingsRecord } from "../repositories/plugin-settings-repository.js";
|
||||
import { declaredFields, resolveFieldValue } from "../../plugins/settings.js";
|
||||
import { getPluginRuntime } from "../../plugins/index.js";
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
|
||||
export type HostPluginSettings = Record<string, Record<string, unknown>>;
|
||||
|
||||
/** Enabled plugins that declare host-scope settings, with their manifests. */
|
||||
function hostSettingsPlugins(): PluginManifest[] {
|
||||
try {
|
||||
const { loader } = getPluginRuntime();
|
||||
return loader
|
||||
.list()
|
||||
.filter((plugin) => plugin.state === "active")
|
||||
.map((plugin) => plugin.manifest)
|
||||
.filter(
|
||||
(manifest): manifest is PluginManifest =>
|
||||
!!manifest && !!manifest.contributes?.settings?.host,
|
||||
);
|
||||
} catch {
|
||||
// The runtime is not up in every context that renders a host.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the map for a set of hosts in one query.
|
||||
*
|
||||
* Returns an empty map when no enabled plugin declares host settings, so the
|
||||
* common case costs nothing.
|
||||
*/
|
||||
export async function loadHostPluginSettings(
|
||||
hostIds: number[],
|
||||
): Promise<Map<number, HostPluginSettings>> {
|
||||
const result = new Map<number, HostPluginSettings>();
|
||||
const manifests = hostSettingsPlugins();
|
||||
if (manifests.length === 0 || hostIds.length === 0) return result;
|
||||
|
||||
let rows: PluginSettingsRecord[] = [];
|
||||
try {
|
||||
rows = await createCurrentPluginSettingsRepository().getAllForScopeIds(
|
||||
"host",
|
||||
hostIds.map(String),
|
||||
);
|
||||
} catch (error) {
|
||||
// A host list must not fail because a settings read did.
|
||||
sshLogger.warn("Failed to load host plugin settings", {
|
||||
operation: "host_plugin_settings",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
const byHostAndPlugin = new Map<string, PluginSettingsRecord[]>();
|
||||
for (const row of rows) {
|
||||
const key = `${row.scopeId}:${row.pluginId}`;
|
||||
const list = byHostAndPlugin.get(key);
|
||||
if (list) list.push(row);
|
||||
else byHostAndPlugin.set(key, [row]);
|
||||
}
|
||||
|
||||
for (const hostId of hostIds) {
|
||||
const perPlugin: HostPluginSettings = {};
|
||||
|
||||
for (const manifest of manifests) {
|
||||
const fields = declaredFields(manifest, "host");
|
||||
if (fields.length === 0) continue;
|
||||
|
||||
const stored = new Map(
|
||||
(byHostAndPlugin.get(`${hostId}:${manifest.id}`) ?? []).map((row) => [
|
||||
row.key,
|
||||
row,
|
||||
]),
|
||||
);
|
||||
|
||||
const values: Record<string, unknown> = {};
|
||||
for (const field of fields) {
|
||||
values[field.key] = await resolveFieldValue(
|
||||
field,
|
||||
stored.get(field.key),
|
||||
{ redactSecrets: true },
|
||||
);
|
||||
}
|
||||
perPlugin[manifest.id] = values;
|
||||
}
|
||||
|
||||
if (Object.keys(perPlugin).length > 0) result.set(hostId, perPlugin);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Attaches the map to already-transformed host objects, in place. */
|
||||
export function attachHostPluginSettings(
|
||||
hosts: Record<string, unknown>[],
|
||||
settings: Map<number, HostPluginSettings>,
|
||||
): void {
|
||||
if (settings.size === 0) return;
|
||||
for (const host of hosts) {
|
||||
const hostId = Number(host.id);
|
||||
const values = settings.get(hostId);
|
||||
if (values) host.pluginSettings = values;
|
||||
}
|
||||
}
|
||||
|
||||
/** The single-host convenience wrapper. */
|
||||
export async function withHostPluginSettings(
|
||||
host: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const hostId = Number(host.id);
|
||||
if (!Number.isInteger(hostId)) return host;
|
||||
|
||||
const settings = await loadHostPluginSettings([hostId]);
|
||||
const values = settings.get(hostId);
|
||||
return values ? { ...host, pluginSettings: values } : host;
|
||||
}
|
||||
@@ -45,6 +45,11 @@ import {
|
||||
stripSensitiveFields,
|
||||
transformHostResponse,
|
||||
} from "./host-normalizers.js";
|
||||
import {
|
||||
attachHostPluginSettings,
|
||||
loadHostPluginSettings,
|
||||
withHostPluginSettings,
|
||||
} from "./host-plugin-settings.js";
|
||||
import { validateParentHostId } from "./host-parent-validation.js";
|
||||
import { registerHostOpksshRoutes } from "./host-opkssh-routes.js";
|
||||
import { registerHostStepCaRoutes } from "./host-step-ca-routes.js";
|
||||
@@ -1701,6 +1706,18 @@ router.get(
|
||||
)
|
||||
: stripSensitiveFields(host),
|
||||
);
|
||||
|
||||
// After sanitizing: the connect-level projection reduces a shared host
|
||||
// to an allowlist, which would drop this again. One query for the list.
|
||||
attachHostPluginSettings(
|
||||
sanitized,
|
||||
await loadHostPluginSettings(
|
||||
sanitized
|
||||
.map((host) => Number(host.id))
|
||||
.filter((id) => Number.isInteger(id)),
|
||||
),
|
||||
);
|
||||
|
||||
res.json(sanitized);
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to fetch SSH hosts from database", err, {
|
||||
@@ -1767,7 +1784,9 @@ router.get(
|
||||
const resolved =
|
||||
(await resolveHostCredentials(result, userId)) || result;
|
||||
|
||||
return res.json(stripSensitiveFields(resolved));
|
||||
return res.json(
|
||||
await withHostPluginSettings(stripSensitiveFields(resolved)),
|
||||
);
|
||||
}
|
||||
|
||||
// Not the owner: shared recipients get a sanitized view of the host.
|
||||
@@ -1818,9 +1837,11 @@ router.get(
|
||||
(await resolveHostCredentials(sharedResult, userId)) || sharedResult;
|
||||
|
||||
res.json(
|
||||
sanitizeHostForRecipient(
|
||||
resolvedSharedResult,
|
||||
accessInfo.permissionLevel,
|
||||
await withHostPluginSettings(
|
||||
sanitizeHostForRecipient(
|
||||
resolvedSharedResult,
|
||||
accessInfo.permissionLevel,
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
|
||||
@@ -14,9 +14,29 @@ import {
|
||||
} from "../repositories/factory.js";
|
||||
import { getPluginRuntime } from "../../plugins/index.js";
|
||||
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||
import {
|
||||
findField,
|
||||
getAllSettings,
|
||||
setSetting,
|
||||
type PluginSettingsScope,
|
||||
} from "../../plugins/settings.js";
|
||||
import {
|
||||
getAuditUsername,
|
||||
getRequestMeta,
|
||||
logAudit,
|
||||
} from "../../utils/audit-logger.js";
|
||||
import type {
|
||||
PluginManifest,
|
||||
PluginSettingsField,
|
||||
} from "@termix/plugin-sdk/manifest";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/** A settings body is a flat object; an array or null is a client bug. */
|
||||
function isPlainBody(body: unknown): body is Record<string, unknown> {
|
||||
return typeof body === "object" && body !== null && !Array.isArray(body);
|
||||
}
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
const permissionManager = PermissionManager.getInstance();
|
||||
@@ -79,15 +99,18 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
|
||||
let contributes: unknown = null;
|
||||
let capabilities: string[] = [];
|
||||
let icon: string | undefined;
|
||||
try {
|
||||
const manifest = JSON.parse(record.manifestJson) as {
|
||||
contributes?: unknown;
|
||||
capabilities?: unknown;
|
||||
icon?: unknown;
|
||||
};
|
||||
contributes = manifest?.contributes ?? null;
|
||||
capabilities = Array.isArray(manifest?.capabilities)
|
||||
? (manifest.capabilities as string[])
|
||||
: [];
|
||||
icon = typeof manifest?.icon === "string" ? manifest.icon : undefined;
|
||||
} catch {
|
||||
contributes = null;
|
||||
}
|
||||
@@ -99,6 +122,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
enabled: record.state === "enabled",
|
||||
state: loaded?.state ?? record.state,
|
||||
contributes,
|
||||
icon,
|
||||
};
|
||||
|
||||
if (!canManage) return summary;
|
||||
@@ -528,4 +552,535 @@ router.delete(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Loads a plugin's manifest for the settings routes.
|
||||
*
|
||||
* Read from the loader rather than the database row so the fields a request is
|
||||
* validated against are the ones the running build declares.
|
||||
*/
|
||||
async function loadManifestForSettings(
|
||||
pluginId: string,
|
||||
): Promise<PluginManifest | null> {
|
||||
const { loader } = getPluginRuntime();
|
||||
const live = loader.get(pluginId);
|
||||
if (live?.manifest) return live.manifest;
|
||||
|
||||
const record = await createCurrentPluginRepository().findById(pluginId);
|
||||
if (!record) return null;
|
||||
try {
|
||||
return JSON.parse(record.manifestJson) as PluginManifest;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the caller may write a given field.
|
||||
*
|
||||
* Admin fields fall back to admin.plugins.manage, so a plugin that declares no
|
||||
* permission of its own still cannot have its install-wide settings changed by
|
||||
* an ordinary user. A field naming its own permission uses that instead.
|
||||
*/
|
||||
async function canWriteField(
|
||||
userId: string,
|
||||
manifest: PluginManifest,
|
||||
scope: PluginSettingsScope,
|
||||
field: PluginSettingsField,
|
||||
): Promise<boolean> {
|
||||
// Admin scope always needs admin.plugins.manage. A field-level permission
|
||||
// narrows who may write it; it never widens the scope's own gate, so a
|
||||
// plugin cannot hand an ordinary user install-wide configuration by naming
|
||||
// a permission that user happens to hold.
|
||||
if (
|
||||
scope === "admin" &&
|
||||
!(await permissionManager.hasPermission(userId, "admin.plugins.manage"))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (field.permission) {
|
||||
const { resolvePermission } = await import("../../plugins/rbac.js");
|
||||
return permissionManager.hasPermission(
|
||||
userId,
|
||||
resolvePermission(manifest, field.permission),
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Applies a body of values to one scope, collecting per-field errors. */
|
||||
async function applySettings(
|
||||
userId: string,
|
||||
manifest: PluginManifest,
|
||||
scope: PluginSettingsScope,
|
||||
scopeId: string | null,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<Record<string, string>> {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(body)) {
|
||||
const field = findField(manifest, scope, key);
|
||||
if (!field) {
|
||||
errors[key] = "Not a settings field this plugin declares";
|
||||
continue;
|
||||
}
|
||||
if (!(await canWriteField(userId, manifest, scope, field))) {
|
||||
errors[key] = "You do not have permission to change this setting";
|
||||
continue;
|
||||
}
|
||||
|
||||
const error = await setSetting(manifest, scope, scopeId, key, value);
|
||||
if (error) errors[key] = error;
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/{id}/settings/admin:
|
||||
* get:
|
||||
* summary: Read a plugin's install-wide settings
|
||||
* description: >
|
||||
* Returns every admin-scope field the plugin declares, with stored values
|
||||
* over declared defaults. Secret fields come back as { set: boolean } and
|
||||
* never carry their value, so a browser can render "configured" without
|
||||
* ever holding the secret.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The plugin's admin settings.
|
||||
* 403:
|
||||
* description: The caller lacks admin.plugins.manage.
|
||||
* 404:
|
||||
* description: No such plugin.
|
||||
*/
|
||||
router.get(
|
||||
"/:id/settings/admin",
|
||||
authenticateJWT,
|
||||
requireManagePlugins,
|
||||
async (req: Request, res: Response) => {
|
||||
const pluginId = String(req.params.id);
|
||||
try {
|
||||
const manifest = await loadManifestForSettings(pluginId);
|
||||
if (!manifest) {
|
||||
res.status(404).json({ error: "No such plugin" });
|
||||
return;
|
||||
}
|
||||
const values = await getAllSettings(manifest, "admin", null, {
|
||||
redactSecrets: true,
|
||||
});
|
||||
res.json({ id: pluginId, values });
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
`Failed to read admin settings for plugin ${pluginId}`,
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
{ operation: "plugin_settings_read" },
|
||||
);
|
||||
res.status(500).json({ error: "Failed to read the plugin's settings" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/{id}/settings/admin:
|
||||
* put:
|
||||
* summary: Update a plugin's install-wide settings
|
||||
* description: >
|
||||
* Writes the given keys. Every value is validated against the field the
|
||||
* manifest declares, and a key the manifest never declared is rejected
|
||||
* rather than stored. Validation failures come back per field so a form
|
||||
* can show every problem at once. Sending a secret field back as
|
||||
* { set: true } leaves the stored value alone, so saving a form does not
|
||||
* clear a key it was never shown.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The updated settings.
|
||||
* 400:
|
||||
* description: One or more fields were rejected.
|
||||
* 403:
|
||||
* description: The caller lacks admin.plugins.manage.
|
||||
* 404:
|
||||
* description: No such plugin.
|
||||
*/
|
||||
router.put(
|
||||
"/:id/settings/admin",
|
||||
authenticateJWT,
|
||||
requireManagePlugins,
|
||||
async (req: Request, res: Response) => {
|
||||
const pluginId = String(req.params.id);
|
||||
const userId = (req as AuthenticatedRequest).userId as string;
|
||||
|
||||
if (!isPlainBody(req.body)) {
|
||||
res.status(400).json({ error: "Body must be an object of settings" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const manifest = await loadManifestForSettings(pluginId);
|
||||
if (!manifest) {
|
||||
res.status(404).json({ error: "No such plugin" });
|
||||
return;
|
||||
}
|
||||
|
||||
const errors = await applySettings(
|
||||
userId,
|
||||
manifest,
|
||||
"admin",
|
||||
null,
|
||||
req.body,
|
||||
);
|
||||
if (Object.keys(errors).length > 0) {
|
||||
res.status(400).json({ error: "Some settings were rejected", errors });
|
||||
return;
|
||||
}
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: await getAuditUsername(userId),
|
||||
action: "update_plugin_settings",
|
||||
resourceType: "setting",
|
||||
resourceId: pluginId,
|
||||
resourceName: manifest.name,
|
||||
// Keys only: a value here could be the secret we just encrypted.
|
||||
details: JSON.stringify({
|
||||
scope: "admin",
|
||||
keys: Object.keys(req.body),
|
||||
}),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
const values = await getAllSettings(manifest, "admin", null, {
|
||||
redactSecrets: true,
|
||||
});
|
||||
res.json({ id: pluginId, values });
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
`Failed to update admin settings for plugin ${pluginId}`,
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
{ operation: "plugin_settings_write" },
|
||||
);
|
||||
res.status(500).json({ error: "Failed to update the plugin's settings" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/{id}/settings/user:
|
||||
* get:
|
||||
* summary: Read the caller's own settings for a plugin
|
||||
* description: Returns every user-scope field the plugin declares for the authenticated user. The scope is always the caller, never a user id from the request.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The caller's settings for this plugin.
|
||||
* 404:
|
||||
* description: No such plugin.
|
||||
*/
|
||||
router.get(
|
||||
"/:id/settings/user",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const pluginId = String(req.params.id);
|
||||
const userId = (req as AuthenticatedRequest).userId as string;
|
||||
|
||||
try {
|
||||
const manifest = await loadManifestForSettings(pluginId);
|
||||
if (!manifest) {
|
||||
res.status(404).json({ error: "No such plugin" });
|
||||
return;
|
||||
}
|
||||
const values = await getAllSettings(manifest, "user", userId, {
|
||||
redactSecrets: true,
|
||||
});
|
||||
res.json({ id: pluginId, values });
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
`Failed to read user settings for plugin ${pluginId}`,
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
{ operation: "plugin_settings_read" },
|
||||
);
|
||||
res.status(500).json({ error: "Failed to read the plugin's settings" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/{id}/settings/user:
|
||||
* put:
|
||||
* summary: Update the caller's own settings for a plugin
|
||||
* description: Writes user-scope values for the authenticated user. The scope is always the caller, so one user can never write another's settings.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The updated settings.
|
||||
* 400:
|
||||
* description: One or more fields were rejected.
|
||||
* 404:
|
||||
* description: No such plugin.
|
||||
*/
|
||||
router.put(
|
||||
"/:id/settings/user",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const pluginId = String(req.params.id);
|
||||
const userId = (req as AuthenticatedRequest).userId as string;
|
||||
|
||||
if (!isPlainBody(req.body)) {
|
||||
res.status(400).json({ error: "Body must be an object of settings" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const manifest = await loadManifestForSettings(pluginId);
|
||||
if (!manifest) {
|
||||
res.status(404).json({ error: "No such plugin" });
|
||||
return;
|
||||
}
|
||||
|
||||
const errors = await applySettings(
|
||||
userId,
|
||||
manifest,
|
||||
"user",
|
||||
userId,
|
||||
req.body,
|
||||
);
|
||||
if (Object.keys(errors).length > 0) {
|
||||
res.status(400).json({ error: "Some settings were rejected", errors });
|
||||
return;
|
||||
}
|
||||
|
||||
const values = await getAllSettings(manifest, "user", userId, {
|
||||
redactSecrets: true,
|
||||
});
|
||||
res.json({ id: pluginId, values });
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
`Failed to update user settings for plugin ${pluginId}`,
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
{ operation: "plugin_settings_write" },
|
||||
);
|
||||
res.status(500).json({ error: "Failed to update the plugin's settings" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/{id}/settings/host/{hostId}:
|
||||
* get:
|
||||
* summary: Read a plugin's settings for one host
|
||||
* description: Returns every host-scope field the plugin declares for that host. Requires edit access to the host, the same check the host editor itself uses.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: path
|
||||
* name: hostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The plugin's settings for that host.
|
||||
* 400:
|
||||
* description: Invalid host id.
|
||||
* 403:
|
||||
* description: No edit access to that host.
|
||||
* 404:
|
||||
* description: No such plugin.
|
||||
*/
|
||||
router.get(
|
||||
"/:id/settings/host/:hostId",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const pluginId = String(req.params.id);
|
||||
const userId = (req as AuthenticatedRequest).userId as string;
|
||||
const hostId = Number(req.params.hostId);
|
||||
|
||||
if (!Number.isInteger(hostId) || hostId <= 0) {
|
||||
res.status(400).json({ error: "Invalid host ID" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const manifest = await loadManifestForSettings(pluginId);
|
||||
if (!manifest) {
|
||||
res.status(404).json({ error: "No such plugin" });
|
||||
return;
|
||||
}
|
||||
|
||||
const access = await permissionManager.canAccessHost(
|
||||
userId,
|
||||
hostId,
|
||||
"edit",
|
||||
);
|
||||
if (!access.hasAccess) {
|
||||
res.status(403).json({ error: "Access denied to host" });
|
||||
return;
|
||||
}
|
||||
|
||||
const values = await getAllSettings(manifest, "host", hostId, {
|
||||
redactSecrets: true,
|
||||
});
|
||||
res.json({ id: pluginId, hostId, values });
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
`Failed to read host settings for plugin ${pluginId}`,
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
{ operation: "plugin_settings_read" },
|
||||
);
|
||||
res.status(500).json({ error: "Failed to read the plugin's settings" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/{id}/settings/host/{hostId}:
|
||||
* put:
|
||||
* summary: Update a plugin's settings for one host
|
||||
* description: Writes host-scope values. Requires edit access to the host, so a user who may only connect to a shared host cannot change how a plugin treats it.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: path
|
||||
* name: hostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The updated settings.
|
||||
* 400:
|
||||
* description: Invalid host id, or one or more fields were rejected.
|
||||
* 403:
|
||||
* description: No edit access to that host.
|
||||
* 404:
|
||||
* description: No such plugin.
|
||||
*/
|
||||
router.put(
|
||||
"/:id/settings/host/:hostId",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const pluginId = String(req.params.id);
|
||||
const userId = (req as AuthenticatedRequest).userId as string;
|
||||
const hostId = Number(req.params.hostId);
|
||||
|
||||
if (!Number.isInteger(hostId) || hostId <= 0) {
|
||||
res.status(400).json({ error: "Invalid host ID" });
|
||||
return;
|
||||
}
|
||||
if (!isPlainBody(req.body)) {
|
||||
res.status(400).json({ error: "Body must be an object of settings" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const manifest = await loadManifestForSettings(pluginId);
|
||||
if (!manifest) {
|
||||
res.status(404).json({ error: "No such plugin" });
|
||||
return;
|
||||
}
|
||||
|
||||
const access = await permissionManager.canAccessHost(
|
||||
userId,
|
||||
hostId,
|
||||
"edit",
|
||||
);
|
||||
if (!access.hasAccess) {
|
||||
res.status(403).json({ error: "Access denied to host" });
|
||||
return;
|
||||
}
|
||||
|
||||
const errors = await applySettings(
|
||||
userId,
|
||||
manifest,
|
||||
"host",
|
||||
String(hostId),
|
||||
req.body,
|
||||
);
|
||||
if (Object.keys(errors).length > 0) {
|
||||
res.status(400).json({ error: "Some settings were rejected", errors });
|
||||
return;
|
||||
}
|
||||
|
||||
const values = await getAllSettings(manifest, "host", hostId, {
|
||||
redactSecrets: true,
|
||||
});
|
||||
res.json({ id: pluginId, hostId, values });
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
`Failed to update host settings for plugin ${pluginId}`,
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
{ operation: "plugin_settings_write" },
|
||||
);
|
||||
res.status(500).json({ error: "Failed to update the plugin's settings" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -338,120 +338,6 @@ export function registerUserSettingsRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/tailscale-settings:
|
||||
* get:
|
||||
* summary: Get Tailscale settings
|
||||
* description: Returns whether a Tailscale API key is configured (value is masked).
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Tailscale settings.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* apiKey:
|
||||
* type: string
|
||||
* description: Masked API key or empty string if not set.
|
||||
* hasApiKey:
|
||||
* type: boolean
|
||||
* apiBaseUrl:
|
||||
* type: string
|
||||
* description: Custom control-plane API base URL (e.g. a Headscale instance), or empty string for the Tailscale default.
|
||||
*/
|
||||
router.get("/tailscale-settings", authenticateJWT, async (_req, res) => {
|
||||
try {
|
||||
const settingsRepo = createCurrentSettingsRepository();
|
||||
const apiKey = (await settingsRepo.get("tailscale_api_key")) ?? "";
|
||||
const apiBaseUrl =
|
||||
(await settingsRepo.get("tailscale_api_base_url")) ?? "";
|
||||
res.json({
|
||||
apiKey: apiKey
|
||||
? `${apiKey.slice(0, 6)}${"*".repeat(Math.max(0, apiKey.length - 6))}`
|
||||
: "",
|
||||
hasApiKey: !!apiKey,
|
||||
apiBaseUrl,
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get Tailscale settings", err);
|
||||
res.status(500).json({ error: "Failed to get Tailscale settings" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/tailscale-settings:
|
||||
* patch:
|
||||
* summary: Update Tailscale settings (admin only)
|
||||
* description: Saves or clears the Tailscale API key used for device discovery.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* apiKey:
|
||||
* type: string
|
||||
* apiBaseUrl:
|
||||
* type: string
|
||||
* description: Optional custom control-plane API base URL (e.g. a Headscale instance). Leave empty to use the Tailscale default.
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Tailscale settings updated.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 500:
|
||||
* description: Failed to update Tailscale settings.
|
||||
*/
|
||||
router.patch("/tailscale-settings", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const actor = await getAdminActor(userId);
|
||||
if (!actor) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
const { apiKey, apiBaseUrl } = req.body;
|
||||
if (typeof apiKey !== "string") {
|
||||
return res.status(400).json({ error: "apiKey must be a string" });
|
||||
}
|
||||
if (apiBaseUrl !== undefined && typeof apiBaseUrl !== "string") {
|
||||
return res.status(400).json({ error: "apiBaseUrl must be a string" });
|
||||
}
|
||||
const settingsRepo = createCurrentSettingsRepository();
|
||||
await settingsRepo.set("tailscale_api_key", apiKey);
|
||||
if (apiBaseUrl !== undefined) {
|
||||
await settingsRepo.set(
|
||||
"tailscale_api_base_url",
|
||||
apiBaseUrl.trim().replace(/\/+$/, ""),
|
||||
);
|
||||
}
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: actor.username ?? userId,
|
||||
action: "update_tailscale_settings",
|
||||
resourceType: "setting",
|
||||
details: JSON.stringify({ hasApiKey: !!apiKey }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({ hasApiKey: !!apiKey });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to update Tailscale settings", err);
|
||||
res.status(500).json({ error: "Failed to update Tailscale settings" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/command-history-enabled:
|
||||
|
||||
Reference in new issue
Block a user