feat: convert secret-sources into a plugin (B20)

This commit is contained in:
LukeGus committed 2026-09-24 16:20:20 -05:00
1 parent 2ac7779eda
commit f4f26a9b8b
72 files changed
+18843 -933

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
-- secret_sources moves to the secret-sources plugin's adopted table at
-- activation, after this migration runs. Dropping it here would delete the
-- data before the plugin has a chance to rename it.
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -351,6 +351,13 @@
"when": 1790282598229, "when": 1790282598229,
"tag": "0049_quiet_blacklash", "tag": "0049_quiet_blacklash",
"breakpoints": true "breakpoints": true
},
{
"idx": 50,
"version": "5",
"when": 1790284032088,
"tag": "0050_optimal_speedball",
"breakpoints": true
} }
] ]
} }
+4
View File
@@ -0,0 +1,4 @@
-- secret_sources moves to the secret-sources plugin's adopted table at
-- activation, after this migration runs. Dropping it here would delete the
-- data before the plugin has a chance to rename it.
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -358,6 +358,13 @@
"when": 1790282597585, "when": 1790282597585,
"tag": "0050_acoustic_lady_bullseye", "tag": "0050_acoustic_lady_bullseye",
"breakpoints": true "breakpoints": true
},
{
"idx": 51,
"version": "7",
"when": 1790284031484,
"tag": "0051_useful_slyde",
"breakpoints": true
} }
] ]
} }
@@ -0,0 +1,4 @@
-- secret_sources moves to the secret-sources plugin's adopted table at
-- activation, after this migration runs. Dropping it here would delete the
-- data before the plugin has a chance to rename it.
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -330,6 +330,13 @@
"when": 1790282596918, "when": 1790282596918,
"tag": "0046_misty_midnight", "tag": "0046_misty_midnight",
"breakpoints": true "breakpoints": true
},
{
"idx": 47,
"version": "6",
"when": 1790284030834,
"tag": "0047_futuristic_microbe",
"breakpoints": true
} }
] ]
} }
+11
View File
@@ -7016,6 +7016,10 @@
"resolved": "plugins/remote-desktop", "resolved": "plugins/remote-desktop",
"link": true "link": true
}, },
"node_modules/@termix-plugin/secret-sources": {
"resolved": "plugins/secret-sources",
"link": true
},
"node_modules/@termix-plugin/serial": { "node_modules/@termix-plugin/serial": {
"resolved": "plugins/serial", "resolved": "plugins/serial",
"link": true "link": true
@@ -18623,6 +18627,13 @@
"@termix/plugin-sdk": "*" "@termix/plugin-sdk": "*"
} }
}, },
"plugins/secret-sources": {
"name": "@termix-plugin/secret-sources",
"version": "1.0.0",
"devDependencies": {
"@termix/plugin-sdk": "*"
}
},
"plugins/serial": { "plugins/serial": {
"name": "@termix-plugin/serial", "name": "@termix-plugin/serial",
"version": "1.0.0", "version": "1.0.0",
+56 -1
View File
@@ -752,7 +752,7 @@ sessions shared with a user from the `sessions.sharedWithMe` action.
(`snippets.access`, a hard dependency), fleets (`fleets.access`), tunnels (`snippets.access`, a hard dependency), fleets (`fleets.access`), tunnels
(`tunnels.access`), Docker (`docker.containers`, `docker.events`), host metrics (`tunnels.access`), Docker (`docker.containers`, `docker.events`), host metrics
(`host-metrics.viewers`) and wake-on-lan (`wake-on-lan.send` v1, (`host-metrics.viewers`) and wake-on-lan (`wake-on-lan.send` v1,
`wake(hostId)`, which B20 provides) as services, notification channels through `wake(hostId)`, which **B20** provides) as services, notification channels through
`ctx.notify`, the HTTP step through `ctx.fetch`, hosts through `ctx.hosts` and `ctx.notify`, the HTTP step through `ctx.fetch`, hosts through `ctx.hosts` and
`ctx.ssh.resolveHost`, and commands through `ctx.ssh.withConnection`. Every run `ctx.ssh.resolveHost`, and commands through `ctx.ssh.withConnection`. Every run
executes inside `ctx.asUser(<owner>)`, so each of those calls is the owner's. executes inside `ctx.asUser(<owner>)`, so each of those calls is the owner's.
@@ -845,6 +845,38 @@ several widget-owning plugins relied on through the `homepage` namespace's
core fallback moved to core's `common.*`, which is what that fallback is core fallback moved to core's `common.*`, which is what that fallback is
actually for. actually for.
**Wake-on-LAN (B20)** imports nothing from core. It owns no table: `macAddress`
and `broadcastAddress` are host settings (`contributes.settings.host`), moved
out of `ssh_data.mac_address`/`wol_broadcast_address` by a boot migration in
`src/backend/utils/crypto-migration/wake-on-lan-settings-migration.ts`. A
"Wake" host action sends the magic packet, gated on `network:broadcast`
(medium, added to the capability catalog this step). It provides
`wake-on-lan.send` v1 (`wake(hostId): Promise<void>`, which resolves the
host's MAC and broadcast address itself and checks the caller's own
`wake-on-lan.send` permission), which automations (B17) already declared as
an optional dependency.
**Secret sources (B20)** imports nothing from core. It adopts `secret_sources`
(rename only; the access token is not a column - see below), and provides no
service, only `ctx.credentials.registerSecretResolver("op", resolve)`. The
1Password Connect client, its egress allowlist (an admin `privateEndpoints`
textarea, the same shape as the ai plugin's) and the source manager UI (a
component slot core's host editor and credential editor render through
`app.registerComponent("secret-sources.hint" | "secret-sources.manager", ...)`
and `PluginComponent`, replacing the static imports those two core files used
to carry) all moved in. The access token follows the pattern the ai plugin set
for provider keys: it lives in `ctx.secrets` under `"source:<id>"` rather than
in the table, so it is encrypted with the installation key rather than the
owner's data key and is readable whenever the plugin runs, not only while the
owner is signed in. A shared source's token is still only ever read for its
own owner: `resolveConnectReference` and the `/test` route both go through
`ctx.asUser(<owner>)` before reading it, so a recipient using a shared source
never touches the raw value directly. `src/backend/utils/crypto-migration/secret-sources-token-migration.ts`
moves an existing owner-DEK-encrypted token out of the column and into
`ctx.secrets` at boot, the same shape as `ai-settings-migration.ts`'s provider
key move. The `/users/secret-source-private-endpoints` route and its admin UI
are gone; the allowlist is the plugin's own admin setting now.
#### Host status is core #### Host status is core
The status dot in the host list, the dashboard and the homepage widget is The status dot in the host list, the dashboard and the homepage widget is
@@ -1276,6 +1308,7 @@ not do.
"sshAuthTypes": ["corp-ca"], // host auth types this plugin handles "sshAuthTypes": ["corp-ca"], // host auth types this plugin handles
"loginMethods": ["corp-sso"], "loginMethods": ["corp-sso"],
"secondFactors": ["pin"], "secondFactors": ["pin"],
"secretSchemes": ["op"], // "<scheme>://..." secret references this plugin resolves
}, },
"settings": { "settings": {
"admin": [ "admin": [
@@ -1380,6 +1413,10 @@ accept the kinds `button` and `component`.
`contributes.auth` lists the ids a plugin registers through `ctx.auth`. A `contributes.auth` lists the ids a plugin registers through `ctx.auth`. A
register call for an id not listed here throws. Core also reads register call for an id not listed here throws. Core also reads
`sshAuthTypes` from disabled plugins, to say which plugin a host needs. `sshAuthTypes` from disabled plugins, to say which plugin a host needs.
`secretSchemes` (**B20**) is the same idea for
`ctx.credentials.registerSecretResolver`: core reads it from disabled
plugins too, so a reference whose scheme belongs to a disabled plugin can
name that plugin in its error instead of saying nothing resolves it.
`contributes.hostCapability`, `provides`, `requires`, `providesSecret` and `contributes.hostCapability`, `provides`, `requires`, `providesSecret` and
`requiresSecret` are carried forward from v1 unchanged because the service and `requiresSecret` are carried forward from v1 unchanged because the service and
@@ -1416,6 +1453,7 @@ deciding, not the mechanism.
| `credentials:use` | medium | Connect using a host's stored credentials, without seeing them | | `credentials:use` | medium | Connect using a host's stored credentials, without seeing them |
| `network:outbound` | medium | Make outbound requests | | `network:outbound` | medium | Make outbound requests |
| `network:serve` | medium | Open a listening port | | `network:serve` | medium | Open a listening port |
| `network:broadcast` | medium | Send network broadcast packets |
| `users:read` | medium | See usernames and roles, never hashes or 2FA state | | `users:read` | medium | See usernames and roles, never hashes or 2FA state |
| `events:core` | medium | Subscribe to and emit core events carrying other users' data | | `events:core` | medium | Subscribe to and emit core events carrying other users' data |
| `notify:send` | medium | Send notifications through configured channels | | `notify:send` | medium | Send notifications through configured channels |
@@ -1477,6 +1515,7 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
| `ctx.desktop.openIsolatedWindow` | `desktop:window` | **B8** | | `ctx.desktop.openIsolatedWindow` | `desktop:window` | **B8** |
| `ctx.desktop.launchNativeRdp` / `.available` | `desktop:window` (launch only) | **B14** | | `ctx.desktop.launchNativeRdp` / `.available` | `desktop:window` (launch only) | **B14** |
| `ctx.credentials.resolveHostProtocol` | `credentials:read` | **B14** | | `ctx.credentials.resolveHostProtocol` | `credentials:read` | **B14** |
| `ctx.credentials.registerSecretResolver` | `auth:provide` | **B20** |
| `ctx.audit.record` | none, the actor is the runtime's | **B9** | | `ctx.audit.record` | none, the actor is the runtime's | **B9** |
| `ctx.schedule.every` / `.after` | none | **B16** | | `ctx.schedule.every` / `.after` | none | **B16** |
| `ctx.fetch` | `network:outbound` | **B17**, signal **B18** | | `ctx.fetch` | `network:outbound` | **B17**, signal **B18** |
@@ -1636,6 +1675,22 @@ resolution (the owner's shared snapshot or their own override), never the
owner's raw secret; no connect access, or no host, is `null`. Every call is owner's raw secret; no connect access, or no host, is `null`. Every call is
audited as `plugin_credentials_read`, allowed or refused. audited as `plugin_credentials_read`, allowed or refused.
**B20** added `ctx.credentials.registerSecretResolver(scheme, resolve)`,
behind `auth:provide` like `ctx.auth`'s registrations, for a plugin that
expands `"<scheme>://..."` references in a host's secret fields into the
real secret (secret-sources registers `"op"` for
`"op://vault/item/field"`). The scheme must be listed in
`contributes.auth.secretSchemes`, which exists for the same reason
`sshAuthTypes` does: so core can name the plugin a disabled scheme needs. A
core registry in `src/backend/hosts/connect/secret-resolver-registry.ts`
mirrors the SSH auth provider registry one-for-one (one owner per scheme,
`SecretResolverMissingError` naming the plugin when none is registered).
`resolve` is called once per host resolution, for the user the field's owner
resolves as, by `src/backend/hosts/external-secrets.ts`, which is scheme-
generic now: it detects a reference by its `scheme://` prefix, looks up the
registered resolver, and caches the result for a minute. Core registers
nothing here itself.
**B16** added `ctx.hosts.status`, the plugin side of core's host status **B16** added `ctx.hosts.status`, the plugin side of core's host status
check (see [Host status](#host-status-is-core)): check (see [Host status](#host-status-is-core)):
+8 -5
View File
@@ -375,11 +375,6 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
there: it writes host rows only and never plugin settings. Owner: D1, with there: it writes host rows only and never plugin settings. Owner: D1, with
the generic host-settings export and import. the generic host-settings export and import.
- **B17 (automations), for B20:** the wake-on-lan step calls the optional
`wake-on-lan.send` v1 service, `wake(hostId): Promise<void>`, as the
automation's owner; the provider resolves the host's MAC and checks access
itself. Until B20 provides it, the step is offered nowhere and an existing
one is skipped with "Needs the wake-on-lan plugin". Owner: B20.
- **B17 (automations), for D2:** the ctx table says `ctx.events.on` needs - **B17 (automations), for D2:** the ctx table says `ctx.events.on` needs
`events:core` for core topics, but the runtime only checks `emit`. `events:core` for core topics, but the runtime only checks `emit`.
docker, file-manager, host-metrics, session-recording and snippets docker, file-manager, host-metrics, session-recording and snippets
@@ -450,6 +445,14 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
own B10 step moved off of (per `plugins/tmux-monitor/CHANGELOG.md`) without own B10 step moved off of (per `plugins/tmux-monitor/CHANGELOG.md`) without
updating this frontend constant to `/plugin-api/tmux-monitor`. Not updating this frontend constant to `/plugin-api/tmux-monitor`. Not
something this step touched or verified further. Owner: D0. something this step touched or verified further. Owner: D0.
- **Pre-existing, found in B20:** `npm run test:plugins` fails on three tests
unrelated to wake-on-lan or secret-sources: `ssh-terminal` and `tunnels`
both assert `mock.wsRoutes` equals `[{ path, raw }]`, but the mock now also
carries `handler` and `options` (added for **B15**'s doubles, per the
contract's "Tests" section), so the exact-equality assertion fails; and
`web-endpoint`'s "answers 503 while the tunnels plugin is not available"
test gets 502. Confirmed present on `dev-2.9.0` before this step (checked
by stashing B20's changes and re-running). Owner: D0.
## Manual checks after 2.9.0 ## Manual checks after 2.9.0
@@ -292,6 +292,10 @@
"secondFactors": { "secondFactors": {
"type": "array", "type": "array",
"items": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,63}$" } "items": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,63}$" }
},
"secretSchemes": {
"type": "array",
"items": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,63}$" }
} }
} }
}, },
+15
View File
@@ -1257,6 +1257,21 @@ export interface PluginCredentials {
hostId: number, hostId: number,
protocol: PluginHostProtocol, protocol: PluginHostProtocol,
) => Promise<PluginProtocolTarget | null>; ) => Promise<PluginProtocolTarget | null>;
/**
* Registers this plugin as the resolver for "<scheme>://..." references in
* a host's secret fields (secret-sources registers "op" for
* "op://vault/item/field"). Needs auth:provide, like registerSshAuthProvider.
*
* `resolve` runs once per host resolution, for the user the field's owner
* resolves as (the host owner, or the recipient for a personal override),
* and its result is cached briefly by the connect pipeline. A scheme with no
* registered resolver fails the connect with a message naming the plugin
* that would provide it.
*/
registerSecretResolver: (
scheme: string,
resolve: (userId: string, reference: string) => Promise<string>,
) => void;
} }
/** /**
+11 -3
View File
@@ -257,6 +257,8 @@ export interface PluginAuthContribution {
loginMethods?: string[]; loginMethods?: string[];
/** Second factor ids. */ /** Second factor ids. */
secondFactors?: string[]; secondFactors?: string[];
/** Schemes this plugin resolves for "<scheme>://..." secret references, e.g. "op". */
secretSchemes?: string[];
} }
export interface PluginContributions { export interface PluginContributions {
@@ -790,11 +792,16 @@ function validateAuthContribution(value: unknown, errors: string[]): void {
} }
rejectUnknown( rejectUnknown(
value, value,
["sshAuthTypes", "loginMethods", "secondFactors"], ["sshAuthTypes", "loginMethods", "secondFactors", "secretSchemes"],
"contributes.auth", "contributes.auth",
errors, errors,
); );
for (const key of ["sshAuthTypes", "loginMethods", "secondFactors"]) { for (const key of [
"sshAuthTypes",
"loginMethods",
"secondFactors",
"secretSchemes",
]) {
const list = value[key]; const list = value[key];
if (list === undefined) continue; if (list === undefined) continue;
if (!Array.isArray(list)) { if (!Array.isArray(list)) {
@@ -1343,7 +1350,8 @@ export function parseManifest(raw: unknown): ParsedManifest {
const contributesAuth = const contributesAuth =
(auth?.sshAuthTypes?.length ?? 0) + (auth?.sshAuthTypes?.length ?? 0) +
(auth?.loginMethods?.length ?? 0) + (auth?.loginMethods?.length ?? 0) +
(auth?.secondFactors?.length ?? 0) > (auth?.secondFactors?.length ?? 0) +
(auth?.secretSchemes?.length ?? 0) >
0; 0;
if (contributesAuth && !manifest.capabilities.includes("auth:provide")) { if (contributesAuth && !manifest.capabilities.includes("auth:provide")) {
errors.push( errors.push(
+16
View File
@@ -126,6 +126,11 @@ export interface FakeAuthRegistrations {
secondFactors: PluginSecondFactor[]; secondFactors: PluginSecondFactor[];
/** "<userId>:<factorId>" for every recorded enrolment. */ /** "<userId>:<factorId>" for every recorded enrolment. */
enrollments: Set<string>; enrollments: Set<string>;
/** Secret schemes registered through ctx.credentials.registerSecretResolver. */
secretResolvers: Map<
string,
(userId: string, reference: string) => Promise<string>
>;
} }
export interface FakePluginContext { export interface FakePluginContext {
@@ -336,6 +341,7 @@ export function createFakeContext(
loginMethods: [], loginMethods: [],
secondFactors: [], secondFactors: [],
enrollments: new Set(), enrollments: new Set(),
secretResolvers: new Map(),
}; };
const sshClient = options.sshClient ?? {}; const sshClient = options.sshClient ?? {};
let actor = options.actor; let actor = options.actor;
@@ -800,6 +806,12 @@ export function createFakeContext(
credentialReads.push({ hostId, protocol }); credentialReads.push({ hostId, protocol });
return options.protocolTargets?.[`${hostId}:${protocol}`] ?? null; return options.protocolTargets?.[`${hostId}:${protocol}`] ?? null;
}, },
registerSecretResolver: (scheme, resolve) => {
auth.secretResolvers.set(scheme, resolve);
disposals.push(() => {
auth.secretResolvers.delete(scheme);
});
},
}, },
notify: { notify: {
@@ -1223,6 +1235,10 @@ export function createMockCtx(
require("credentials:read"); require("credentials:read");
return ctx.credentials.resolveHostProtocol(hostId, protocol); return ctx.credentials.resolveHostProtocol(hostId, protocol);
}, },
registerSecretResolver: (scheme, resolve) => {
require("auth:provide");
ctx.credentials.registerSecretResolver(scheme, resolve);
},
}, },
notify: { notify: {
+27
View File
@@ -0,0 +1,27 @@
{
"hint": "You can paste a 1Password reference (op://vault/item/field) instead of the secret. It is fetched from your secret source when connecting.",
"manage": "Manage secret sources",
"title": "Secret sources",
"description": "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.",
"new": "New source",
"nameLabel": "Friendly name",
"urlLabel": "Connect server URL",
"tokenLabel": "Connect access token",
"tokenKeep": "Leave empty to keep the current token",
"sharedLabel": "Share with all users (admins only, resolves while you are signed in)",
"shared": "shared",
"test": "Test",
"testOk": "Connected, {{count}} vault(s) visible",
"testFailed": "Connection test failed",
"required": "Name, server URL and token are required",
"saved": "Secret source saved",
"deleted": "Secret source deleted",
"settings": {
"admin": {
"privateEndpoints": {
"label": "Private endpoint allowlist",
"description": "Private or loopback hosts a secret source may reach, one per line. A self-hosted 1Password Connect server usually needs its own address listed here."
}
}
}
}
+43
View File
@@ -0,0 +1,43 @@
{
"id": "secret-sources",
"name": "Secret Sources",
"version": "1.0.0",
"description": "Pull host secrets from an external password manager at connect time instead of storing them, using op:// references.",
"author": {
"name": "Termix"
},
"license": "MIT",
"repository": "https://github.com/Termix-SSH/Termix",
"category": "Access & Security",
"icon": "KeyRound",
"engine": {
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": [
"db:own",
"secrets:own",
"network:outbound",
"network:serve",
"auth:provide",
"ui:surface"
],
"contributes": {
"auth": {
"secretSchemes": ["op"]
},
"settings": {
"admin": [
{
"key": "privateEndpoints",
"type": "textarea",
"labelKey": "settings.admin.privateEndpoints.label",
"descriptionKey": "settings.admin.privateEndpoints.description",
"default": "localhost\n127.0.0.1\n::1\nhost.docker.internal"
}
]
}
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
}
@@ -0,0 +1,15 @@
-- secret-sources 0001: adopt_secret_sources
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS `secret_sources` (
`id` varchar(255) PRIMARY KEY NOT NULL,
`user_id` varchar(255) NOT NULL,
`name` text NOT NULL,
`kind` text NOT NULL DEFAULT ('onepassword-connect'),
`base_url` text NOT NULL,
`shared` boolean NOT NULL DEFAULT false,
`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
);
RENAME TABLE `secret_sources` TO `p_secret_sources_sources`;
@@ -0,0 +1,16 @@
-- secret-sources 0001: adopt_secret_sources
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS "secret_sources" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"user_id" varchar(255) NOT NULL,
"name" text NOT NULL,
"kind" text NOT NULL DEFAULT 'onepassword-connect',
"base_url" text NOT NULL,
"shared" boolean NOT NULL DEFAULT false,
"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 INDEX IF NOT EXISTS "idx_secret_sources_user" ON "secret_sources" ("user_id");
ALTER TABLE "secret_sources" RENAME TO "p_secret_sources_sources";
@@ -0,0 +1,55 @@
{
"version": 1,
"tables": {
"sources": {
"columns": {
"id": {
"type": "varchar",
"length": 255,
"primaryKey": true,
"notNull": true
},
"userId": {
"type": "refUser",
"length": 255,
"notNull": true
},
"name": {
"type": "text",
"notNull": true
},
"kind": {
"type": "text",
"notNull": true,
"defaultValue": "onepassword-connect"
},
"baseUrl": {
"type": "text",
"notNull": true
},
"shared": {
"type": "boolean",
"notNull": true,
"defaultValue": false
},
"createdAt": {
"type": "timestamp",
"notNull": true,
"defaultNow": true
},
"updatedAt": {
"type": "timestamp",
"notNull": true,
"defaultNow": true
}
},
"indexes": [
{
"name": "idx_secret_sources_user",
"columns": ["userId"]
}
],
"adopts": "secret_sources"
}
}
}
@@ -0,0 +1,16 @@
-- secret-sources 0001: adopt_secret_sources
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS "secret_sources" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"name" text NOT NULL,
"kind" text NOT NULL DEFAULT 'onepassword-connect',
"base_url" 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 INDEX IF NOT EXISTS "idx_secret_sources_user" ON "secret_sources" ("user_id");
ALTER TABLE "secret_sources" RENAME TO "p_secret_sources_sources";
+15
View File
@@ -0,0 +1,15 @@
{
"name": "@termix-plugin/secret-sources",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -p tsconfig.json",
"validate": "termix-plugin validate"
},
"devDependencies": {
"@termix/plugin-sdk": "*"
}
}
@@ -0,0 +1,28 @@
/**
* Where a secret source (a self-hosted 1Password Connect server) may be
* reached. Every request goes through ctx.fetch, which refuses private and
* loopback addresses unless the exact host is listed here, admin-only and
* install-wide.
*/
export const DEFAULT_PRIVATE_ALLOWLIST = [
"localhost",
"127.0.0.1",
"::1",
"host.docker.internal",
];
/** The admin setting is one host per line. A line that is not a bare host is ignored. */
const HOST_PATTERN = /^[a-z0-9._:-]+$/;
export function parseAllowlist(raw: unknown): string[] {
if (typeof raw !== "string") return [...DEFAULT_PRIVATE_ALLOWLIST];
const hosts: string[] = [];
for (const line of raw.split(/[\r\n,]+/)) {
const host = line.trim().toLowerCase();
if (host && HOST_PATTERN.test(host) && !hosts.includes(host)) {
hosts.push(host);
}
}
return hosts;
}
@@ -0,0 +1,27 @@
import type { Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { sources } from "./tables.js";
import { createSecretSourceRepository } from "./repository.js";
import { createTokenStore } from "./token-store.js";
import { registerSecretSourceRoutes } from "./routes.js";
import { registerOnePasswordResolver } from "./resolver.js";
export async function activate(ctx: PluginContext) {
const table = await ctx.db.define(sources);
const repository = createSecretSourceRepository(ctx.db, table);
const tokenStore = createTokenStore(ctx);
registerSecretSourceRoutes(
ctx.http.router<Router>(),
ctx,
repository,
tokenStore,
);
registerOnePasswordResolver(ctx, repository, tokenStore);
ctx.log.info("Secret sources routes mounted at /plugin-api/secret-sources");
}
export async function deactivate() {
// Everything above was registered through ctx and is disposed by core.
}
@@ -1,4 +1,4 @@
import { safeOutboundFetch } from "./safe-outbound-fetch.js"; import type { PluginFetch } from "@termix/plugin-sdk/backend";
/** /**
* The slice of the 1Password Connect REST API needed to resolve a secret * The slice of the 1Password Connect REST API needed to resolve a secret
@@ -22,27 +22,24 @@ export function parseSecretReference(raw: string): SecretReference | null {
return { vault, item, field }; return { vault, item, field };
} }
export function isSecretReference(value: unknown): value is string {
return typeof value === "string" && value.trimStart().startsWith("op://");
}
export interface ConnectSource { export interface ConnectSource {
baseUrl: string; baseUrl: string;
token: string; token: string;
allowedPrivateHosts: readonly string[]; allowedPrivateHosts: readonly string[];
} }
async function connectGet<T>(source: ConnectSource, path: string): Promise<T> { async function connectGet<T>(
fetch: PluginFetch,
source: ConnectSource,
path: string,
): Promise<T> {
const base = source.baseUrl.replace(/\/+$/, ""); const base = source.baseUrl.replace(/\/+$/, "");
const response = await safeOutboundFetch( const response = await fetch(`${base}${path}`, {
`${base}${path}`, method: "GET",
{ headers: { Authorization: `Bearer ${source.token}` },
method: "GET", timeoutMs: FETCH_TIMEOUT_MS,
headers: { Authorization: `Bearer ${source.token}` }, allowPrivateHosts: source.allowedPrivateHosts,
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), });
},
source.allowedPrivateHosts,
);
if (!response.ok) { if (!response.ok) {
throw new Error( throw new Error(
`1Password Connect ${path} failed: HTTP ${response.status}`, `1Password Connect ${path} failed: HTTP ${response.status}`,
@@ -73,9 +70,10 @@ interface ConnectField {
/** Reachability + token validity: the vault list needs a valid token. */ /** Reachability + token validity: the vault list needs a valid token. */
export async function testConnectSource( export async function testConnectSource(
fetch: PluginFetch,
source: ConnectSource, source: ConnectSource,
): Promise<number> { ): Promise<number> {
const vaults = await connectGet<ConnectVault[]>(source, "/v1/vaults"); const vaults = await connectGet<ConnectVault[]>(fetch, source, "/v1/vaults");
return vaults.length; return vaults.length;
} }
@@ -87,21 +85,24 @@ const byIdOrName =
name.toLowerCase(); name.toLowerCase();
export async function resolveConnectReference( export async function resolveConnectReference(
fetch: PluginFetch,
source: ConnectSource, source: ConnectSource,
ref: SecretReference, ref: SecretReference,
): Promise<string> { ): Promise<string> {
const vaults = await connectGet<ConnectVault[]>( const vaults = await connectGet<ConnectVault[]>(
fetch,
source, source,
`/v1/vaults?filter=${eqFilter(ref.vault)}`, `/v1/vaults?filter=${eqFilter(ref.vault)}`,
); );
const vault = const vault =
vaults.find(byIdOrName(ref.vault)) ?? vaults.find(byIdOrName(ref.vault)) ??
(await connectGet<ConnectVault[]>(source, "/v1/vaults")).find( (await connectGet<ConnectVault[]>(fetch, source, "/v1/vaults")).find(
byIdOrName(ref.vault), byIdOrName(ref.vault),
); );
if (!vault) throw new Error(`1Password vault "${ref.vault}" not found`); if (!vault) throw new Error(`1Password vault "${ref.vault}" not found`);
const items = await connectGet<ConnectItemSummary[]>( const items = await connectGet<ConnectItemSummary[]>(
fetch,
source, source,
`/v1/vaults/${vault.id}/items?filter=${eqFilter(ref.item)}`, `/v1/vaults/${vault.id}/items?filter=${eqFilter(ref.item)}`,
); );
@@ -113,6 +114,7 @@ export async function resolveConnectReference(
} }
const item = await connectGet<{ fields?: ConnectField[] }>( const item = await connectGet<{ fields?: ConnectField[] }>(
fetch,
source, source,
`/v1/vaults/${vault.id}/items/${summary.id}`, `/v1/vaults/${vault.id}/items/${summary.id}`,
); );
@@ -0,0 +1,116 @@
import { eq, or } from "drizzle-orm";
import type { PluginDatabase } from "@termix/plugin-sdk/backend";
export type SecretSourceKind = "onepassword-connect";
export interface SecretSourceRecord {
id: string;
userId: string;
name: string;
kind: SecretSourceKind;
baseUrl: string;
shared: boolean;
createdAt: string;
updatedAt: string;
}
export interface SecretSourceCreateInput {
id: string;
userId: string;
name: string;
kind: SecretSourceKind;
baseUrl: string;
shared: boolean;
}
export interface SecretSourceUpdateInput {
name?: string;
baseUrl?: string;
shared?: boolean;
}
/* eslint-disable @typescript-eslint/no-explicit-any */
// The table comes from ctx.db.define, which the SDK hands back untyped, and
// the drizzle handle is the server's own. Typed at this module's edge instead.
type Table = any;
type Drizzle = any;
/* eslint-enable @typescript-eslint/no-explicit-any */
export type SecretSourceRepository = ReturnType<
typeof createSecretSourceRepository
>;
/**
* Per-user secret source storage. The token is never in this table - it
* lives in ctx.secrets under "source:<id>". A row with an id the caller
* chose is written without RETURNING so it runs the same on all three
* engines.
*/
export function createSecretSourceRepository(db: PluginDatabase, table: Table) {
const client = () => db.client<Drizzle>();
async function findById(id: string): Promise<SecretSourceRecord | null> {
const drizzle = await client();
const rows = await drizzle
.select()
.from(table)
.where(eq(table.id, id))
.limit(1);
return (rows[0] as SecretSourceRecord) ?? null;
}
return {
findById,
async listVisibleToUser(userId: string): Promise<SecretSourceRecord[]> {
const drizzle = await client();
return drizzle
.select()
.from(table)
.where(or(eq(table.userId, userId), eq(table.shared, true)));
},
async create(
input: SecretSourceCreateInput,
now = new Date().toISOString(),
): Promise<SecretSourceRecord> {
const drizzle = await client();
await drizzle.insert(table).values({
id: input.id,
userId: input.userId,
name: input.name,
kind: input.kind,
baseUrl: input.baseUrl,
shared: input.shared,
createdAt: now,
updatedAt: now,
});
await db.persist();
return (await findById(input.id)) as SecretSourceRecord;
},
async update(
row: SecretSourceRecord,
input: SecretSourceUpdateInput,
now = new Date().toISOString(),
): Promise<void> {
const drizzle = await client();
await drizzle
.update(table)
.set({
...(input.name !== undefined ? { name: input.name } : {}),
...(input.baseUrl !== undefined ? { baseUrl: input.baseUrl } : {}),
...(input.shared !== undefined ? { shared: input.shared } : {}),
updatedAt: now,
})
.where(eq(table.id, row.id));
await db.persist();
},
async deleteById(id: string): Promise<void> {
const drizzle = await client();
await drizzle.delete(table).where(eq(table.id, id));
await db.persist();
},
};
}
@@ -0,0 +1,61 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { SecretSourceRepository } from "./repository.js";
import type { TokenStore } from "./token-store.js";
import { parseAllowlist } from "./egress.js";
import {
parseSecretReference,
resolveConnectReference,
} from "./onepassword-connect.js";
/** The user's own source first, else a shared one. */
async function pickSecretSource(
repository: SecretSourceRepository,
userId: string,
) {
const sources = await repository.listVisibleToUser(userId);
return (
sources.find((source) => source.userId === userId) ?? sources[0] ?? null
);
}
/**
* The "op" scheme resolver: turns "op://vault/item/field" into the actual
* secret, using whichever source the acting user can see (their own first,
* else a shared one).
*/
export function registerOnePasswordResolver(
ctx: PluginContext,
repository: SecretSourceRepository,
tokenStore: TokenStore,
): void {
ctx.credentials.registerSecretResolver("op", async (userId, reference) => {
const ref = parseSecretReference(reference);
if (!ref) throw new Error(`Invalid secret reference: ${reference}`);
const source = await pickSecretSource(repository, userId);
if (!source) {
throw new Error(
"This host uses a secret reference but no secret source is configured",
);
}
const token = await tokenStore.getForOwner(source.id, source.userId);
if (!token) {
throw new Error(
"The secret source owner's data is locked; they need to sign in first",
);
}
return resolveConnectReference(
ctx.fetch,
{
baseUrl: source.baseUrl,
token,
allowedPrivateHosts: parseAllowlist(
await ctx.settings.get<string>("privateEndpoints"),
),
},
ref,
);
});
}
@@ -0,0 +1,255 @@
import { randomUUID } from "node:crypto";
import type { Request, Response, Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { SecretSourceRepository } from "./repository.js";
import type { TokenStore } from "./token-store.js";
import { testConnectSource } from "./onepassword-connect.js";
import { parseAllowlist } from "./egress.js";
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;
}
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export function registerSecretSourceRoutes(
router: Router,
ctx: PluginContext,
repository: SecretSourceRepository,
tokenStore: TokenStore,
): void {
const isAdmin = () => ctx.rbac.has("admin.plugins.manage");
const allowedPrivateHosts = async () =>
parseAllowlist(await ctx.settings.get<string>("privateEndpoints"));
/**
* @openapi
* /plugin-api/secret-sources:
* get:
* summary: List secret sources visible to the caller (own + shared)
* tags:
* - Secret Sources
*/
router.get(
"/",
ctx.rbac.require("credentials.view") as never,
async (req: Request, res: Response) => {
const userId = ctx.currentActor()!;
try {
const rows = await repository.listVisibleToUser(userId);
res.json({
sources: rows.map((row) => ({
...row,
owned: row.userId === userId,
})),
});
} catch (error) {
ctx.log.error("Failed to list secret sources", error);
res.status(500).json({ error: "Failed to list secret sources" });
}
},
);
/**
* @openapi
* /plugin-api/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(
"/",
ctx.rbac.require("credentials.create") as never,
async (req: Request, res: Response) => {
const userId = ctx.currentActor()!;
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 as readonly string[]).includes(kind)) {
return res
.status(400)
.json({ error: "Unsupported secret source kind" });
}
if (shared === true && !(await isAdmin())) {
return res
.status(403)
.json({ error: "Only admins can share a secret source" });
}
try {
const id = randomUUID();
const created = await repository.create({
id,
userId,
name: name.trim(),
kind,
baseUrl: url,
shared: shared === true,
});
await tokenStore.set(id, token.trim());
await ctx.audit.record({
action: "secret_source_create",
resourceType: "secret_source",
resourceId: created.id,
resourceName: created.name,
success: true,
});
res.json({ source: { ...created, owned: true } });
} catch (error) {
ctx.log.error("Failed to create secret source", error);
res.status(500).json({ error: "Failed to create secret source" });
}
},
);
/**
* @openapi
* /plugin-api/secret-sources/{id}:
* put:
* summary: Update a secret source (owner only; omit token to keep it)
* tags:
* - Secret Sources
*/
router.put(
"/:id",
ctx.rbac.require("credentials.edit") as never,
async (req: Request, res: Response) => {
const userId = ctx.currentActor()!;
const { name, baseUrl, token, shared } = req.body ?? {};
try {
const source = await repository.findById(String(req.params.id));
if (!source) {
return res.status(404).json({ error: "Secret source not found" });
}
if (source.userId !== userId) {
return res
.status(403)
.json({ error: "Only the owner can change this source" });
}
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 isAdmin())) {
return res
.status(403)
.json({ error: "Only admins can share a secret source" });
}
await repository.update(source, {
...(isNonEmptyString(name) ? { name: name.trim() } : {}),
...(url ? { baseUrl: url } : {}),
...(typeof shared === "boolean" ? { shared } : {}),
});
if (isNonEmptyString(token)) {
await tokenStore.set(source.id, token.trim());
}
res.json({ success: true });
} catch (error) {
ctx.log.error("Failed to update secret source", error);
res.status(500).json({ error: "Failed to update secret source" });
}
},
);
/**
* @openapi
* /plugin-api/secret-sources/{id}:
* delete:
* summary: Delete a secret source (owner only)
* tags:
* - Secret Sources
*/
router.delete(
"/:id",
ctx.rbac.require("credentials.delete") as never,
async (req: Request, res: Response) => {
const userId = ctx.currentActor()!;
try {
const source = await repository.findById(String(req.params.id));
if (!source) {
return res.status(404).json({ error: "Secret source not found" });
}
if (source.userId !== userId) {
return res
.status(403)
.json({ error: "Only the owner can change this source" });
}
await repository.deleteById(source.id);
await tokenStore.clear(source.id);
await ctx.audit.record({
action: "secret_source_delete",
resourceType: "secret_source",
resourceId: source.id,
resourceName: source.name,
success: true,
});
res.json({ success: true });
} catch (error) {
ctx.log.error("Failed to delete secret source", error);
res.status(500).json({ error: "Failed to delete secret source" });
}
},
);
/**
* @openapi
* /plugin-api/secret-sources/{id}/test:
* post:
* summary: Check that the source is reachable and the token is accepted
* tags:
* - Secret Sources
*/
router.post(
"/:id/test",
ctx.rbac.require("credentials.view") as never,
async (req: Request, res: Response) => {
const userId = ctx.currentActor()!;
try {
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 token = await tokenStore.getForOwner(source.id, source.userId);
if (!token) {
return res
.status(200)
.json({ ok: false, error: "No token stored for this source" });
}
const vaults = await testConnectSource(ctx.fetch, {
baseUrl: source.baseUrl,
token,
allowedPrivateHosts: await allowedPrivateHosts(),
});
res.json({ ok: true, vaults });
} catch (error) {
res.status(200).json({ ok: false, error: errorMessage(error) });
}
},
);
}
@@ -0,0 +1,42 @@
import {
adoptLegacyTable,
boolean,
defineTable,
refUser,
text,
timestamp,
varchar,
} from "@termix/plugin-sdk/db";
/**
* Adopted from core's secret_sources, so column and index names are the
* legacy ones and the rename carries every row across.
*
* The access token is not a column here: it lives in ctx.secrets under
* "source:<id>", the same move the ai plugin made for provider API keys. A
* 2.8 database still has a token column, which the boot migration empties
* after moving the value.
*/
export const sources = adoptLegacyTable(
"secret_sources",
defineTable(
"sources",
{
id: varchar().primaryKey(),
userId: refUser(),
name: text().notNull(),
// "onepassword-connect" for now; the reference syntax is per kind.
kind: text().notNull().default("onepassword-connect"),
baseUrl: text().notNull(),
// Visible to every user; the token still decrypts with the owner's key.
shared: boolean().notNull().default(false),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp().notNull().defaultNow(),
},
{
indexes: [{ name: "idx_secret_sources_user", columns: ["userId"] }],
},
),
);
export const tables = [sources];
@@ -0,0 +1,37 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
/**
* The access token for one source, kept out of the sources table and in
* ctx.secrets instead, the same move the ai plugin made for provider API
* keys: encrypted with the installation key rather than the owner's data
* key, so it is readable whenever the plugin runs rather than only while the
* owner is signed in. A shared source's token is still only ever handed to
* its owner's part of the resolve, so a read for any other acting user runs
* as the owner through ctx.asUser.
*/
function tokenKey(sourceId: string): string {
return `source:${sourceId}`;
}
export function createTokenStore(ctx: PluginContext) {
return {
async set(sourceId: string, token: string): Promise<void> {
await ctx.secrets.set(tokenKey(sourceId), token);
},
async clear(sourceId: string): Promise<void> {
await ctx.secrets.delete(tokenKey(sourceId));
},
/** Reads the token as the source's owner, whoever is currently acting. */
async getForOwner(
sourceId: string,
ownerId: string,
): Promise<string | null> {
return ctx.asUser(ownerId, () => ctx.secrets.get(tokenKey(sourceId)));
},
};
}
export type TokenStore = ReturnType<typeof createTokenStore>;
@@ -0,0 +1,18 @@
import { useTranslation } from "@termix/plugin-sdk/frontend";
/** 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("hint")}{" "}
<button
type="button"
className="text-accent-brand hover:underline"
onClick={onManage}
>
{t("manage")}
</button>
</p>
);
}
@@ -1,35 +1,18 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { KeyRound, Pencil, Plus, Trash2, X } from "lucide-react"; import { KeyRound, Pencil, Plus, Trash2, X } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/button"; import { Button, Input, PasswordInput } from "@termix/plugin-sdk/ui";
import { Input } from "@/components/input"; import { usePluginApi, useTranslation } from "@termix/plugin-sdk/frontend";
import { PasswordInput } from "@/components/password-input";
import { getErrorMessage } from "@/lib/error-message";
import { import {
createSecretSource, createSecretSourcesApi,
deleteSecretSource,
listSecretSources,
testSecretSource,
updateSecretSource,
type SecretSource, type SecretSource,
} from "@/api/secret-sources-api"; } from "./secret-sources-api";
/** One line under a secret field: references are allowed, here is where to set them up. */ function errorMessage(error: unknown): string {
export function SecretReferenceHint({ onManage }: { onManage: () => void }) { if (error && typeof error === "object" && "message" in error) {
const { t } = useTranslation(); return String((error as { message?: unknown }).message ?? "");
return ( }
<p className="text-[10px] text-muted-foreground"> return String(error);
{t("hosts.secretRefHint")}{" "}
<button
type="button"
className="text-accent-brand hover:underline"
onClick={onManage}
>
{t("hosts.secretSourcesManage")}
</button>
</p>
);
} }
type FormState = { type FormState = {
@@ -49,6 +32,8 @@ const emptyForm: FormState = {
export function SecretSourceManager({ onClose }: { onClose: () => void }) { export function SecretSourceManager({ onClose }: { onClose: () => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
const pluginApi = usePluginApi();
const api = useMemo(() => createSecretSourcesApi(pluginApi), [pluginApi]);
const [sources, setSources] = useState<SecretSource[]>([]); const [sources, setSources] = useState<SecretSource[]>([]);
const [form, setForm] = useState<FormState | null>(null); const [form, setForm] = useState<FormState | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -56,11 +41,11 @@ export function SecretSourceManager({ onClose }: { onClose: () => void }) {
const reload = useCallback(async () => { const reload = useCallback(async () => {
try { try {
setSources(await listSecretSources()); setSources(await api.list());
} catch (e) { } catch (e) {
toast.error(getErrorMessage(e)); toast.error(errorMessage(e));
} }
}, []); }, [api]);
useEffect(() => { useEffect(() => {
void reload(); void reload();
@@ -76,31 +61,31 @@ export function SecretSourceManager({ onClose }: { onClose: () => void }) {
!form.baseUrl.trim() || !form.baseUrl.trim() ||
(!form.id && !form.token) (!form.id && !form.token)
) { ) {
toast.error(t("hosts.secretSourceRequired")); toast.error(t("required"));
return; return;
} }
setSaving(true); setSaving(true);
try { try {
if (form.id) { if (form.id) {
await updateSecretSource(form.id, { await api.update(form.id, {
name: form.name, name: form.name,
baseUrl: form.baseUrl, baseUrl: form.baseUrl,
shared: form.shared, shared: form.shared,
...(form.token ? { token: form.token } : {}), ...(form.token ? { token: form.token } : {}),
}); });
} else { } else {
await createSecretSource({ await api.create({
name: form.name, name: form.name,
baseUrl: form.baseUrl, baseUrl: form.baseUrl,
token: form.token, token: form.token,
shared: form.shared, shared: form.shared,
}); });
} }
toast.success(t("hosts.secretSourceSaved")); toast.success(t("saved"));
setForm(null); setForm(null);
await reload(); await reload();
} catch (e) { } catch (e) {
toast.error(getErrorMessage(e)); toast.error(errorMessage(e));
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -108,27 +93,25 @@ export function SecretSourceManager({ onClose }: { onClose: () => void }) {
const handleDelete = async (source: SecretSource) => { const handleDelete = async (source: SecretSource) => {
try { try {
await deleteSecretSource(source.id); await api.remove(source.id);
toast.success(t("hosts.secretSourceDeleted")); toast.success(t("deleted"));
await reload(); await reload();
} catch (e) { } catch (e) {
toast.error(getErrorMessage(e)); toast.error(errorMessage(e));
} }
}; };
const handleTest = async (source: SecretSource) => { const handleTest = async (source: SecretSource) => {
setTesting(source.id); setTesting(source.id);
try { try {
const result = await testSecretSource(source.id); const result = await api.test(source.id);
if (result.ok) { if (result.ok) {
toast.success( toast.success(t("testOk", { count: result.vaults ?? 0 }));
t("hosts.secretSourceTestOk", { count: result.vaults ?? 0 }),
);
} else { } else {
toast.error(result.error ?? t("hosts.secretSourceTestFailed")); toast.error(result.error ?? t("testFailed"));
} }
} catch (e) { } catch (e) {
toast.error(getErrorMessage(e)); toast.error(errorMessage(e));
} finally { } finally {
setTesting(null); setTesting(null);
} }
@@ -138,7 +121,7 @@ export function SecretSourceManager({ onClose }: { onClose: () => void }) {
<div className="flex flex-col gap-3 col-span-2 border border-border bg-muted/20 p-3"> <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"> <div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground"> <span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.secretSourcesTitle")} {t("title")}
</span> </span>
<button <button
type="button" type="button"
@@ -148,9 +131,7 @@ export function SecretSourceManager({ onClose }: { onClose: () => void }) {
<X className="size-3.5" /> <X className="size-3.5" />
</button> </button>
</div> </div>
<p className="text-[10px] text-muted-foreground"> <p className="text-[10px] text-muted-foreground">{t("description")}</p>
{t("hosts.secretSourcesDesc")}
</p>
{!form && ( {!form && (
<> <>
@@ -165,7 +146,7 @@ export function SecretSourceManager({ onClose }: { onClose: () => void }) {
{source.name} {source.name}
{source.shared && ( {source.shared && (
<span className="ml-1 text-[9px] uppercase text-muted-foreground"> <span className="ml-1 text-[9px] uppercase text-muted-foreground">
{t("hosts.secretSourceShared")} {t("shared")}
</span> </span>
)} )}
</div> </div>
@@ -181,7 +162,7 @@ export function SecretSourceManager({ onClose }: { onClose: () => void }) {
disabled={testing === source.id} disabled={testing === source.id}
onClick={() => void handleTest(source)} onClick={() => void handleTest(source)}
> >
{t("hosts.secretSourceTest")} {t("test")}
</Button> </Button>
{source.owned && ( {source.owned && (
<> <>
@@ -218,7 +199,7 @@ export function SecretSourceManager({ onClose }: { onClose: () => void }) {
className="self-start border-accent-brand/40 text-accent-brand" className="self-start border-accent-brand/40 text-accent-brand"
onClick={() => setForm(emptyForm)} onClick={() => setForm(emptyForm)}
> >
<Plus className="size-3 mr-1" /> {t("hosts.secretSourceNew")} <Plus className="size-3 mr-1" /> {t("new")}
</Button> </Button>
</> </>
)} )}
@@ -228,7 +209,7 @@ export function SecretSourceManager({ onClose }: { onClose: () => void }) {
<div className="grid grid-cols-1 md:grid-cols-2 gap-2"> <div className="grid grid-cols-1 md:grid-cols-2 gap-2">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground"> <label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.friendlyNameLabel")} {t("nameLabel")}
</label> </label>
<Input <Input
className="h-8 text-xs" className="h-8 text-xs"
@@ -239,7 +220,7 @@ export function SecretSourceManager({ onClose }: { onClose: () => void }) {
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground"> <label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.secretSourceUrlLabel")} {t("urlLabel")}
</label> </label>
<Input <Input
className="h-8 text-xs" className="h-8 text-xs"
@@ -251,13 +232,11 @@ export function SecretSourceManager({ onClose }: { onClose: () => void }) {
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground"> <label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.secretSourceTokenLabel")} {t("tokenLabel")}
</label> </label>
<PasswordInput <PasswordInput
className="h-8 text-xs pr-8" className="h-8 text-xs pr-8"
placeholder={ placeholder={form.id ? t("tokenKeep") : "eyJhbGciOi..."}
form.id ? t("hosts.secretSourceTokenKeep") : "eyJhbGciOi..."
}
value={form.token} value={form.token}
onChange={(e) => setField("token", e.target.value)} onChange={(e) => setField("token", e.target.value)}
/> />
@@ -268,7 +247,7 @@ export function SecretSourceManager({ onClose }: { onClose: () => void }) {
checked={form.shared} checked={form.shared}
onChange={(e) => setField("shared", e.target.checked)} onChange={(e) => setField("shared", e.target.checked)}
/> />
{t("hosts.secretSourceSharedLabel")} {t("sharedLabel")}
</label> </label>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button <Button
@@ -278,7 +257,7 @@ export function SecretSourceManager({ onClose }: { onClose: () => void }) {
onClick={() => setForm(null)} onClick={() => setForm(null)}
disabled={saving} disabled={saving}
> >
{t("hosts.cancelBtn")} {t("common.cancel")}
</Button> </Button>
<Button <Button
type="button" type="button"
@@ -0,0 +1,8 @@
import type { TermixApp } from "@termix/plugin-sdk/frontend";
import { SecretSourceManager } from "./SecretSourceManager";
import { SecretReferenceHint } from "./SecretReferenceHint";
export function activate(app: TermixApp): void {
app.registerComponent("secret-sources.hint", SecretReferenceHint);
app.registerComponent("secret-sources.manager", SecretSourceManager);
}
@@ -0,0 +1,51 @@
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
export type SecretSourceKind = "onepassword-connect";
export interface SecretSource {
id: string;
name: string;
kind: SecretSourceKind;
baseUrl: string;
shared: boolean;
owned: boolean;
createdAt: string;
updatedAt: string;
}
export interface SecretSourceInput {
name: string;
baseUrl: string;
token: string;
shared?: boolean;
}
export interface SecretSourceUpdateInput {
name?: string;
baseUrl?: string;
token?: string;
shared?: boolean;
}
/** The secret-sources routes, through the plugin's own client. */
export function createSecretSourcesApi(api: PluginApiClient) {
const data = async <T>(request: Promise<{ data: T }>) => (await request).data;
return {
list: () =>
data(api.get<{ sources: SecretSource[] }>("/")).then((r) => r.sources),
create: (body: SecretSourceInput) =>
data(api.post<{ source: SecretSource }>("/", body)).then((r) => r.source),
update: (id: string, body: SecretSourceUpdateInput) =>
data(api.put<{ success: boolean }>(`/${id}`, body)),
remove: (id: string) => data(api.delete<{ success: boolean }>(`/${id}`)),
test: (id: string) =>
data(
api.post<{ ok: boolean; vaults?: number; error?: string }>(
`/${id}/test`,
),
),
};
}
export type SecretSourcesApi = ReturnType<typeof createSecretSourcesApi>;
@@ -0,0 +1,103 @@
import http from "node:http";
import type { AddressInfo } from "node:net";
import { fileURLToPath } from "node:url";
import express, { type Router } from "express";
import {
createMockCtx,
createTestDb,
type MockPluginContext,
type TestDb,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import manifestJson from "../../manifest.json";
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
export const manifest = manifestJson as unknown as PluginManifest;
export const ALL_PERMISSIONS = [
"credentials.view",
"credentials.create",
"credentials.edit",
"credentials.delete",
];
export interface TestServer {
db: TestDb;
mock: MockPluginContext;
request: (
method: string,
path: string,
options?: { user?: string; body?: unknown },
// Route bodies vary per test; asserting on them is the point.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => Promise<{ status: number; body: any }>;
close: () => Promise<void>;
}
/**
* The plugin activated against a real in-memory database, its router mounted
* on an express app the way core mounts it: JSON parsed, the acting user set
* from the request, everything else left to the plugin.
*/
export async function startServer(
options: {
permissions?: string[];
users?: string[];
fetch?: (url: string, init?: unknown) => Promise<Response>;
} = {},
): Promise<TestServer> {
const db = await createTestDb(pluginDir);
for (const user of options.users ?? ["user-1", "user-2"]) {
db.sqlite
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
.run(user, user);
}
let router: Router | null = null;
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities,
db: db.database,
router: () => (router = express.Router()),
permissions: options.permissions ?? ALL_PERMISSIONS,
fetch: options.fetch as never,
});
const { activate } = await import("../../src/backend/index.js");
await activate(mock.ctx);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
mock.setActor(req.header("x-test-user") ?? undefined);
next();
});
app.use((req, res, next) => router!(req, res, next));
const server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
const { port } = server.address() as AddressInfo;
return {
db,
mock,
async request(method, path, { user = "user-1", body: given } = {}) {
const body = method === "GET" ? undefined : given;
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
method,
headers: {
"x-test-user": user,
...(body !== undefined ? { "content-type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await response.text();
return { status: response.status, body: text ? JSON.parse(text) : null };
},
async close() {
await new Promise<void>((resolve) => server.close(() => resolve()));
db.close();
},
};
}
@@ -0,0 +1,117 @@
import { describe, expect, it, vi } from "vitest";
import {
parseSecretReference,
resolveConnectReference,
testConnectSource,
} from "../../src/backend/onepassword-connect.js";
import type { PluginFetch } from "@termix/plugin-sdk/backend";
function jsonResponse(body: unknown, ok = true, status = 200): Response {
return {
ok,
status,
json: async () => body,
} as unknown as Response;
}
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();
});
});
describe("testConnectSource", () => {
it("returns the vault count when the token is valid", async () => {
const fetch = vi.fn().mockResolvedValue(
jsonResponse([
{ id: "v1", name: "Infra" },
{ id: "v2", name: "Dev" },
]),
) as unknown as PluginFetch;
const count = await testConnectSource(fetch, {
baseUrl: "https://connect.internal",
token: "tok",
allowedPrivateHosts: ["connect.internal"],
});
expect(count).toBe(2);
expect(fetch).toHaveBeenCalledWith(
"https://connect.internal/v1/vaults",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
allowPrivateHosts: ["connect.internal"],
}),
);
});
it("throws when the server answers with a non-2xx status", async () => {
const fetch = vi
.fn()
.mockResolvedValue(
jsonResponse([], false, 401),
) as unknown as PluginFetch;
await expect(
testConnectSource(fetch, {
baseUrl: "https://connect.internal",
token: "bad",
allowedPrivateHosts: [],
}),
).rejects.toThrow(/HTTP 401/);
});
});
describe("resolveConnectReference", () => {
it("resolves a field by label across vault, item and field lookups", async () => {
const fetch = vi
.fn()
.mockResolvedValueOnce(jsonResponse([{ id: "vid", name: "Infra" }]))
.mockResolvedValueOnce(jsonResponse([{ id: "iid", title: "prod-db" }]))
.mockResolvedValueOnce(
jsonResponse({
fields: [{ id: "f1", label: "password", value: "s3cret" }],
}),
) as unknown as PluginFetch;
const value = await resolveConnectReference(
fetch,
{
baseUrl: "https://connect.internal",
token: "tok",
allowedPrivateHosts: [],
},
{ vault: "Infra", item: "prod-db", field: "password" },
);
expect(value).toBe("s3cret");
});
it("fails clearly when the vault is not found", async () => {
const fetch = vi
.fn()
.mockResolvedValueOnce(jsonResponse([]))
.mockResolvedValueOnce(jsonResponse([])) as unknown as PluginFetch;
await expect(
resolveConnectReference(
fetch,
{
baseUrl: "https://connect.internal",
token: "tok",
allowedPrivateHosts: [],
},
{ vault: "Missing", item: "x", field: "y" },
),
).rejects.toThrow(/vault "Missing" not found/);
});
});
@@ -0,0 +1,93 @@
import { fileURLToPath } from "node:url";
import { describe, expect, it, vi } from "vitest";
import { createMockCtx, createTestDb } from "@termix/plugin-sdk/testing";
import { createSecretSourceRepository } from "../../src/backend/repository.js";
import { createTokenStore } from "../../src/backend/token-store.js";
import { registerOnePasswordResolver } from "../../src/backend/resolver.js";
import { sources } from "../../src/backend/tables.js";
import manifestJson from "../../manifest.json";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
const manifest = manifestJson as unknown as PluginManifest;
const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
function jsonResponse(body: unknown): Response {
return {
ok: true,
status: 200,
json: async () => body,
} as unknown as Response;
}
async function setUp(fetchMock: ReturnType<typeof vi.fn>) {
const db = await createTestDb(pluginDir);
db.sqlite
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
.run("owner-1", "owner-1");
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities,
db: db.database,
actor: "owner-1",
fetch: fetchMock as never,
});
const table = await mock.ctx.db.define(sources);
const repository = createSecretSourceRepository(mock.ctx.db, table as never);
const tokenStore = createTokenStore(mock.ctx);
registerOnePasswordResolver(mock.ctx, repository, tokenStore);
const resolve = mock.auth.secretResolvers.get("op");
if (!resolve) throw new Error("resolver was not registered");
return { db, repository, tokenStore, resolve };
}
describe("op:// secret resolver", () => {
it("registers under the declared scheme", async () => {
const { resolve } = await setUp(vi.fn());
expect(typeof resolve).toBe("function");
});
it("resolves through the owner's source", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse([{ id: "vid", name: "Infra" }]))
.mockResolvedValueOnce(jsonResponse([{ id: "iid", title: "prod-db" }]))
.mockResolvedValueOnce(
jsonResponse({
fields: [{ id: "f1", label: "password", value: "s3cret" }],
}),
);
const { repository, tokenStore, resolve } = await setUp(fetchMock);
const source = await repository.create({
id: "src-1",
userId: "owner-1",
name: "n",
kind: "onepassword-connect",
baseUrl: "https://connect.internal",
shared: false,
});
await tokenStore.set(source.id, "tok");
const value = await resolve("owner-1", "op://Infra/prod-db/password");
expect(value).toBe("s3cret");
});
it("fails clearly with no configured source", async () => {
const { resolve } = await setUp(vi.fn());
await expect(
resolve("nobody", "op://Infra/prod-db/password"),
).rejects.toThrow(/no secret source is configured/);
});
it("rejects a malformed reference", async () => {
const { resolve } = await setUp(vi.fn());
await expect(resolve("owner-1", "op://only-two")).rejects.toThrow(
/Invalid secret reference/,
);
});
});
@@ -0,0 +1,177 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { startServer, type TestServer } from "./helpers.js";
function jsonResponse(body: unknown, ok = true, status = 200): Response {
return { ok, status, json: async () => body } as unknown as Response;
}
let server: TestServer;
afterEach(async () => {
await server?.close();
});
describe("secret-sources routes", () => {
beforeEach(async () => {
server = await startServer();
});
it("refuses a request without the matching core permission", async () => {
await server.close();
server = await startServer({ permissions: [] });
const res = await server.request("GET", "/");
expect(res.status).toBe(403);
});
it("creates, lists and hides the token from the owner's own list", async () => {
const create = await server.request("POST", "/", {
body: {
name: "Team 1Password",
baseUrl: "https://connect.internal",
token: "s3cret-token",
},
});
expect(create.status).toBe(200);
expect(create.body.source).toMatchObject({
name: "Team 1Password",
baseUrl: "https://connect.internal",
owned: true,
shared: false,
});
expect(create.body.source.token).toBeUndefined();
const list = await server.request("GET", "/");
expect(list.body.sources).toHaveLength(1);
expect(list.body.sources[0].owned).toBe(true);
});
it("rejects a create missing a required field", async () => {
const res = await server.request("POST", "/", {
body: { name: "", baseUrl: "https://x", token: "t" },
});
expect(res.status).toBe(400);
});
it("rejects an http(s)-only baseUrl", async () => {
const res = await server.request("POST", "/", {
body: { name: "n", baseUrl: "ftp://x", token: "t" },
});
expect(res.status).toBe(400);
});
it("refuses shared: true for a non-admin", async () => {
const res = await server.request("POST", "/", {
body: {
name: "n",
baseUrl: "https://connect.internal",
token: "t",
shared: true,
},
});
expect(res.status).toBe(403);
});
it("allows shared: true for an admin", async () => {
await server.close();
server = await startServer({
permissions: [
"credentials.view",
"credentials.create",
"credentials.edit",
"credentials.delete",
"admin.plugins.manage",
],
});
const res = await server.request("POST", "/", {
body: {
name: "n",
baseUrl: "https://connect.internal",
token: "t",
shared: true,
},
});
expect(res.status).toBe(200);
expect(res.body.source.shared).toBe(true);
});
it("shows a shared source to another user, unowned", async () => {
await server.close();
server = await startServer({
permissions: [
"credentials.view",
"credentials.create",
"credentials.edit",
"credentials.delete",
"admin.plugins.manage",
],
});
await server.request("POST", "/", {
body: { name: "n", baseUrl: "https://x", token: "t", shared: true },
});
const list = await server.request("GET", "/", { user: "user-2" });
expect(list.body.sources).toHaveLength(1);
expect(list.body.sources[0].owned).toBe(false);
});
it("refuses an update or delete from a non-owner", async () => {
const created = await server.request("POST", "/", {
body: { name: "n", baseUrl: "https://x", token: "t" },
});
const id = created.body.source.id;
const update = await server.request("PUT", `/${id}`, {
user: "user-2",
body: { name: "hijacked" },
});
expect(update.status).toBe(403);
const del = await server.request("DELETE", `/${id}`, { user: "user-2" });
expect(del.status).toBe(403);
});
it("updates without a token keeping the existing one, and deletes cleanly", async () => {
const created = await server.request("POST", "/", {
body: { name: "n", baseUrl: "https://x", token: "orig-token" },
});
const id = created.body.source.id;
const update = await server.request("PUT", `/${id}`, {
body: { name: "renamed" },
});
expect(update.status).toBe(200);
const test = await server.request("POST", `/${id}/test`);
// Reaches out through ctx.fetch, unstubbed here, so it fails softly.
expect(test.status).toBe(200);
expect(test.body.ok).toBe(false);
const del = await server.request("DELETE", `/${id}`);
expect(del.status).toBe(200);
const list = await server.request("GET", "/");
expect(list.body.sources).toHaveLength(0);
});
it("tests a source through ctx.fetch and reports the vault count", async () => {
await server.close();
const fetchMock = vi
.fn()
.mockResolvedValue(jsonResponse([{ id: "v1", name: "Infra" }]));
server = await startServer({ fetch: fetchMock });
const created = await server.request("POST", "/", {
body: { name: "n", baseUrl: "https://connect.internal", token: "tok" },
});
const id = created.body.source.id;
const test = await server.request("POST", `/${id}/test`);
expect(test.status).toBe(200);
expect(test.body).toEqual({ ok: true, vaults: 1 });
expect(fetchMock).toHaveBeenCalledWith(
"https://connect.internal/v1/vaults",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
});
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { createMockCtx } from "@termix/plugin-sdk/testing";
import { createTokenStore } from "../../src/backend/token-store.js";
import manifestJson from "../../manifest.json";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
const manifest = manifestJson as unknown as PluginManifest;
function mockCtx(actor?: string) {
return createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities,
actor,
});
}
describe("token store", () => {
it("stores and reads back a token for the acting user", async () => {
const { ctx } = mockCtx("owner-1");
const store = createTokenStore(ctx);
await store.set("src-1", "s3cret");
await expect(store.getForOwner("src-1", "owner-1")).resolves.toBe("s3cret");
});
it("reads the owner's token for a caller currently acting as someone else", async () => {
const { ctx, setActor } = mockCtx("owner-1");
const store = createTokenStore(ctx);
await store.set("src-1", "s3cret");
// A shared source's test/resolve runs as the recipient viewing it.
setActor("recipient-1");
await expect(store.getForOwner("src-1", "owner-1")).resolves.toBe("s3cret");
// ctx.asUser restores the previous actor once it returns.
expect(ctx.currentActor()).toBe("recipient-1");
});
it("clears a token", async () => {
const { ctx } = mockCtx("owner-1");
const store = createTokenStore(ctx);
await store.set("src-1", "s3cret");
await store.clear("src-1");
await expect(store.getForOwner("src-1", "owner-1")).resolves.toBeNull();
});
});
@@ -0,0 +1,56 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import {
renderWithApp,
type RenderedPluginApp,
} from "@termix/plugin-sdk/testing";
import { PluginComponent } from "@termix/plugin-sdk/ui";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import * as plugin from "../../src/frontend/index";
import manifestJson from "../../manifest.json";
import locales from "../../locales/en.json";
const manifest = manifestJson as unknown as PluginManifest;
let rendered: RenderedPluginApp | null = null;
afterEach(async () => {
await rendered?.deactivate();
rendered = null;
});
describe(`${manifest.id} activate`, () => {
it("registers the hint and manager components", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
render(
<PluginComponent
id="secret-sources.hint"
onManage={vi.fn()}
fallback={<span>fallback</span>}
/>,
);
expect(await screen.findByText(locales.hint)).toBeTruthy();
});
it("renders the manager and can be closed", async () => {
rendered = await renderWithApp(plugin, {
manifest,
locales,
api: {
get: vi.fn(async () => ({ data: { sources: [] } })),
} as never,
});
const onClose = vi.fn();
render(<PluginComponent id="secret-sources.manager" onClose={onClose} />);
expect(await screen.findByText(locales.title)).toBeTruthy();
});
it("removes both components on deactivate", async () => {
const app = await renderWithApp(plugin, { manifest, locales });
await app.deactivate();
render(
<PluginComponent id="secret-sources.hint" fallback={<span>gone</span>} />,
);
expect(await screen.findByText("gone")).toBeTruthy();
});
});
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
}
+3
View File
@@ -0,0 +1,3 @@
import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
export default pluginVitestConfig(import.meta.url);
-2
View File
@@ -19,7 +19,6 @@ import uiPreferencesRoutes from "./routes/ui-preferences.js";
import termixIdRoutes from "./routes/termix-id.js"; import termixIdRoutes from "./routes/termix-id.js";
import { registerAuditLogRoutes } from "./routes/audit-log-routes.js"; import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
import vaultRoutes from "./routes/vault.js"; import vaultRoutes from "./routes/vault.js";
import secretSourceRoutes from "./routes/secret-sources.js";
import notificationChannelsRoutes from "./routes/notification-channels-routes.js"; import notificationChannelsRoutes from "./routes/notification-channels-routes.js";
import syncRoutes from "./routes/sync.js"; import syncRoutes from "./routes/sync.js";
import dashboardRoutes from "./routes/dashboard-routes.js"; import dashboardRoutes from "./routes/dashboard-routes.js";
@@ -1803,7 +1802,6 @@ app.use("/ui-preferences", uiPreferencesRoutes);
app.use("/termix-id", termixIdRoutes); app.use("/termix-id", termixIdRoutes);
registerAuditLogRoutes(app, authenticateJWT); registerAuditLogRoutes(app, authenticateJWT);
app.use("/vault", vaultRoutes); app.use("/vault", vaultRoutes);
app.use("/secret-sources", secretSourceRoutes);
app.use("/", notificationChannelsRoutes); app.use("/", notificationChannelsRoutes);
app.use("/sync", syncRoutes); app.use("/sync", syncRoutes);
app.use("/dashboard", dashboardRoutes); app.use("/dashboard", dashboardRoutes);
-13
View File
@@ -477,19 +477,6 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL
); );
CREATE TABLE IF NOT EXISTS secret_sources (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'onepassword-connect',
base_url TEXT NOT NULL,
token TEXT NOT NULL,
shared INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS credential_access ( CREATE TABLE IF NOT EXISTS credential_access (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
credential_id INTEGER NOT NULL, credential_id INTEGER NOT NULL,
-32
View File
@@ -1030,38 +1030,6 @@ export const syncTombstones = mysqlTable("sync_tombstones", {
// --- collab rooms --- // --- collab rooms ---
// --- secret sources ---
/**
* An external password manager Termix pulls secrets from at connect time,
* instead of storing them. Only the access token is secret; it is encrypted
* with the owner's data key under the row id. Hosts and credentials refer to
* entries by reference ("op://vault/item/field") in their secret fields.
*/
export const secretSources = mysqlTable(
"secret_sources",
{
id: varchar("id", { length: 255 }).primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
// "onepassword-connect" for now; the reference syntax is per kind.
kind: text("kind").notNull().default("onepassword-connect"),
baseUrl: text("base_url").notNull(),
token: text("token").notNull(),
// Visible to every user; secrets still decrypt with the owner's key.
shared: boolean("shared").notNull().default(false),
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: text("updated_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [index("idx_secret_sources_user").on(table.userId)],
);
// --- credential sharing --- // --- credential sharing ---
/** /**
-32
View File
@@ -1031,38 +1031,6 @@ export const syncTombstones = pgTable("sync_tombstones", {
// --- collab rooms --- // --- collab rooms ---
// --- secret sources ---
/**
* An external password manager Termix pulls secrets from at connect time,
* instead of storing them. Only the access token is secret; it is encrypted
* with the owner's data key under the row id. Hosts and credentials refer to
* entries by reference ("op://vault/item/field") in their secret fields.
*/
export const secretSources = pgTable(
"secret_sources",
{
id: varchar("id", { length: 255 }).primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
// "onepassword-connect" for now; the reference syntax is per kind.
kind: text("kind").notNull().default("onepassword-connect"),
baseUrl: text("base_url").notNull(),
token: text("token").notNull(),
// Visible to every user; secrets still decrypt with the owner's key.
shared: boolean("shared").notNull().default(false),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [index("idx_secret_sources_user").on(table.userId)],
);
// --- credential sharing --- // --- credential sharing ---
/** /**
-32
View File
@@ -1027,38 +1027,6 @@ export const syncTombstones = sqliteTable("sync_tombstones", {
// --- collab rooms --- // --- collab rooms ---
// --- secret sources ---
/**
* An external password manager Termix pulls secrets from at connect time,
* instead of storing them. Only the access token is secret; it is encrypted
* with the owner's data key under the row id. Hosts and credentials refer to
* entries by reference ("op://vault/item/field") in their secret fields.
*/
export const secretSources = sqliteTable(
"secret_sources",
{
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(),
// "onepassword-connect" for now; the reference syntax is per kind.
kind: text("kind").notNull().default("onepassword-connect"),
baseUrl: text("base_url").notNull(),
token: text("token").notNull(),
// Visible to every user; secrets still decrypt with the owner's key.
shared: integer("shared", { mode: "boolean" }).notNull().default(false),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [index("idx_secret_sources_user").on(table.userId)],
);
// --- credential sharing --- // --- credential sharing ---
/** /**
@@ -30,7 +30,6 @@ import { RbacPermissionRepository } from "./rbac-permission-repository.js";
import { RecentActivityRepository } from "./recent-activity-repository.js"; import { RecentActivityRepository } from "./recent-activity-repository.js";
import { RoleRepository } from "./role-repository.js"; import { RoleRepository } from "./role-repository.js";
import { SessionRepository } from "./session-repository.js"; import { SessionRepository } from "./session-repository.js";
import { SecretSourceRepository } from "./secret-source-repository.js";
import { CredentialAccessRepository } from "./credential-access-repository.js"; import { CredentialAccessRepository } from "./credential-access-repository.js";
import { SharedCredentialSecretsRepository } from "./shared-credential-secrets-repository.js"; import { SharedCredentialSecretsRepository } from "./shared-credential-secrets-repository.js";
import { FolderAccessRepository } from "./folder-access-repository.js"; import { FolderAccessRepository } from "./folder-access-repository.js";
@@ -441,13 +440,6 @@ export function createCurrentSharedCredentialSecretsRepository(): SharedCredenti
); );
} }
export function createCurrentSecretSourceRepository(): SecretSourceRepository {
return new SecretSourceRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("secret_source_repository_write"),
);
}
export function createCurrentVaultProfileRepository(): VaultProfileRepository { export function createCurrentVaultProfileRepository(): VaultProfileRepository {
return new VaultProfileRepository( return new VaultProfileRepository(
createCurrentRepositoryContext(), createCurrentRepositoryContext(),
@@ -1,135 +0,0 @@
import { desc, eq, or } from "drizzle-orm";
import { secretSources } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { insertReturning } from "./returning.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { FieldCrypto } from "../../utils/field-crypto.js";
export type SecretSourceRecord = typeof secretSources.$inferSelect;
export type SecretSourceKind = "onepassword-connect";
/** A row with the token still encrypted - safe to hand to the API. */
export type SecretSourcePublic = Omit<SecretSourceRecord, "token"> & {
hasToken: boolean;
};
export interface SecretSourceCreateInput {
id: string;
userId: string;
name: string;
kind: SecretSourceKind;
baseUrl: string;
token: string;
shared: boolean;
}
export interface SecretSourceUpdateInput {
name?: string;
baseUrl?: string;
token?: string;
shared?: boolean;
}
export function toPublicSecretSource(
row: SecretSourceRecord,
): SecretSourcePublic {
const { token, ...rest } = row;
return { ...rest, hasToken: token.length > 0 };
}
/**
* The token is encrypted with the owner's data key under the row id, like
* vault_tokens - so a shared source only decrypts while its owner's key is
* loaded, which the resolver reports as a clear error.
*/
export class SecretSourceRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
private encryptToken(id: string, ownerId: string, token: string): string {
const key = DataCrypto.validateUserAccess(ownerId);
return FieldCrypto.encryptField(token, key, id, "token");
}
decryptToken(row: SecretSourceRecord): string {
const key = DataCrypto.getUserDataKey(row.userId);
if (!key) {
throw new Error(
"The secret source owner's data is locked; they need to sign in first",
);
}
return FieldCrypto.decryptField(row.token, key, row.id, "token");
}
async listVisibleToUser(userId: string): Promise<SecretSourceRecord[]> {
return this.context.drizzle
.select()
.from(secretSources)
.where(
or(eq(secretSources.userId, userId), eq(secretSources.shared, true)),
)
.orderBy(desc(secretSources.updatedAt));
}
async findById(id: string): Promise<SecretSourceRecord | null> {
const rows = await this.context.drizzle
.select()
.from(secretSources)
.where(eq(secretSources.id, id))
.limit(1);
return rows[0] ?? null;
}
async create(input: SecretSourceCreateInput): Promise<SecretSourceRecord> {
const [created] = await insertReturning(this.context, secretSources, {
id: input.id,
userId: input.userId,
name: input.name,
kind: input.kind,
baseUrl: input.baseUrl,
token: this.encryptToken(input.id, input.userId, input.token),
shared: input.shared,
});
await this.afterWrite();
return created;
}
async update(
row: SecretSourceRecord,
input: SecretSourceUpdateInput,
): Promise<void> {
await this.context.drizzle
.update(secretSources)
.set({
...(input.name !== undefined ? { name: input.name } : {}),
...(input.baseUrl !== undefined ? { baseUrl: input.baseUrl } : {}),
...(input.shared !== undefined ? { shared: input.shared } : {}),
...(input.token !== undefined
? { token: this.encryptToken(row.id, row.userId, input.token) }
: {}),
updatedAt: new Date().toISOString(),
})
.where(eq(secretSources.id, row.id));
await this.afterWrite();
}
async deleteById(id: string): Promise<void> {
await this.context.drizzle
.delete(secretSources)
.where(eq(secretSources.id, id));
await this.afterWrite();
}
async deleteByUserId(userId: string): Promise<void> {
await this.context.drizzle
.delete(secretSources)
.where(eq(secretSources.userId, userId));
await this.afterWrite();
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -25,7 +25,6 @@ import {
createCurrentUserPreferenceRepository, createCurrentUserPreferenceRepository,
createCurrentUserRepository, createCurrentUserRepository,
createCurrentVaultProfileRepository, createCurrentVaultProfileRepository,
createCurrentSecretSourceRepository,
createCurrentSharedCredentialSecretsRepository, createCurrentSharedCredentialSecretsRepository,
createCurrentCredentialAccessRepository, createCurrentCredentialAccessRepository,
createCurrentVaultTokenRepository, createCurrentVaultTokenRepository,
@@ -94,14 +93,15 @@ export async function deleteUserAndRelatedData(
await createCurrentHostRepository().deleteByUserId(userId); await createCurrentHostRepository().deleteByUserId(userId);
await createCurrentCredentialRepository().deleteByUserId(userId); await createCurrentCredentialRepository().deleteByUserId(userId);
// homepage_items, homepage_layouts and dashboard_service_links cascade on // homepage_items, homepage_layouts, dashboard_service_links and
// the user's refUser() foreign key, as the homepage plugin's adopted // secret_sources cascade on the user's refUser() foreign key, as the
// tables. // homepage and secret-sources plugins' adopted tables. The secret
// source's token in ctx.secrets is cleaned up generically below, with
// every other plugin_settings row for this user.
await createCurrentOpksshTokenRepository().deleteByUserId(userId); await createCurrentOpksshTokenRepository().deleteByUserId(userId);
await createCurrentVaultTokenRepository().deleteByUserId(userId); await createCurrentVaultTokenRepository().deleteByUserId(userId);
await createCurrentVaultProfileRepository().deleteByUserId(userId); await createCurrentVaultProfileRepository().deleteByUserId(userId);
await createCurrentSecretSourceRepository().deleteByUserId(userId);
await createCurrentTermixIdentityCaRepository().deleteByUserId(userId); await createCurrentTermixIdentityCaRepository().deleteByUserId(userId);
await createCurrentTermixIdentityRepository().deleteByUserId(userId); await createCurrentTermixIdentityRepository().deleteByUserId(userId);
await createCurrentOpenTabRepository().deleteByUserId(userId); await createCurrentOpenTabRepository().deleteByUserId(userId);
@@ -1,278 +0,0 @@
import crypto from "crypto";
import express, { type Request, type Response } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import { authLogger } from "../../utils/logger.js";
import { getErrorMessage } from "../../utils/error-message.js";
import {
logAudit,
getAuditUsername,
getRequestMeta,
} from "../../utils/audit-logger.js";
import { testConnectSource } from "../../utils/onepassword-connect.js";
import { readSecretSourcePrivateAllowlist } from "../../utils/secret-source-egress.js";
import { clearExternalSecretCache } from "../../hosts/external-secrets.js";
import { createCurrentSecretSourceRepository } from "../repositories/factory.js";
import {
toPublicSecretSource,
type SecretSourceRecord,
} from "../repositories/secret-source-repository.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware();
const permissionManager = PermissionManager.getInstance();
const KINDS = ["onepassword-connect"] as const;
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function validBaseUrl(raw: unknown): string | null {
if (!isNonEmptyString(raw)) return null;
try {
const url = new URL(raw.trim());
if (!["http:", "https:"].includes(url.protocol)) return null;
return url.toString().replace(/\/+$/, "");
} catch {
return null;
}
}
async function loadOwned(
id: string,
userId: string,
res: Response,
): Promise<SecretSourceRecord | null> {
const source = await createCurrentSecretSourceRepository().findById(id);
if (!source) {
res.status(404).json({ error: "Secret source not found" });
return null;
}
if (source.userId !== userId) {
res.status(403).json({ error: "Only the owner can change this source" });
return null;
}
return source;
}
/**
* @openapi
* /secret-sources:
* get:
* summary: List secret sources visible to the caller (own + shared)
* tags:
* - Secret Sources
*/
router.get(
"/",
authenticateJWT,
permissionManager.requirePermission("credentials.view"),
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId!;
try {
const rows =
await createCurrentSecretSourceRepository().listVisibleToUser(userId);
res.json({
sources: rows.map((row) => ({
...toPublicSecretSource(row),
owned: row.userId === userId,
})),
});
} catch (error) {
authLogger.error("Failed to list secret sources", error);
res.status(500).json({ error: "Failed to list secret sources" });
}
},
);
/**
* @openapi
* /secret-sources:
* post:
* summary: Create a secret source (1Password Connect)
* description: Sharing a source with every user requires admin. The token is encrypted with the owner's data key.
* tags:
* - Secret Sources
*/
router.post(
"/",
authenticateJWT,
permissionManager.requirePermission("credentials.create"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId!;
const {
name,
kind = "onepassword-connect",
baseUrl,
token,
shared,
} = req.body ?? {};
const url = validBaseUrl(baseUrl);
if (!isNonEmptyString(name) || !url || !isNonEmptyString(token)) {
return res.status(400).json({
error: "name, a valid http(s) baseUrl and token are required",
});
}
if (!KINDS.includes(kind)) {
return res.status(400).json({ error: "Unsupported secret source kind" });
}
if (shared === true && !(await permissionManager.isAdmin(userId))) {
return res
.status(403)
.json({ error: "Only admins can share a secret source" });
}
try {
const created = await createCurrentSecretSourceRepository().create({
id: crypto.randomUUID(),
userId,
name: name.trim(),
kind,
baseUrl: url,
token: token.trim(),
shared: shared === true,
});
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getAuditUsername(userId),
action: "secret_source_create",
resourceType: "secret_source",
resourceId: created.id,
resourceName: created.name,
ipAddress,
userAgent,
success: true,
});
res.json({ source: { ...toPublicSecretSource(created), owned: true } });
} catch (error) {
authLogger.error("Failed to create secret source", error);
res.status(500).json({ error: "Failed to create secret source" });
}
},
);
/**
* @openapi
* /secret-sources/{id}:
* put:
* summary: Update a secret source (owner only; omit token to keep it)
* tags:
* - Secret Sources
*/
router.put(
"/:id",
authenticateJWT,
permissionManager.requirePermission("credentials.edit"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId!;
const { name, baseUrl, token, shared } = req.body ?? {};
try {
const source = await loadOwned(String(req.params.id), userId, res);
if (!source) return;
const url = baseUrl === undefined ? undefined : validBaseUrl(baseUrl);
if (baseUrl !== undefined && !url) {
return res.status(400).json({ error: "baseUrl must be http(s)" });
}
if (
shared === true &&
!source.shared &&
!(await permissionManager.isAdmin(userId))
) {
return res
.status(403)
.json({ error: "Only admins can share a secret source" });
}
await createCurrentSecretSourceRepository().update(source, {
...(isNonEmptyString(name) ? { name: name.trim() } : {}),
...(url ? { baseUrl: url } : {}),
...(isNonEmptyString(token) ? { token: token.trim() } : {}),
...(typeof shared === "boolean" ? { shared } : {}),
});
clearExternalSecretCache();
res.json({ success: true });
} catch (error) {
authLogger.error("Failed to update secret source", error);
res.status(500).json({ error: "Failed to update secret source" });
}
},
);
/**
* @openapi
* /secret-sources/{id}:
* delete:
* summary: Delete a secret source (owner only)
* tags:
* - Secret Sources
*/
router.delete(
"/:id",
authenticateJWT,
permissionManager.requirePermission("credentials.delete"),
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId!;
try {
const source = await loadOwned(String(req.params.id), userId, res);
if (!source) return;
await createCurrentSecretSourceRepository().deleteById(source.id);
clearExternalSecretCache();
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getAuditUsername(userId),
action: "secret_source_delete",
resourceType: "secret_source",
resourceId: source.id,
resourceName: source.name,
ipAddress,
userAgent,
success: true,
});
res.json({ success: true });
} catch (error) {
authLogger.error("Failed to delete secret source", error);
res.status(500).json({ error: "Failed to delete secret source" });
}
},
);
/**
* @openapi
* /secret-sources/{id}/test:
* post:
* summary: Check that the source is reachable and the token is accepted
* tags:
* - Secret Sources
*/
router.post(
"/:id/test",
authenticateJWT,
permissionManager.requirePermission("credentials.view"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId!;
try {
const repository = createCurrentSecretSourceRepository();
const source = await repository.findById(String(req.params.id));
if (!source || (source.userId !== userId && !source.shared)) {
return res.status(404).json({ error: "Secret source not found" });
}
const vaults = await testConnectSource({
baseUrl: source.baseUrl,
token: repository.decryptToken(source),
allowedPrivateHosts: await readSecretSourcePrivateAllowlist(),
});
res.json({ ok: true, vaults });
} catch (error) {
res.status(200).json({ ok: false, error: getErrorMessage(error) });
}
},
);
export default router;
@@ -14,7 +14,6 @@ import {
} from "../../utils/audit-forwarder.js"; } from "../../utils/audit-forwarder.js";
import { getTelemetryEnvOverride } from "../../utils/analytics.js"; import { getTelemetryEnvOverride } from "../../utils/analytics.js";
import { STEP_CA_PRIVATE_ALLOWLIST_KEY } from "../../utils/step-ca-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 { import {
NOTIFICATION_PRIVATE_ALLOWLIST_KEY, NOTIFICATION_PRIVATE_ALLOWLIST_KEY,
parseNotificationAllowlist, parseNotificationAllowlist,
@@ -582,25 +581,6 @@ export function registerUserSettingsRoutes(
"Step CA endpoint", "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 * @openapi
* /users/step-ca-settings: * /users/step-ca-settings:
@@ -0,0 +1,104 @@
/**
* Which plugin resolves a "<scheme>://..." secret reference in a host's
* secret fields ("op://vault/item/field" for 1Password Connect, and so on).
*
* Core registers nothing here: every scheme comes from a plugin through
* ctx.credentials.registerSecretResolver. A reference whose scheme has no
* resolver fails the connect with a message naming the plugin that would
* provide it, the same shape as SshAuthProviderMissingError.
*/
export interface SecretResolverOwner {
pluginId: string;
pluginName: string;
}
export type SecretResolveFn = (
userId: string,
reference: string,
) => Promise<string>;
interface SecretResolverRegistration extends SecretResolverOwner {
resolve: SecretResolveFn;
}
const resolvers = new Map<string, SecretResolverRegistration>();
/**
* Which plugin declares which secret scheme, from every manifest on disk
* including disabled ones. Set by the plugin runtime, mirroring
* setSshAuthTypeOwnerSource.
*/
let ownerSource: () => Array<
SecretResolverOwner & { scheme: string }
> = () => [];
export function setSecretResolverOwnerSource(
source: () => Array<SecretResolverOwner & { scheme: string }>,
): void {
ownerSource = source;
}
function findSchemeOwner(scheme: string): SecretResolverOwner | null {
const owner = ownerSource().find((entry) => entry.scheme === scheme);
return owner
? { pluginId: owner.pluginId, pluginName: owner.pluginName }
: null;
}
export class SecretResolverMissingError extends Error {
readonly code = "SECRET_RESOLVER_MISSING";
constructor(
readonly scheme: string,
readonly owner: SecretResolverOwner | null,
) {
super(
owner
? `This host uses a "${scheme}://" secret reference, which needs the ${owner.pluginName} plugin`
: `This host uses a "${scheme}://" secret reference, which no enabled plugin resolves`,
);
this.name = "SecretResolverMissingError";
}
}
export function registerSecretResolver(
scheme: string,
owner: SecretResolverOwner,
resolve: SecretResolveFn,
): () => void {
const existing = resolvers.get(scheme);
if (existing && existing.pluginId !== owner.pluginId) {
throw new Error(
`Secret scheme "${scheme}" is already resolved by ${existing.pluginId}`,
);
}
const registration = { ...owner, resolve };
resolvers.set(scheme, registration);
return () => {
if (resolvers.get(scheme) === registration) {
resolvers.delete(scheme);
}
};
}
export function getSecretResolver(
scheme: string,
): SecretResolverRegistration | undefined {
return resolvers.get(scheme);
}
export function requireSecretResolver(
scheme: string,
): SecretResolverRegistration {
const resolver = resolvers.get(scheme);
if (!resolver) {
throw new SecretResolverMissingError(scheme, findSchemeOwner(scheme));
}
return resolver;
}
/** Test seam. */
export function resetSecretResolverRegistryForTests(): void {
resolvers.clear();
ownerSource = () => [];
}
+30 -42
View File
@@ -1,15 +1,13 @@
import { createCurrentSecretSourceRepository } from "../database/repositories/factory.js";
import type { SecretSourceRecord } from "../database/repositories/secret-source-repository.js";
import { import {
isSecretReference, getSecretResolver,
parseSecretReference, requireSecretResolver,
resolveConnectReference, } from "./connect/secret-resolver-registry.js";
} 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 * Expands "<scheme>://..." references ("op://vault/item/field" for 1Password
* fields into the actual secrets, fetched from the user's secret source. * Connect, and so on) in a resolved host's secret fields into the actual
* secrets, resolved by whichever plugin registered that scheme through
* ctx.credentials.registerSecretResolver.
* *
* Runs once per host resolution, at the single point where every subsystem * Runs once per host resolution, at the single point where every subsystem
* gets its plaintext credentials - so terminal, SFTP, Docker, metrics and * gets its plaintext credentials - so terminal, SFTP, Docker, metrics and
@@ -30,6 +28,8 @@ export const SECRET_FIELDS = [
"telnetPassword", "telnetPassword",
] as const; ] as const;
const REFERENCE_PATTERN = /^([a-z][a-z0-9+.-]*):\/\//i;
const CACHE_TTL_MS = 60_000; const CACHE_TTL_MS = 60_000;
const cache = new Map<string, { value: string; expiresAt: number }>(); const cache = new Map<string, { value: string; expiresAt: number }>();
@@ -38,37 +38,27 @@ export function clearExternalSecretCache(): void {
cache.clear(); cache.clear();
} }
export function isSecretReference(value: unknown): value is string {
return typeof value === "string" && REFERENCE_PATTERN.test(value.trim());
}
function referenceScheme(reference: string): string {
const match = REFERENCE_PATTERN.exec(reference.trim());
return (match?.[1] ?? "").toLowerCase();
}
export type SecretResolver = ( export type SecretResolver = (
source: SecretSourceRecord, userId: string,
reference: string, reference: string,
) => Promise<string>; ) => Promise<string>;
async function defaultResolver( async function defaultResolver(
source: SecretSourceRecord, userId: string,
reference: string, reference: string,
): Promise<string> { ): Promise<string> {
const ref = parseSecretReference(reference); const scheme = referenceScheme(reference);
if (!ref) throw new Error(`Invalid secret reference: ${reference}`); const resolver = requireSecretResolver(scheme);
const repository = createCurrentSecretSourceRepository(); return resolver.resolve(userId, reference);
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( export async function resolveSecretReference(
@@ -76,22 +66,15 @@ export async function resolveSecretReference(
reference: string, reference: string,
deps: { deps: {
resolver?: SecretResolver; resolver?: SecretResolver;
pickSource?: (userId: string) => Promise<SecretSourceRecord | null>;
now?: () => number; now?: () => number;
} = {}, } = {},
): Promise<string> { ): Promise<string> {
const now = deps.now ?? Date.now; const now = deps.now ?? Date.now;
const source = await (deps.pickSource ?? pickSecretSource)(userId); const cacheKey = `${userId}:${reference.trim()}`;
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); const cached = cache.get(cacheKey);
if (cached && cached.expiresAt > now()) return cached.value; if (cached && cached.expiresAt > now()) return cached.value;
const value = await (deps.resolver ?? defaultResolver)(source, reference); const value = await (deps.resolver ?? defaultResolver)(userId, reference);
cache.set(cacheKey, { value, expiresAt: now() + CACHE_TTL_MS }); cache.set(cacheKey, { value, expiresAt: now() + CACHE_TTL_MS });
return value; return value;
} }
@@ -108,3 +91,8 @@ export async function resolveExternalSecretRefs(
host[field] = await resolveSecretReference(userId, value, deps); host[field] = await resolveSecretReference(userId, value, deps);
} }
} }
/** Whether any plugin currently resolves this reference's scheme. */
export function hasSecretResolverFor(reference: string): boolean {
return !!getSecretResolver(referenceScheme(reference));
}
+43
View File
@@ -7,6 +7,12 @@
* The protocol credentials themselves stay in core, next to the host, because * The protocol credentials themselves stay in core, next to the host, because
* core's sharing model (per-recipient secret snapshots, personal overrides) * core's sharing model (per-recipient secret snapshots, personal overrides)
* is what decides which ones a shared recipient may use. * is what decides which ones a shared recipient may use.
*
* registerSecretResolver is the other half: a plugin that resolves
* "<scheme>://..." references in a host's secret fields (secret-sources and
* "op://") instead of handing over a host's own stored secret. Needs
* auth:provide, like ctx.auth's registrations, and the scheme must be listed
* in contributes.auth.secretSchemes.
*/ */
import type { import type {
@@ -14,9 +20,12 @@ import type {
PluginHostProtocol, PluginHostProtocol,
PluginProtocolTarget, PluginProtocolTarget,
} from "@termix/plugin-sdk/backend"; } from "@termix/plugin-sdk/backend";
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest"; import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import { assertCapability } from "./permissions.js"; import { assertCapability } from "./permissions.js";
import { getActor } from "./actor.js"; import { getActor } from "./actor.js";
import type { DisposableBag } from "./disposables.js";
import { registerSecretResolver } from "../hosts/connect/secret-resolver-registry.js";
type AuditFn = ( type AuditFn = (
action: string, action: string,
@@ -26,6 +35,7 @@ type AuditFn = (
interface Deps { interface Deps {
manifest: PluginManifest; manifest: PluginManifest;
bag: DisposableBag;
audit: AuditFn; audit: AuditFn;
} }
@@ -149,12 +159,45 @@ async function resolveRecipientAuth(
export function createPluginCredentials({ export function createPluginCredentials({
manifest, manifest,
bag,
audit, audit,
}: Deps): PluginCredentials { }: Deps): PluginCredentials {
const pluginId = manifest.id; const pluginId = manifest.id;
const declared = manifest.capabilities; const declared = manifest.capabilities;
return { return {
registerSecretResolver: (scheme, resolve) => {
if (!declared.includes("auth:provide")) {
throw new PluginCapabilityError(pluginId, "auth:provide");
}
if (!manifest.contributes?.auth?.secretSchemes?.includes(scheme)) {
throw new Error(
`Plugin ${pluginId} cannot register secret scheme "${scheme}": it is not listed in contributes.auth.secretSchemes`,
);
}
const dispose = registerSecretResolver(
scheme,
{ pluginId, pluginName: manifest.name },
async (userId, reference) => {
await assertCapability(pluginId, "auth:provide", declared);
const details = `secret reference for ${userId}`;
try {
const value = await resolve(userId, reference);
await audit("secret_resolve", details, { success: true });
return value;
} catch (error) {
await audit("secret_resolve", details, {
success: false,
errorMessage:
error instanceof Error ? error.message : String(error),
});
throw error;
}
},
);
bag.add(dispose, `secret scheme "${scheme}"`);
},
resolveHostProtocol: async (hostId, protocol) => { resolveHostProtocol: async (hostId, protocol) => {
const details = `${protocol} credentials for host ${hostId}`; const details = `${protocol} credentials for host ${hostId}`;
try { try {
+5 -1
View File
@@ -804,7 +804,11 @@ export function createPluginContext(
available: () => isElectronIpcAvailable(), available: () => isElectronIpcAvailable(),
}, },
credentials: createPluginCredentials({ manifest, audit: auditCall }), credentials: createPluginCredentials({
manifest,
bag: handle.bag,
audit: auditCall,
}),
audit: { audit: {
// Attribution comes from the runtime: the actor, never a plugin value. // Attribution comes from the runtime: the actor, never a plugin value.
+13
View File
@@ -15,6 +15,7 @@ import { PluginLoader, type LoadedPlugin } from "./loader.js";
import type { PluginPermissionContribution } from "./manifest.js"; import type { PluginPermissionContribution } from "./manifest.js";
import { invalidatePluginPermissionCache } from "./permissions.js"; import { invalidatePluginPermissionCache } from "./permissions.js";
import { setSshAuthTypeOwnerSource } from "../hosts/connect/auth-provider-registry.js"; import { setSshAuthTypeOwnerSource } from "../hosts/connect/auth-provider-registry.js";
import { setSecretResolverOwnerSource } from "../hosts/connect/secret-resolver-registry.js";
let loader: PluginLoader | null = null; let loader: PluginLoader | null = null;
@@ -37,6 +38,18 @@ export function getPluginRuntime(): { loader: PluginLoader } {
})), })),
), ),
); );
// Lets a secret reference whose scheme belongs to a disabled plugin name it.
setSecretResolverOwnerSource(() =>
(loader?.list() ?? []).flatMap((plugin) =>
(plugin.manifest.contributes?.auth?.secretSchemes ?? []).map(
(scheme) => ({
scheme,
pluginId: plugin.id,
pluginName: plugin.manifest.name,
}),
),
),
);
} }
return { loader }; return { loader };
} }
+4
View File
@@ -405,6 +405,10 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
const { runWakeOnLanSettingsMigration } = const { runWakeOnLanSettingsMigration } =
await import("./utils/crypto-migration/wake-on-lan-settings-migration.js"); await import("./utils/crypto-migration/wake-on-lan-settings-migration.js");
await runWakeOnLanSettingsMigration(); await runWakeOnLanSettingsMigration();
const { runSecretSourcesTokenMigration } =
await import("./utils/crypto-migration/secret-sources-token-migration.js");
await runSecretSourcesTokenMigration();
} catch (error) { } catch (error) {
systemLogger.warn("Plugin runtime failed to initialize", { systemLogger.warn("Plugin runtime failed to initialize", {
operation: "plugin_init", operation: "plugin_init",
@@ -1,46 +1,38 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentSecretSourceRepository: () => ({
listVisibleToUser: async () => [],
decryptToken: () => "",
}),
createCurrentSettingsRepository: () => ({ get: async () => null }),
}));
const { const {
clearExternalSecretCache, clearExternalSecretCache,
isSecretReference,
resolveExternalSecretRefs, resolveExternalSecretRefs,
resolveSecretReference, resolveSecretReference,
} = await import("../../hosts/external-secrets.js"); } = await import("../../hosts/external-secrets.js");
const { setSecretResolverOwnerSource, resetSecretResolverRegistryForTests } =
await import("../../hosts/connect/secret-resolver-registry.js");
const source = { describe("isSecretReference", () => {
id: "src-1", it("recognises a scheme:// reference without confusing it with a secret", () => {
userId: "alice", expect(isSecretReference("op://v/i/f")).toBe(true);
name: "1P", expect(isSecretReference(" op://v/i/f")).toBe(true);
kind: "onepassword-connect", expect(isSecretReference("vault+https://x")).toBe(true);
baseUrl: "https://connect.internal", expect(isSecretReference("hunter2")).toBe(false);
token: "enc", expect(isSecretReference(undefined)).toBe(false);
shared: false, });
createdAt: "", });
updatedAt: "",
};
describe("external secret references", () => { describe("external secret references", () => {
beforeEach(() => clearExternalSecretCache()); beforeEach(() => clearExternalSecretCache());
it("replaces references in the host's secret fields and leaves plain secrets alone", async () => { it("replaces references in the host's secret fields and leaves plain secrets alone", async () => {
const resolver = vi.fn(async (_s, ref: string) => `resolved:${ref}`); const resolver = vi.fn(
async (_userId: string, ref: string) => `resolved:${ref}`,
);
const host: Record<string, unknown> = { const host: Record<string, unknown> = {
password: "op://Infra/box/password", password: "op://Infra/box/password",
key: "-----BEGIN OPENSSH PRIVATE KEY-----", key: "-----BEGIN OPENSSH PRIVATE KEY-----",
sudoPassword: "op://Infra/box/sudo", sudoPassword: "op://Infra/box/sudo",
username: "op://not-a-secret-field", username: "op://not-a-secret-field",
}; };
await resolveExternalSecretRefs(host, "alice", { await resolveExternalSecretRefs(host, "alice", { resolver });
resolver,
pickSource: async () => source,
});
expect(host.password).toBe("resolved:op://Infra/box/password"); expect(host.password).toBe("resolved:op://Infra/box/password");
expect(host.sudoPassword).toBe("resolved:op://Infra/box/sudo"); expect(host.sudoPassword).toBe("resolved:op://Infra/box/sudo");
expect(host.key).toBe("-----BEGIN OPENSSH PRIVATE KEY-----"); expect(host.key).toBe("-----BEGIN OPENSSH PRIVATE KEY-----");
@@ -48,10 +40,10 @@ describe("external secret references", () => {
expect(resolver).toHaveBeenCalledTimes(2); expect(resolver).toHaveBeenCalledTimes(2);
}); });
it("caches a resolved reference per source for a minute", async () => { it("caches a resolved reference per user for a minute", async () => {
const resolver = vi.fn(async () => "s3cret"); const resolver = vi.fn(async () => "s3cret");
let clock = 1_000_000; let clock = 1_000_000;
const deps = { resolver, pickSource: async () => source, now: () => clock }; const deps = { resolver, now: () => clock };
await resolveSecretReference("alice", "op://v/i/f", deps); await resolveSecretReference("alice", "op://v/i/f", deps);
await resolveSecretReference("alice", "op://v/i/f", deps); await resolveSecretReference("alice", "op://v/i/f", deps);
expect(resolver).toHaveBeenCalledTimes(1); expect(resolver).toHaveBeenCalledTimes(1);
@@ -60,12 +52,26 @@ describe("external secret references", () => {
expect(resolver).toHaveBeenCalledTimes(2); expect(resolver).toHaveBeenCalledTimes(2);
}); });
it("fails clearly when the user has no secret source", async () => { it("fails clearly when no plugin resolves the scheme", async () => {
await expect( await expect(
resolveSecretReference("bob", "op://v/i/f", { resolveSecretReference("bob", "op://v/i/f", {}),
resolver: async () => "x", ).rejects.toThrow(/needs the .* plugin|no enabled plugin resolves/);
pickSource: async () => null, });
}),
).rejects.toThrow(/no secret source/); it("names the plugin that would resolve a disabled scheme", async () => {
setSecretResolverOwnerSource(() => [
{
scheme: "op",
pluginId: "secret-sources",
pluginName: "Secret Sources",
},
]);
try {
await expect(
resolveSecretReference("bob", "op://v/i/f", {}),
).rejects.toThrow("needs the Secret Sources plugin");
} finally {
resetSecretResolverRegistryForTests();
}
}); });
}); });
@@ -60,9 +60,17 @@ vi.mock("../../utils/shared-host-auth-resolver.js", () => ({
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js"; import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js"; import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import { runAsActor } from "../../plugins/actor.js"; import { runAsActor } from "../../plugins/actor.js";
import {
getSecretResolver,
requireSecretResolver,
resetSecretResolverRegistryForTests,
} from "../../hosts/connect/secret-resolver-registry.js";
import type { PluginManifest } from "@termix/plugin-sdk/manifest"; import type { PluginManifest } from "@termix/plugin-sdk/manifest";
function contextFor(capabilities: string[]) { function contextFor(
capabilities: string[],
contributes?: PluginManifest["contributes"],
) {
const manifest = { const manifest = {
id: "demo", id: "demo",
name: "demo", name: "demo",
@@ -73,6 +81,7 @@ function contextFor(capabilities: string[]) {
category: "Productivity", category: "Productivity",
engine: { termix: ">=2.9.0", api: "1" }, engine: { termix: ">=2.9.0", api: "1" },
capabilities, capabilities,
contributes,
} as PluginManifest; } as PluginManifest;
const handle = createPluginHandle("demo", { activate: () => {} }); const handle = createPluginHandle("demo", { activate: () => {} });
return createPluginContext(manifest, handle); return createPluginContext(manifest, handle);
@@ -104,6 +113,7 @@ beforeEach(() => {
state.resolution = null; state.resolution = null;
state.resolverCalls.length = 0; state.resolverCalls.length = 0;
state.hosts.set(7, { ...baseHost }); state.hosts.set(7, { ...baseHost });
resetSecretResolverRegistryForTests();
}); });
describe("ctx.credentials.resolveHostProtocol", () => { describe("ctx.credentials.resolveHostProtocol", () => {
@@ -226,3 +236,72 @@ describe("ctx.credentials.resolveHostProtocol", () => {
expect(auditEntries.at(-1)).toMatchObject({ success: false }); expect(auditEntries.at(-1)).toMatchObject({ success: false });
}); });
}); });
describe("ctx.credentials.registerSecretResolver", () => {
it("refuses without auth:provide", () => {
grants.set("demo", []);
const ctx = contextFor([], { auth: { secretSchemes: ["op"] } });
expect(() =>
ctx.credentials.registerSecretResolver("op", async () => ""),
).toThrow(/auth:provide/);
});
it("refuses a scheme not listed in contributes.auth.secretSchemes", () => {
grants.set("demo", ["auth:provide"]);
const ctx = contextFor(["auth:provide"], { auth: {} });
expect(() =>
ctx.credentials.registerSecretResolver("op", async () => ""),
).toThrow(/secretSchemes/);
});
it("resolves through the registered scheme and audits the call", async () => {
grants.set("demo", ["auth:provide"]);
const ctx = contextFor(["auth:provide"], {
auth: { secretSchemes: ["op"] },
});
ctx.credentials.registerSecretResolver("op", async (userId, ref) => {
state.resolverCalls.push({ userId, ref });
return `resolved:${ref}`;
});
const registration = requireSecretResolver("op");
expect(registration.pluginId).toBe("demo");
const value = await registration.resolve("alice", "op://v/i/f");
expect(value).toBe("resolved:op://v/i/f");
expect(state.resolverCalls[0]).toMatchObject({
userId: "alice",
ref: "op://v/i/f",
});
expect(auditEntries.at(-1)).toMatchObject({
action: "plugin_secret_resolve",
success: true,
});
});
it("is removed when the plugin is deactivated", async () => {
grants.set("demo", ["auth:provide"]);
const manifest = {
id: "demo",
name: "demo",
version: "1.0.0",
description: "",
author: { name: "test" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: "1" },
capabilities: ["auth:provide"],
contributes: { auth: { secretSchemes: ["op"] } },
} as PluginManifest;
const handle = createPluginHandle("demo", { activate: () => {} });
const ctx = createPluginContext(manifest, handle);
ctx.credentials.registerSecretResolver("op", async () => "x");
await handle.bag.disposeAll();
expect(getSecretResolver("op")).toBeUndefined();
});
});
@@ -1,29 +0,0 @@
import { describe, expect, it } from "vitest";
import {
isSecretReference,
parseSecretReference,
} from "../../utils/onepassword-connect.js";
describe("1Password secret references", () => {
it("parses op://vault/item/field, ignoring a query suffix", () => {
expect(parseSecretReference("op://Infra/prod-db/password")).toEqual({
vault: "Infra",
item: "prod-db",
field: "password",
});
expect(
parseSecretReference(
"op://Infra/deploy key/private key?ssh-format=openssh",
),
).toEqual({ vault: "Infra", item: "deploy key", field: "private key" });
expect(parseSecretReference("op://Infra/only-two")).toBeNull();
expect(parseSecretReference("https://x")).toBeNull();
});
it("recognises references without confusing them with secrets", () => {
expect(isSecretReference("op://v/i/f")).toBe(true);
expect(isSecretReference(" op://v/i/f")).toBe(true);
expect(isSecretReference("hunter2")).toBe(false);
expect(isSecretReference(undefined)).toBe(false);
});
});
@@ -0,0 +1,206 @@
/**
* The secret source token migration.
*
* An upgrade must be lossless: every source's access token has to reach the
* secret-sources plugin's ctx.secrets, encrypted, and be gone from the
* table. Running it twice must not duplicate or clobber anything.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface Row {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
encrypted: boolean;
}
const state = vi.hoisted(() => ({
pluginRows: [] as Row[],
sources: new Map<string, Array<Record<string, unknown>>>(),
pluginInstalled: true,
deks: new Set<string>(),
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "secret-sources" ? { id } : null,
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
_pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
state.pluginRows.find(
(row) =>
row.scope === scope && row.scopeId === scopeId && row.key === key,
) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
encrypted = false,
) => {
state.pluginRows.push({
pluginId,
scope,
scopeId,
key,
value,
encrypted,
});
},
}),
}));
/** The SQL text of a drizzle sql`` query, identifiers included. */
function text(query: { queryChunks: unknown[] }): string {
return query.queryChunks
.map((chunk) => {
if (typeof chunk === "string") return chunk;
const value = (chunk as { value?: unknown; name?: unknown }) ?? {};
if (Array.isArray(value.value)) return value.value.join("");
if (typeof value.value === "string") return value.value;
if (typeof value.name === "string") return value.name;
return String(chunk);
})
.join("");
}
vi.mock("../../utils/crypto-migration/raw-rows.js", () => ({
selectRows: async (query: { queryChunks: unknown[] }) => {
const sqlText = text(query);
for (const [table, rows] of state.sources) {
if (new RegExp(`FROM ${table} `).test(sqlText)) {
return rows.filter((row) => row.token);
}
}
throw new Error(`no such table in: ${sqlText}`);
},
runStatement: async (query: { queryChunks: unknown[] }) => {
const sqlText = text(query);
for (const [table, rows] of state.sources) {
if (!sqlText.includes(`UPDATE ${table} `)) continue;
const id = query.queryChunks.find((chunk) => typeof chunk === "string");
for (const row of rows) if (row.id === id) row.token = null;
}
},
}));
vi.mock("../../utils/data-crypto.js", () => ({
DataCrypto: {
getUserDataKey: (userId: string) =>
state.deks.has(userId) ? Buffer.from(userId) : null,
},
}));
vi.mock("../../utils/lazy-field-encryption.js", () => ({
LazyFieldEncryption: {
// Stands in for FieldCrypto: "enc:<plain>" decrypts, anything else is a
// legacy plaintext value.
safeGetFieldValue: (value: string) => value.replace(/^enc:/, ""),
},
}));
vi.mock("../../utils/system-secret-crypto.js", () => ({
encryptSystemSecret: async (value: string) => `sysenc:${value}`,
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import { runSecretSourcesTokenMigration } from "../../utils/crypto-migration/secret-sources-token-migration.js";
const value = (scopeId: string | null, key: string) => {
const row = state.pluginRows.find(
(entry) =>
entry.scope === "secret" &&
entry.scopeId === scopeId &&
entry.key === key,
);
return row ? JSON.parse(row.value ?? "null") : undefined;
};
beforeEach(() => {
state.pluginRows = [];
state.sources = new Map([
[
"secret_sources",
[
{ id: "src-1", user_id: "u1", token: "enc:tok-one" },
{ id: "src-2", user_id: "u2", token: "tok-plain" },
],
],
]);
state.pluginInstalled = true;
state.deks = new Set(["u1", "u2"]);
});
describe("runSecretSourcesTokenMigration", () => {
it("moves each source's token into its owner's secrets, encrypted, and clears the column", async () => {
const result = await runSecretSourcesTokenMigration();
expect(result).toEqual({ moved: 2 });
const secret = state.pluginRows.find(
(row) => row.scope === "secret" && row.key === "source:src-1",
);
expect(secret).toMatchObject({
scopeId: "u1",
encrypted: true,
value: JSON.stringify("sysenc:tok-one"),
});
expect(value("u2", "source:src-2")).toBe("sysenc:tok-plain");
expect(
state.sources.get("secret_sources")!.every((row) => row.token === null),
).toBe(true);
});
it("leaves a token alone while its owner's data key is unavailable", async () => {
state.deks = new Set(["u1"]);
const result = await runSecretSourcesTokenMigration();
expect(result).toEqual({ moved: 1 });
expect(value("u2", "source:src-2")).toBeUndefined();
const row = state.sources
.get("secret_sources")!
.find((r) => r.id === "src-2");
expect(row?.token).toBe("tok-plain");
});
it("reads the adopted table once the plugin has renamed it", async () => {
state.sources = new Map([
[
"p_secret_sources_sources",
[{ id: "src-9", user_id: "u1", token: "enc:tok-late" }],
],
]);
await runSecretSourcesTokenMigration();
expect(value("u1", "source:src-9")).toBe("sysenc:tok-late");
});
it("changes nothing on a second run", async () => {
await runSecretSourcesTokenMigration();
const first = JSON.stringify(state.pluginRows);
const second = await runSecretSourcesTokenMigration();
expect(second).toEqual({ moved: 0 });
expect(JSON.stringify(state.pluginRows)).toBe(first);
});
it("does nothing until the plugin row exists", async () => {
state.pluginInstalled = false;
const result = await runSecretSourcesTokenMigration();
expect(result).toEqual({ moved: 0 });
expect(state.pluginRows).toEqual([]);
});
});
@@ -0,0 +1,114 @@
/**
* Moves each secret source's access token out of secret_sources.token,
* encrypted under its owner's data key, into a ctx.secrets row "source:<id>"
* for that owner, encrypted under the installation key - the same move the
* ai plugin made for provider API keys. The column is then emptied so no
* token material is left in the adopted table.
*
* The old column stays in the database, unused, until this runs: core's
* drizzle migrations run before this does, so dropping it there would lose
* the token first. The table is secret_sources until the plugin adopts it
* and p_secret_sources_sources after, so both names are tried.
*
* Idempotent: a source whose ctx.secrets row already exists is skipped, and
* a token is only cleared once its copy exists. Safe on every boot.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
} from "../../database/repositories/factory.js";
import { DataCrypto } from "../data-crypto.js";
import { LazyFieldEncryption } from "../lazy-field-encryption.js";
import { encryptSystemSecret } from "../system-secret-crypto.js";
import { runStatement, selectRows } from "./raw-rows.js";
const PLUGIN_ID = "secret-sources";
export interface SecretSourcesTokenMigrationResult {
moved: number;
}
async function tryRows<T>(query: ReturnType<typeof sql>): Promise<T[]> {
try {
return await selectRows<T>(query);
} catch {
// A fresh install never had the table or column.
return [];
}
}
export async function runSecretSourcesTokenMigration(): Promise<SecretSourcesTokenMigrationResult> {
const result: SecretSourcesTokenMigrationResult = { moved: 0 };
// plugin_settings has a foreign key to plugins: nothing to migrate into
// until the plugin row exists.
try {
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return result;
} catch {
return result;
}
const pluginSettings = createCurrentPluginSettingsRepository();
for (const table of ["secret_sources", "p_secret_sources_sources"]) {
const rows = await tryRows<{
id: string;
user_id: string;
token: string | null;
}>(
sql`SELECT id, user_id, token FROM ${sql.identifier(table)} WHERE token IS NOT NULL AND token <> ''`,
);
for (const row of rows) {
try {
const key = `source:${row.id}`;
const existing = await pluginSettings.get(
PLUGIN_ID,
"secret",
row.user_id,
key,
);
if (!existing) {
const dek = DataCrypto.getUserDataKey(row.user_id);
if (!dek) continue;
const plaintext = LazyFieldEncryption.safeGetFieldValue(
row.token as string,
dek,
row.id,
"token",
);
if (!plaintext) continue;
await pluginSettings.set(
PLUGIN_ID,
"secret",
row.user_id,
key,
JSON.stringify(await encryptSystemSecret(plaintext)),
true,
);
result.moved++;
}
await runStatement(
sql`UPDATE ${sql.identifier(table)} SET token = NULL WHERE id = ${row.id}`,
);
} catch (error) {
databaseLogger.warn("Secret source token migration failed for a row", {
operation: "secret_sources_token_migration",
error: error instanceof Error ? error.message : String(error),
});
}
}
}
if (result.moved > 0) {
databaseLogger.info(
`Moved ${result.moved} secret source token(s) into the secret-sources plugin`,
{ operation: "secret_sources_token_migration" },
);
}
return result;
}
-13
View File
@@ -1,13 +0,0 @@
import { createCurrentSettingsRepository } from "../database/repositories/factory.js";
import { parseNotificationAllowlist } from "./notification-egress.js";
/** Private hosts (a self-hosted 1Password Connect server) secret sources may reach. */
export const SECRET_SOURCE_PRIVATE_ALLOWLIST_KEY =
"secret_source_private_endpoint_allowlist";
export async function readSecretSourcePrivateAllowlist(): Promise<string[]> {
const raw = await createCurrentSettingsRepository().get(
SECRET_SOURCE_PRIVATE_ALLOWLIST_KEY,
);
return parseNotificationAllowlist(raw);
}
-21
View File
@@ -33,27 +33,6 @@ 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( export async function setNotificationPrivateEndpoints(
hosts: string[], hosts: string[],
): Promise<string[]> { ): Promise<string[]> {
-69
View File
@@ -1,69 +0,0 @@
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");
}
}
-19
View File
@@ -611,22 +611,6 @@
"selectAVaultProfile": "Select a Vault profile...", "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.", "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", "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", "vaultManageProfiles": "Manage Vault profiles",
"vaultAddrLabel": "Vault Address", "vaultAddrLabel": "Vault Address",
"vaultNamespaceLabel": "Namespace", "vaultNamespaceLabel": "Namespace",
@@ -2103,9 +2087,6 @@
"stepCaProvisioner": "OIDC provisioner name", "stepCaProvisioner": "OIDC provisioner name",
"stepCaSaved": "Step CA settings saved", "stepCaSaved": "Step CA settings saved",
"stepCaSaveFailed": "Failed to save Step CA settings", "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", "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.", "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", "updateStepCaEndpointsFailed": "Failed to update the Step CA endpoint allowlist",
-26
View File
@@ -5,8 +5,6 @@ import {
getNotificationPrivateEndpoints, getNotificationPrivateEndpoints,
getStepCaPrivateEndpoints, getStepCaPrivateEndpoints,
setStepCaPrivateEndpoints as setStepCaPrivateEndpointsApi, setStepCaPrivateEndpoints as setStepCaPrivateEndpointsApi,
getSecretSourcePrivateEndpoints,
setSecretSourcePrivateEndpoints as setSecretSourcePrivateEndpointsApi,
setNotificationPrivateEndpoints as setNotificationPrivateEndpointsApi, setNotificationPrivateEndpoints as setNotificationPrivateEndpointsApi,
} from "@/api/private-endpoints-api"; } from "@/api/private-endpoints-api";
import { import {
@@ -142,8 +140,6 @@ export function AdminSettingsPanel({
const [stepCaPrivateEndpoints, setStepCaPrivateEndpoints] = useState< const [stepCaPrivateEndpoints, setStepCaPrivateEndpoints] = useState<
string[] string[]
>([]); >([]);
const [secretSourcePrivateEndpoints, setSecretSourcePrivateEndpoints] =
useState<string[]>([]);
const [stepCaSettings, setStepCaSettings] = useState({ const [stepCaSettings, setStepCaSettings] = useState({
caUrl: "", caUrl: "",
fingerprint: "", fingerprint: "",
@@ -351,7 +347,6 @@ export function AdminSettingsPanel({
analytics, analytics,
notificationEndpoints, notificationEndpoints,
stepCaEndpoints, stepCaEndpoints,
secretSourceEndpoints,
branding, branding,
] = await Promise.allSettled([ ] = await Promise.allSettled([
getRegistrationAllowed(), getRegistrationAllowed(),
@@ -366,7 +361,6 @@ export function AdminSettingsPanel({
getAnalyticsEnabled(), getAnalyticsEnabled(),
getNotificationPrivateEndpoints(), getNotificationPrivateEndpoints(),
getStepCaPrivateEndpoints(), getStepCaPrivateEndpoints(),
getSecretSourcePrivateEndpoints(),
getBranding(), getBranding(),
]); ]);
@@ -401,9 +395,6 @@ export function AdminSettingsPanel({
if (stepCaEndpoints.status === "fulfilled") { if (stepCaEndpoints.status === "fulfilled") {
setStepCaPrivateEndpoints(stepCaEndpoints.value); setStepCaPrivateEndpoints(stepCaEndpoints.value);
} }
if (secretSourceEndpoints.status === "fulfilled") {
setSecretSourcePrivateEndpoints(secretSourceEndpoints.value);
}
if (notificationEndpoints.status === "fulfilled") { if (notificationEndpoints.status === "fulfilled") {
setNotificationPrivateEndpoints(notificationEndpoints.value); setNotificationPrivateEndpoints(notificationEndpoints.value);
} }
@@ -541,19 +532,6 @@ 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[]) { async function handleSaveStepCaPrivateEndpoints(hosts: string[]) {
const previous = stepCaPrivateEndpoints; const previous = stepCaPrivateEndpoints;
setStepCaPrivateEndpoints(hosts); setStepCaPrivateEndpoints(hosts);
@@ -1033,10 +1011,6 @@ export function AdminSettingsPanel({
notificationPrivateEndpoints={notificationPrivateEndpoints} notificationPrivateEndpoints={notificationPrivateEndpoints}
stepCaPrivateEndpoints={stepCaPrivateEndpoints} stepCaPrivateEndpoints={stepCaPrivateEndpoints}
onSaveStepCaPrivateEndpoints={handleSaveStepCaPrivateEndpoints} onSaveStepCaPrivateEndpoints={handleSaveStepCaPrivateEndpoints}
secretSourcePrivateEndpoints={secretSourcePrivateEndpoints}
onSaveSecretSourcePrivateEndpoints={
handleSaveSecretSourcePrivateEndpoints
}
stepCaSettings={stepCaSettings} stepCaSettings={stepCaSettings}
setStepCaSettings={setStepCaSettings} setStepCaSettings={setStepCaSettings}
handleSaveStepCaSettings={handleSaveStepCaSettings} handleSaveStepCaSettings={handleSaveStepCaSettings}
-26
View File
@@ -31,8 +31,6 @@ type GeneralSettingsSectionProps = {
onSaveNotificationPrivateEndpoints: (hosts: string[]) => void; onSaveNotificationPrivateEndpoints: (hosts: string[]) => void;
stepCaPrivateEndpoints: string[]; stepCaPrivateEndpoints: string[];
onSaveStepCaPrivateEndpoints: (hosts: string[]) => void; onSaveStepCaPrivateEndpoints: (hosts: string[]) => void;
secretSourcePrivateEndpoints: string[];
onSaveSecretSourcePrivateEndpoints: (hosts: string[]) => void;
stepCaSettings: { caUrl: string; fingerprint: string; provisioner: string }; stepCaSettings: { caUrl: string; fingerprint: string; provisioner: string };
setStepCaSettings: Dispatch< setStepCaSettings: Dispatch<
SetStateAction<{ caUrl: string; fingerprint: string; provisioner: string }> SetStateAction<{ caUrl: string; fingerprint: string; provisioner: string }>
@@ -72,8 +70,6 @@ export function AdminGeneralSettingsSection({
onSaveNotificationPrivateEndpoints, onSaveNotificationPrivateEndpoints,
stepCaPrivateEndpoints, stepCaPrivateEndpoints,
onSaveStepCaPrivateEndpoints, onSaveStepCaPrivateEndpoints,
secretSourcePrivateEndpoints,
onSaveSecretSourcePrivateEndpoints,
stepCaSettings, stepCaSettings,
setStepCaSettings, setStepCaSettings,
handleSaveStepCaSettings, handleSaveStepCaSettings,
@@ -167,28 +163,6 @@ export function AdminGeneralSettingsSection({
/> />
</div> </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"> <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"> <span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("admin.stepCa")} {t("admin.stepCa")}
+5 -6
View File
@@ -1,8 +1,5 @@
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { import { PluginComponent } from "@/plugin-host/component-registry";
SecretReferenceHint,
SecretSourceManager,
} from "./SecretSourceManager";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { copyToClipboard } from "@/lib/clipboard"; import { copyToClipboard } from "@/lib/clipboard";
import { Copy, Info, Lock, Upload, X } from "lucide-react"; import { Copy, Info, Lock, Upload, X } from "lucide-react";
@@ -296,12 +293,14 @@ export function CredentialEditorView({
value={credForm.password} value={credForm.password}
onChange={(e) => setCredField("password", e.target.value)} onChange={(e) => setCredField("password", e.target.value)}
/> />
<SecretReferenceHint <PluginComponent
id="secret-sources.hint"
onManage={() => setShowSecretSources((v) => !v)} onManage={() => setShowSecretSources((v) => !v)}
/> />
</div> </div>
{showSecretSources && ( {showSecretSources && (
<SecretSourceManager <PluginComponent
id="secret-sources.manager"
onClose={() => setShowSecretSources(false)} onClose={() => setShowSecretSources(false)}
/> />
)} )}
+5 -6
View File
@@ -89,10 +89,7 @@ import { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
import { SshAuthProviderFields } from "./SshAuthProviderFields"; import { SshAuthProviderFields } from "./SshAuthProviderFields";
import type { PluginSettingsField } from "@termix/plugin-sdk/manifest"; import type { PluginSettingsField } from "@termix/plugin-sdk/manifest";
import { ensureLegacyAuthUI } from "@/auth/legacy-auth-ui"; import { ensureLegacyAuthUI } from "@/auth/legacy-auth-ui";
import { import { PluginComponent } from "@/plugin-host/component-registry";
SecretReferenceHint,
SecretSourceManager,
} from "./SecretSourceManager";
import { import {
toCredentialOption, toCredentialOption,
type CredentialOption, type CredentialOption,
@@ -698,14 +695,16 @@ export function HostEditor({
}} }}
onChange={(e) => setField("password", e.target.value)} onChange={(e) => setField("password", e.target.value)}
/> />
<SecretReferenceHint <PluginComponent
id="secret-sources.hint"
onManage={() => setShowSecretSources((v) => !v)} onManage={() => setShowSecretSources((v) => !v)}
/> />
</div> </div>
)} )}
{(authMethod === "password" || authMethod === "key") && {(authMethod === "password" || authMethod === "key") &&
showSecretSources && ( showSecretSources && (
<SecretSourceManager <PluginComponent
id="secret-sources.manager"
onClose={() => setShowSecretSources(false)} onClose={() => setShowSecretSources(false)}
/> />
)} )}