mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: convert secret-sources into a plugin (B20)
This commit is contained in:
1 parent
2ac7779eda
commit
f4f26a9b8b
72 files changed
+18843
-933
No files matched your search
@@ -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
@@ -351,6 +351,13 @@
|
||||
"when": 1790282598229,
|
||||
"tag": "0049_quiet_blacklash",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 50,
|
||||
"version": "5",
|
||||
"when": 1790284032088,
|
||||
"tag": "0050_optimal_speedball",
|
||||
"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
@@ -358,6 +358,13 @@
|
||||
"when": 1790282597585,
|
||||
"tag": "0050_acoustic_lady_bullseye",
|
||||
"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
@@ -330,6 +330,13 @@
|
||||
"when": 1790282596918,
|
||||
"tag": "0046_misty_midnight",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 47,
|
||||
"version": "6",
|
||||
"when": 1790284030834,
|
||||
"tag": "0047_futuristic_microbe",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+11
@@ -7016,6 +7016,10 @@
|
||||
"resolved": "plugins/remote-desktop",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@termix-plugin/secret-sources": {
|
||||
"resolved": "plugins/secret-sources",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@termix-plugin/serial": {
|
||||
"resolved": "plugins/serial",
|
||||
"link": true
|
||||
@@ -18623,6 +18627,13 @@
|
||||
"@termix/plugin-sdk": "*"
|
||||
}
|
||||
},
|
||||
"plugins/secret-sources": {
|
||||
"name": "@termix-plugin/secret-sources",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "*"
|
||||
}
|
||||
},
|
||||
"plugins/serial": {
|
||||
"name": "@termix-plugin/serial",
|
||||
"version": "1.0.0",
|
||||
|
||||
@@ -752,7 +752,7 @@ sessions shared with a user from the `sessions.sharedWithMe` action.
|
||||
(`snippets.access`, a hard dependency), fleets (`fleets.access`), tunnels
|
||||
(`tunnels.access`), Docker (`docker.containers`, `docker.events`), host metrics
|
||||
(`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.ssh.resolveHost`, and commands through `ctx.ssh.withConnection`. Every run
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
"loginMethods": ["corp-sso"],
|
||||
"secondFactors": ["pin"],
|
||||
"secretSchemes": ["op"], // "<scheme>://..." secret references this plugin resolves
|
||||
},
|
||||
"settings": {
|
||||
"admin": [
|
||||
@@ -1380,6 +1413,10 @@ accept the kinds `button` and `component`.
|
||||
`contributes.auth` lists the ids a plugin registers through `ctx.auth`. A
|
||||
register call for an id not listed here throws. Core also reads
|
||||
`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
|
||||
`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 |
|
||||
| `network:outbound` | medium | Make outbound requests |
|
||||
| `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 |
|
||||
| `events:core` | medium | Subscribe to and emit core events carrying other users' data |
|
||||
| `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.launchNativeRdp` / `.available` | `desktop:window` (launch only) | **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.schedule.every` / `.after` | none | **B16** |
|
||||
| `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
|
||||
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
|
||||
check (see [Host status](#host-status-is-core)):
|
||||
|
||||
|
||||
@@ -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
|
||||
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
|
||||
`events:core` for core topics, but the runtime only checks `emit`.
|
||||
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
|
||||
updating this frontend constant to `/plugin-api/tmux-monitor`. Not
|
||||
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
|
||||
|
||||
|
||||
@@ -292,6 +292,10 @@
|
||||
"secondFactors": {
|
||||
"type": "array",
|
||||
"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}$" }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1257,6 +1257,21 @@ export interface PluginCredentials {
|
||||
hostId: number,
|
||||
protocol: PluginHostProtocol,
|
||||
) => 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -257,6 +257,8 @@ export interface PluginAuthContribution {
|
||||
loginMethods?: string[];
|
||||
/** Second factor ids. */
|
||||
secondFactors?: string[];
|
||||
/** Schemes this plugin resolves for "<scheme>://..." secret references, e.g. "op". */
|
||||
secretSchemes?: string[];
|
||||
}
|
||||
|
||||
export interface PluginContributions {
|
||||
@@ -790,11 +792,16 @@ function validateAuthContribution(value: unknown, errors: string[]): void {
|
||||
}
|
||||
rejectUnknown(
|
||||
value,
|
||||
["sshAuthTypes", "loginMethods", "secondFactors"],
|
||||
["sshAuthTypes", "loginMethods", "secondFactors", "secretSchemes"],
|
||||
"contributes.auth",
|
||||
errors,
|
||||
);
|
||||
for (const key of ["sshAuthTypes", "loginMethods", "secondFactors"]) {
|
||||
for (const key of [
|
||||
"sshAuthTypes",
|
||||
"loginMethods",
|
||||
"secondFactors",
|
||||
"secretSchemes",
|
||||
]) {
|
||||
const list = value[key];
|
||||
if (list === undefined) continue;
|
||||
if (!Array.isArray(list)) {
|
||||
@@ -1343,7 +1350,8 @@ export function parseManifest(raw: unknown): ParsedManifest {
|
||||
const contributesAuth =
|
||||
(auth?.sshAuthTypes?.length ?? 0) +
|
||||
(auth?.loginMethods?.length ?? 0) +
|
||||
(auth?.secondFactors?.length ?? 0) >
|
||||
(auth?.secondFactors?.length ?? 0) +
|
||||
(auth?.secretSchemes?.length ?? 0) >
|
||||
0;
|
||||
if (contributesAuth && !manifest.capabilities.includes("auth:provide")) {
|
||||
errors.push(
|
||||
|
||||
@@ -126,6 +126,11 @@ export interface FakeAuthRegistrations {
|
||||
secondFactors: PluginSecondFactor[];
|
||||
/** "<userId>:<factorId>" for every recorded enrolment. */
|
||||
enrollments: Set<string>;
|
||||
/** Secret schemes registered through ctx.credentials.registerSecretResolver. */
|
||||
secretResolvers: Map<
|
||||
string,
|
||||
(userId: string, reference: string) => Promise<string>
|
||||
>;
|
||||
}
|
||||
|
||||
export interface FakePluginContext {
|
||||
@@ -336,6 +341,7 @@ export function createFakeContext(
|
||||
loginMethods: [],
|
||||
secondFactors: [],
|
||||
enrollments: new Set(),
|
||||
secretResolvers: new Map(),
|
||||
};
|
||||
const sshClient = options.sshClient ?? {};
|
||||
let actor = options.actor;
|
||||
@@ -800,6 +806,12 @@ export function createFakeContext(
|
||||
credentialReads.push({ hostId, protocol });
|
||||
return options.protocolTargets?.[`${hostId}:${protocol}`] ?? null;
|
||||
},
|
||||
registerSecretResolver: (scheme, resolve) => {
|
||||
auth.secretResolvers.set(scheme, resolve);
|
||||
disposals.push(() => {
|
||||
auth.secretResolvers.delete(scheme);
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
notify: {
|
||||
@@ -1223,6 +1235,10 @@ export function createMockCtx(
|
||||
require("credentials:read");
|
||||
return ctx.credentials.resolveHostProtocol(hostId, protocol);
|
||||
},
|
||||
registerSecretResolver: (scheme, resolve) => {
|
||||
require("auth:provide");
|
||||
ctx.credentials.registerSecretResolver(scheme, resolve);
|
||||
},
|
||||
},
|
||||
|
||||
notify: {
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
}
|
||||
+19
-17
@@ -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
|
||||
@@ -22,27 +22,24 @@ export function parseSecretReference(raw: string): SecretReference | null {
|
||||
return { vault, item, field };
|
||||
}
|
||||
|
||||
export function isSecretReference(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trimStart().startsWith("op://");
|
||||
}
|
||||
|
||||
export interface ConnectSource {
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
allowedPrivateHosts: readonly string[];
|
||||
}
|
||||
|
||||
async function connectGet<T>(source: ConnectSource, path: string): Promise<T> {
|
||||
async function connectGet<T>(
|
||||
fetch: PluginFetch,
|
||||
source: ConnectSource,
|
||||
path: string,
|
||||
): Promise<T> {
|
||||
const base = source.baseUrl.replace(/\/+$/, "");
|
||||
const response = await safeOutboundFetch(
|
||||
`${base}${path}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${source.token}` },
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
},
|
||||
source.allowedPrivateHosts,
|
||||
);
|
||||
const response = await fetch(`${base}${path}`, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${source.token}` },
|
||||
timeoutMs: FETCH_TIMEOUT_MS,
|
||||
allowPrivateHosts: source.allowedPrivateHosts,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`1Password Connect ${path} failed: HTTP ${response.status}`,
|
||||
@@ -73,9 +70,10 @@ interface ConnectField {
|
||||
|
||||
/** Reachability + token validity: the vault list needs a valid token. */
|
||||
export async function testConnectSource(
|
||||
fetch: PluginFetch,
|
||||
source: ConnectSource,
|
||||
): Promise<number> {
|
||||
const vaults = await connectGet<ConnectVault[]>(source, "/v1/vaults");
|
||||
const vaults = await connectGet<ConnectVault[]>(fetch, source, "/v1/vaults");
|
||||
return vaults.length;
|
||||
}
|
||||
|
||||
@@ -87,21 +85,24 @@ const byIdOrName =
|
||||
name.toLowerCase();
|
||||
|
||||
export async function resolveConnectReference(
|
||||
fetch: PluginFetch,
|
||||
source: ConnectSource,
|
||||
ref: SecretReference,
|
||||
): Promise<string> {
|
||||
const vaults = await connectGet<ConnectVault[]>(
|
||||
fetch,
|
||||
source,
|
||||
`/v1/vaults?filter=${eqFilter(ref.vault)}`,
|
||||
);
|
||||
const vault =
|
||||
vaults.find(byIdOrName(ref.vault)) ??
|
||||
(await connectGet<ConnectVault[]>(source, "/v1/vaults")).find(
|
||||
(await connectGet<ConnectVault[]>(fetch, source, "/v1/vaults")).find(
|
||||
byIdOrName(ref.vault),
|
||||
);
|
||||
if (!vault) throw new Error(`1Password vault "${ref.vault}" not found`);
|
||||
|
||||
const items = await connectGet<ConnectItemSummary[]>(
|
||||
fetch,
|
||||
source,
|
||||
`/v1/vaults/${vault.id}/items?filter=${eqFilter(ref.item)}`,
|
||||
);
|
||||
@@ -113,6 +114,7 @@ export async function resolveConnectReference(
|
||||
}
|
||||
|
||||
const item = await connectGet<{ fields?: ConnectField[] }>(
|
||||
fetch,
|
||||
source,
|
||||
`/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>
|
||||
);
|
||||
}
|
||||
+38
-59
@@ -1,35 +1,18 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { KeyRound, Pencil, Plus, Trash2, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { PasswordInput } from "@/components/password-input";
|
||||
import { getErrorMessage } from "@/lib/error-message";
|
||||
import { Button, Input, PasswordInput } from "@termix/plugin-sdk/ui";
|
||||
import { usePluginApi, useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
createSecretSource,
|
||||
deleteSecretSource,
|
||||
listSecretSources,
|
||||
testSecretSource,
|
||||
updateSecretSource,
|
||||
createSecretSourcesApi,
|
||||
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. */
|
||||
export function SecretReferenceHint({ onManage }: { onManage: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.secretRefHint")}{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="text-accent-brand hover:underline"
|
||||
onClick={onManage}
|
||||
>
|
||||
{t("hosts.secretSourcesManage")}
|
||||
</button>
|
||||
</p>
|
||||
);
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error && typeof error === "object" && "message" in error) {
|
||||
return String((error as { message?: unknown }).message ?? "");
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
type FormState = {
|
||||
@@ -49,6 +32,8 @@ const emptyForm: FormState = {
|
||||
|
||||
export function SecretSourceManager({ onClose }: { onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const pluginApi = usePluginApi();
|
||||
const api = useMemo(() => createSecretSourcesApi(pluginApi), [pluginApi]);
|
||||
const [sources, setSources] = useState<SecretSource[]>([]);
|
||||
const [form, setForm] = useState<FormState | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -56,11 +41,11 @@ export function SecretSourceManager({ onClose }: { onClose: () => void }) {
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try {
|
||||
setSources(await listSecretSources());
|
||||
setSources(await api.list());
|
||||
} catch (e) {
|
||||
toast.error(getErrorMessage(e));
|
||||
toast.error(errorMessage(e));
|
||||
}
|
||||
}, []);
|
||||
}, [api]);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
@@ -76,31 +61,31 @@ export function SecretSourceManager({ onClose }: { onClose: () => void }) {
|
||||
!form.baseUrl.trim() ||
|
||||
(!form.id && !form.token)
|
||||
) {
|
||||
toast.error(t("hosts.secretSourceRequired"));
|
||||
toast.error(t("required"));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (form.id) {
|
||||
await updateSecretSource(form.id, {
|
||||
await api.update(form.id, {
|
||||
name: form.name,
|
||||
baseUrl: form.baseUrl,
|
||||
shared: form.shared,
|
||||
...(form.token ? { token: form.token } : {}),
|
||||
});
|
||||
} else {
|
||||
await createSecretSource({
|
||||
await api.create({
|
||||
name: form.name,
|
||||
baseUrl: form.baseUrl,
|
||||
token: form.token,
|
||||
shared: form.shared,
|
||||
});
|
||||
}
|
||||
toast.success(t("hosts.secretSourceSaved"));
|
||||
toast.success(t("saved"));
|
||||
setForm(null);
|
||||
await reload();
|
||||
} catch (e) {
|
||||
toast.error(getErrorMessage(e));
|
||||
toast.error(errorMessage(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -108,27 +93,25 @@ export function SecretSourceManager({ onClose }: { onClose: () => void }) {
|
||||
|
||||
const handleDelete = async (source: SecretSource) => {
|
||||
try {
|
||||
await deleteSecretSource(source.id);
|
||||
toast.success(t("hosts.secretSourceDeleted"));
|
||||
await api.remove(source.id);
|
||||
toast.success(t("deleted"));
|
||||
await reload();
|
||||
} catch (e) {
|
||||
toast.error(getErrorMessage(e));
|
||||
toast.error(errorMessage(e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async (source: SecretSource) => {
|
||||
setTesting(source.id);
|
||||
try {
|
||||
const result = await testSecretSource(source.id);
|
||||
const result = await api.test(source.id);
|
||||
if (result.ok) {
|
||||
toast.success(
|
||||
t("hosts.secretSourceTestOk", { count: result.vaults ?? 0 }),
|
||||
);
|
||||
toast.success(t("testOk", { count: result.vaults ?? 0 }));
|
||||
} else {
|
||||
toast.error(result.error ?? t("hosts.secretSourceTestFailed"));
|
||||
toast.error(result.error ?? t("testFailed"));
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(getErrorMessage(e));
|
||||
toast.error(errorMessage(e));
|
||||
} finally {
|
||||
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 items-center justify-between">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.secretSourcesTitle")}
|
||||
{t("title")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -148,9 +131,7 @@ export function SecretSourceManager({ onClose }: { onClose: () => void }) {
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.secretSourcesDesc")}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">{t("description")}</p>
|
||||
|
||||
{!form && (
|
||||
<>
|
||||
@@ -165,7 +146,7 @@ export function SecretSourceManager({ onClose }: { onClose: () => void }) {
|
||||
{source.name}
|
||||
{source.shared && (
|
||||
<span className="ml-1 text-[9px] uppercase text-muted-foreground">
|
||||
{t("hosts.secretSourceShared")}
|
||||
{t("shared")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -181,7 +162,7 @@ export function SecretSourceManager({ onClose }: { onClose: () => void }) {
|
||||
disabled={testing === source.id}
|
||||
onClick={() => void handleTest(source)}
|
||||
>
|
||||
{t("hosts.secretSourceTest")}
|
||||
{t("test")}
|
||||
</Button>
|
||||
{source.owned && (
|
||||
<>
|
||||
@@ -218,7 +199,7 @@ export function SecretSourceManager({ onClose }: { onClose: () => void }) {
|
||||
className="self-start border-accent-brand/40 text-accent-brand"
|
||||
onClick={() => setForm(emptyForm)}
|
||||
>
|
||||
<Plus className="size-3 mr-1" /> {t("hosts.secretSourceNew")}
|
||||
<Plus className="size-3 mr-1" /> {t("new")}
|
||||
</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="flex flex-col gap-1">
|
||||
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.friendlyNameLabel")}
|
||||
{t("nameLabel")}
|
||||
</label>
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
@@ -239,7 +220,7 @@ export function SecretSourceManager({ onClose }: { onClose: () => void }) {
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.secretSourceUrlLabel")}
|
||||
{t("urlLabel")}
|
||||
</label>
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
@@ -251,13 +232,11 @@ export function SecretSourceManager({ onClose }: { onClose: () => void }) {
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.secretSourceTokenLabel")}
|
||||
{t("tokenLabel")}
|
||||
</label>
|
||||
<PasswordInput
|
||||
className="h-8 text-xs pr-8"
|
||||
placeholder={
|
||||
form.id ? t("hosts.secretSourceTokenKeep") : "eyJhbGciOi..."
|
||||
}
|
||||
placeholder={form.id ? t("tokenKeep") : "eyJhbGciOi..."}
|
||||
value={form.token}
|
||||
onChange={(e) => setField("token", e.target.value)}
|
||||
/>
|
||||
@@ -268,7 +247,7 @@ export function SecretSourceManager({ onClose }: { onClose: () => void }) {
|
||||
checked={form.shared}
|
||||
onChange={(e) => setField("shared", e.target.checked)}
|
||||
/>
|
||||
{t("hosts.secretSourceSharedLabel")}
|
||||
{t("sharedLabel")}
|
||||
</label>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
@@ -278,7 +257,7 @@ export function SecretSourceManager({ onClose }: { onClose: () => void }) {
|
||||
onClick={() => setForm(null)}
|
||||
disabled={saving}
|
||||
>
|
||||
{t("hosts.cancelBtn")}
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
|
||||
|
||||
export default pluginVitestConfig(import.meta.url);
|
||||
@@ -19,7 +19,6 @@ import uiPreferencesRoutes from "./routes/ui-preferences.js";
|
||||
import termixIdRoutes from "./routes/termix-id.js";
|
||||
import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
|
||||
import vaultRoutes from "./routes/vault.js";
|
||||
import secretSourceRoutes from "./routes/secret-sources.js";
|
||||
import notificationChannelsRoutes from "./routes/notification-channels-routes.js";
|
||||
import syncRoutes from "./routes/sync.js";
|
||||
import dashboardRoutes from "./routes/dashboard-routes.js";
|
||||
@@ -1803,7 +1802,6 @@ app.use("/ui-preferences", uiPreferencesRoutes);
|
||||
app.use("/termix-id", termixIdRoutes);
|
||||
registerAuditLogRoutes(app, authenticateJWT);
|
||||
app.use("/vault", vaultRoutes);
|
||||
app.use("/secret-sources", secretSourceRoutes);
|
||||
app.use("/", notificationChannelsRoutes);
|
||||
app.use("/sync", syncRoutes);
|
||||
app.use("/dashboard", dashboardRoutes);
|
||||
|
||||
@@ -477,19 +477,6 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS secret_sources (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'onepassword-connect',
|
||||
base_url TEXT NOT NULL,
|
||||
token TEXT NOT NULL,
|
||||
shared INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS credential_access (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
credential_id INTEGER NOT NULL,
|
||||
|
||||
@@ -1030,38 +1030,6 @@ export const syncTombstones = mysqlTable("sync_tombstones", {
|
||||
|
||||
// --- collab rooms ---
|
||||
|
||||
// --- secret sources ---
|
||||
|
||||
/**
|
||||
* An external password manager Termix pulls secrets from at connect time,
|
||||
* instead of storing them. Only the access token is secret; it is encrypted
|
||||
* with the owner's data key under the row id. Hosts and credentials refer to
|
||||
* entries by reference ("op://vault/item/field") in their secret fields.
|
||||
*/
|
||||
export const secretSources = mysqlTable(
|
||||
"secret_sources",
|
||||
{
|
||||
id: varchar("id", { length: 255 }).primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
// "onepassword-connect" for now; the reference syntax is per kind.
|
||||
kind: text("kind").notNull().default("onepassword-connect"),
|
||||
baseUrl: text("base_url").notNull(),
|
||||
token: text("token").notNull(),
|
||||
// Visible to every user; secrets still decrypt with the owner's key.
|
||||
shared: boolean("shared").notNull().default(false),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
(table) => [index("idx_secret_sources_user").on(table.userId)],
|
||||
);
|
||||
|
||||
// --- credential sharing ---
|
||||
|
||||
/**
|
||||
|
||||
@@ -1031,38 +1031,6 @@ export const syncTombstones = pgTable("sync_tombstones", {
|
||||
|
||||
// --- collab rooms ---
|
||||
|
||||
// --- secret sources ---
|
||||
|
||||
/**
|
||||
* An external password manager Termix pulls secrets from at connect time,
|
||||
* instead of storing them. Only the access token is secret; it is encrypted
|
||||
* with the owner's data key under the row id. Hosts and credentials refer to
|
||||
* entries by reference ("op://vault/item/field") in their secret fields.
|
||||
*/
|
||||
export const secretSources = pgTable(
|
||||
"secret_sources",
|
||||
{
|
||||
id: varchar("id", { length: 255 }).primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
// "onepassword-connect" for now; the reference syntax is per kind.
|
||||
kind: text("kind").notNull().default("onepassword-connect"),
|
||||
baseUrl: text("base_url").notNull(),
|
||||
token: text("token").notNull(),
|
||||
// Visible to every user; secrets still decrypt with the owner's key.
|
||||
shared: boolean("shared").notNull().default(false),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [index("idx_secret_sources_user").on(table.userId)],
|
||||
);
|
||||
|
||||
// --- credential sharing ---
|
||||
|
||||
/**
|
||||
|
||||
@@ -1027,38 +1027,6 @@ export const syncTombstones = sqliteTable("sync_tombstones", {
|
||||
|
||||
// --- collab rooms ---
|
||||
|
||||
// --- secret sources ---
|
||||
|
||||
/**
|
||||
* An external password manager Termix pulls secrets from at connect time,
|
||||
* instead of storing them. Only the access token is secret; it is encrypted
|
||||
* with the owner's data key under the row id. Hosts and credentials refer to
|
||||
* entries by reference ("op://vault/item/field") in their secret fields.
|
||||
*/
|
||||
export const secretSources = sqliteTable(
|
||||
"secret_sources",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
// "onepassword-connect" for now; the reference syntax is per kind.
|
||||
kind: text("kind").notNull().default("onepassword-connect"),
|
||||
baseUrl: text("base_url").notNull(),
|
||||
token: text("token").notNull(),
|
||||
// Visible to every user; secrets still decrypt with the owner's key.
|
||||
shared: integer("shared", { mode: "boolean" }).notNull().default(false),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [index("idx_secret_sources_user").on(table.userId)],
|
||||
);
|
||||
|
||||
// --- credential sharing ---
|
||||
|
||||
/**
|
||||
|
||||
@@ -30,7 +30,6 @@ import { RbacPermissionRepository } from "./rbac-permission-repository.js";
|
||||
import { RecentActivityRepository } from "./recent-activity-repository.js";
|
||||
import { RoleRepository } from "./role-repository.js";
|
||||
import { SessionRepository } from "./session-repository.js";
|
||||
import { SecretSourceRepository } from "./secret-source-repository.js";
|
||||
import { CredentialAccessRepository } from "./credential-access-repository.js";
|
||||
import { SharedCredentialSecretsRepository } from "./shared-credential-secrets-repository.js";
|
||||
import { FolderAccessRepository } from "./folder-access-repository.js";
|
||||
@@ -441,13 +440,6 @@ export function createCurrentSharedCredentialSecretsRepository(): SharedCredenti
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentSecretSourceRepository(): SecretSourceRepository {
|
||||
return new SecretSourceRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("secret_source_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentVaultProfileRepository(): VaultProfileRepository {
|
||||
return new VaultProfileRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
import { desc, eq, or } from "drizzle-orm";
|
||||
import { secretSources } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { insertReturning } from "./returning.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
import { FieldCrypto } from "../../utils/field-crypto.js";
|
||||
|
||||
export type SecretSourceRecord = typeof secretSources.$inferSelect;
|
||||
export type SecretSourceKind = "onepassword-connect";
|
||||
|
||||
/** A row with the token still encrypted - safe to hand to the API. */
|
||||
export type SecretSourcePublic = Omit<SecretSourceRecord, "token"> & {
|
||||
hasToken: boolean;
|
||||
};
|
||||
|
||||
export interface SecretSourceCreateInput {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
kind: SecretSourceKind;
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
shared: boolean;
|
||||
}
|
||||
|
||||
export interface SecretSourceUpdateInput {
|
||||
name?: string;
|
||||
baseUrl?: string;
|
||||
token?: string;
|
||||
shared?: boolean;
|
||||
}
|
||||
|
||||
export function toPublicSecretSource(
|
||||
row: SecretSourceRecord,
|
||||
): SecretSourcePublic {
|
||||
const { token, ...rest } = row;
|
||||
return { ...rest, hasToken: token.length > 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* The token is encrypted with the owner's data key under the row id, like
|
||||
* vault_tokens - so a shared source only decrypts while its owner's key is
|
||||
* loaded, which the resolver reports as a clear error.
|
||||
*/
|
||||
export class SecretSourceRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
private encryptToken(id: string, ownerId: string, token: string): string {
|
||||
const key = DataCrypto.validateUserAccess(ownerId);
|
||||
return FieldCrypto.encryptField(token, key, id, "token");
|
||||
}
|
||||
|
||||
decryptToken(row: SecretSourceRecord): string {
|
||||
const key = DataCrypto.getUserDataKey(row.userId);
|
||||
if (!key) {
|
||||
throw new Error(
|
||||
"The secret source owner's data is locked; they need to sign in first",
|
||||
);
|
||||
}
|
||||
return FieldCrypto.decryptField(row.token, key, row.id, "token");
|
||||
}
|
||||
|
||||
async listVisibleToUser(userId: string): Promise<SecretSourceRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(secretSources)
|
||||
.where(
|
||||
or(eq(secretSources.userId, userId), eq(secretSources.shared, true)),
|
||||
)
|
||||
.orderBy(desc(secretSources.updatedAt));
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<SecretSourceRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(secretSources)
|
||||
.where(eq(secretSources.id, id))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async create(input: SecretSourceCreateInput): Promise<SecretSourceRecord> {
|
||||
const [created] = await insertReturning(this.context, secretSources, {
|
||||
id: input.id,
|
||||
userId: input.userId,
|
||||
name: input.name,
|
||||
kind: input.kind,
|
||||
baseUrl: input.baseUrl,
|
||||
token: this.encryptToken(input.id, input.userId, input.token),
|
||||
shared: input.shared,
|
||||
});
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async update(
|
||||
row: SecretSourceRecord,
|
||||
input: SecretSourceUpdateInput,
|
||||
): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(secretSources)
|
||||
.set({
|
||||
...(input.name !== undefined ? { name: input.name } : {}),
|
||||
...(input.baseUrl !== undefined ? { baseUrl: input.baseUrl } : {}),
|
||||
...(input.shared !== undefined ? { shared: input.shared } : {}),
|
||||
...(input.token !== undefined
|
||||
? { token: this.encryptToken(row.id, row.userId, input.token) }
|
||||
: {}),
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(secretSources.id, row.id));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async deleteById(id: string): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.delete(secretSources)
|
||||
.where(eq(secretSources.id, id));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.delete(secretSources)
|
||||
.where(eq(secretSources.userId, userId));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
createCurrentUserPreferenceRepository,
|
||||
createCurrentUserRepository,
|
||||
createCurrentVaultProfileRepository,
|
||||
createCurrentSecretSourceRepository,
|
||||
createCurrentSharedCredentialSecretsRepository,
|
||||
createCurrentCredentialAccessRepository,
|
||||
createCurrentVaultTokenRepository,
|
||||
@@ -94,14 +93,15 @@ export async function deleteUserAndRelatedData(
|
||||
await createCurrentHostRepository().deleteByUserId(userId);
|
||||
await createCurrentCredentialRepository().deleteByUserId(userId);
|
||||
|
||||
// homepage_items, homepage_layouts and dashboard_service_links cascade on
|
||||
// the user's refUser() foreign key, as the homepage plugin's adopted
|
||||
// tables.
|
||||
// homepage_items, homepage_layouts, dashboard_service_links and
|
||||
// secret_sources cascade on the user's refUser() foreign key, as the
|
||||
// homepage and secret-sources plugins' adopted tables. The secret
|
||||
// source's token in ctx.secrets is cleaned up generically below, with
|
||||
// every other plugin_settings row for this user.
|
||||
|
||||
await createCurrentOpksshTokenRepository().deleteByUserId(userId);
|
||||
await createCurrentVaultTokenRepository().deleteByUserId(userId);
|
||||
await createCurrentVaultProfileRepository().deleteByUserId(userId);
|
||||
await createCurrentSecretSourceRepository().deleteByUserId(userId);
|
||||
await createCurrentTermixIdentityCaRepository().deleteByUserId(userId);
|
||||
await createCurrentTermixIdentityRepository().deleteByUserId(userId);
|
||||
await createCurrentOpenTabRepository().deleteByUserId(userId);
|
||||
|
||||
@@ -1,278 +0,0 @@
|
||||
import crypto from "crypto";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import {
|
||||
logAudit,
|
||||
getAuditUsername,
|
||||
getRequestMeta,
|
||||
} from "../../utils/audit-logger.js";
|
||||
import { testConnectSource } from "../../utils/onepassword-connect.js";
|
||||
import { readSecretSourcePrivateAllowlist } from "../../utils/secret-source-egress.js";
|
||||
import { clearExternalSecretCache } from "../../hosts/external-secrets.js";
|
||||
import { createCurrentSecretSourceRepository } from "../repositories/factory.js";
|
||||
import {
|
||||
toPublicSecretSource,
|
||||
type SecretSourceRecord,
|
||||
} from "../repositories/secret-source-repository.js";
|
||||
|
||||
const router = express.Router();
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
const requireDataAccess = authManager.createDataAccessMiddleware();
|
||||
const permissionManager = PermissionManager.getInstance();
|
||||
|
||||
const KINDS = ["onepassword-connect"] as const;
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function validBaseUrl(raw: unknown): string | null {
|
||||
if (!isNonEmptyString(raw)) return null;
|
||||
try {
|
||||
const url = new URL(raw.trim());
|
||||
if (!["http:", "https:"].includes(url.protocol)) return null;
|
||||
return url.toString().replace(/\/+$/, "");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOwned(
|
||||
id: string,
|
||||
userId: string,
|
||||
res: Response,
|
||||
): Promise<SecretSourceRecord | null> {
|
||||
const source = await createCurrentSecretSourceRepository().findById(id);
|
||||
if (!source) {
|
||||
res.status(404).json({ error: "Secret source not found" });
|
||||
return null;
|
||||
}
|
||||
if (source.userId !== userId) {
|
||||
res.status(403).json({ error: "Only the owner can change this source" });
|
||||
return null;
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /secret-sources:
|
||||
* get:
|
||||
* summary: List secret sources visible to the caller (own + shared)
|
||||
* tags:
|
||||
* - Secret Sources
|
||||
*/
|
||||
router.get(
|
||||
"/",
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("credentials.view"),
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId!;
|
||||
try {
|
||||
const rows =
|
||||
await createCurrentSecretSourceRepository().listVisibleToUser(userId);
|
||||
res.json({
|
||||
sources: rows.map((row) => ({
|
||||
...toPublicSecretSource(row),
|
||||
owned: row.userId === userId,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to list secret sources", error);
|
||||
res.status(500).json({ error: "Failed to list secret sources" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /secret-sources:
|
||||
* post:
|
||||
* summary: Create a secret source (1Password Connect)
|
||||
* description: Sharing a source with every user requires admin. The token is encrypted with the owner's data key.
|
||||
* tags:
|
||||
* - Secret Sources
|
||||
*/
|
||||
router.post(
|
||||
"/",
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("credentials.create"),
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId!;
|
||||
const {
|
||||
name,
|
||||
kind = "onepassword-connect",
|
||||
baseUrl,
|
||||
token,
|
||||
shared,
|
||||
} = req.body ?? {};
|
||||
const url = validBaseUrl(baseUrl);
|
||||
if (!isNonEmptyString(name) || !url || !isNonEmptyString(token)) {
|
||||
return res.status(400).json({
|
||||
error: "name, a valid http(s) baseUrl and token are required",
|
||||
});
|
||||
}
|
||||
if (!KINDS.includes(kind)) {
|
||||
return res.status(400).json({ error: "Unsupported secret source kind" });
|
||||
}
|
||||
if (shared === true && !(await permissionManager.isAdmin(userId))) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "Only admins can share a secret source" });
|
||||
}
|
||||
try {
|
||||
const created = await createCurrentSecretSourceRepository().create({
|
||||
id: crypto.randomUUID(),
|
||||
userId,
|
||||
name: name.trim(),
|
||||
kind,
|
||||
baseUrl: url,
|
||||
token: token.trim(),
|
||||
shared: shared === true,
|
||||
});
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: await getAuditUsername(userId),
|
||||
action: "secret_source_create",
|
||||
resourceType: "secret_source",
|
||||
resourceId: created.id,
|
||||
resourceName: created.name,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
res.json({ source: { ...toPublicSecretSource(created), owned: true } });
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to create secret source", error);
|
||||
res.status(500).json({ error: "Failed to create secret source" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /secret-sources/{id}:
|
||||
* put:
|
||||
* summary: Update a secret source (owner only; omit token to keep it)
|
||||
* tags:
|
||||
* - Secret Sources
|
||||
*/
|
||||
router.put(
|
||||
"/:id",
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("credentials.edit"),
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId!;
|
||||
const { name, baseUrl, token, shared } = req.body ?? {};
|
||||
try {
|
||||
const source = await loadOwned(String(req.params.id), userId, res);
|
||||
if (!source) return;
|
||||
const url = baseUrl === undefined ? undefined : validBaseUrl(baseUrl);
|
||||
if (baseUrl !== undefined && !url) {
|
||||
return res.status(400).json({ error: "baseUrl must be http(s)" });
|
||||
}
|
||||
if (
|
||||
shared === true &&
|
||||
!source.shared &&
|
||||
!(await permissionManager.isAdmin(userId))
|
||||
) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "Only admins can share a secret source" });
|
||||
}
|
||||
await createCurrentSecretSourceRepository().update(source, {
|
||||
...(isNonEmptyString(name) ? { name: name.trim() } : {}),
|
||||
...(url ? { baseUrl: url } : {}),
|
||||
...(isNonEmptyString(token) ? { token: token.trim() } : {}),
|
||||
...(typeof shared === "boolean" ? { shared } : {}),
|
||||
});
|
||||
clearExternalSecretCache();
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to update secret source", error);
|
||||
res.status(500).json({ error: "Failed to update secret source" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /secret-sources/{id}:
|
||||
* delete:
|
||||
* summary: Delete a secret source (owner only)
|
||||
* tags:
|
||||
* - Secret Sources
|
||||
*/
|
||||
router.delete(
|
||||
"/:id",
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("credentials.delete"),
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId!;
|
||||
try {
|
||||
const source = await loadOwned(String(req.params.id), userId, res);
|
||||
if (!source) return;
|
||||
await createCurrentSecretSourceRepository().deleteById(source.id);
|
||||
clearExternalSecretCache();
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: await getAuditUsername(userId),
|
||||
action: "secret_source_delete",
|
||||
resourceType: "secret_source",
|
||||
resourceId: source.id,
|
||||
resourceName: source.name,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to delete secret source", error);
|
||||
res.status(500).json({ error: "Failed to delete secret source" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /secret-sources/{id}/test:
|
||||
* post:
|
||||
* summary: Check that the source is reachable and the token is accepted
|
||||
* tags:
|
||||
* - Secret Sources
|
||||
*/
|
||||
router.post(
|
||||
"/:id/test",
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("credentials.view"),
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId!;
|
||||
try {
|
||||
const repository = createCurrentSecretSourceRepository();
|
||||
const source = await repository.findById(String(req.params.id));
|
||||
if (!source || (source.userId !== userId && !source.shared)) {
|
||||
return res.status(404).json({ error: "Secret source not found" });
|
||||
}
|
||||
const vaults = await testConnectSource({
|
||||
baseUrl: source.baseUrl,
|
||||
token: repository.decryptToken(source),
|
||||
allowedPrivateHosts: await readSecretSourcePrivateAllowlist(),
|
||||
});
|
||||
res.json({ ok: true, vaults });
|
||||
} catch (error) {
|
||||
res.status(200).json({ ok: false, error: getErrorMessage(error) });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
} from "../../utils/audit-forwarder.js";
|
||||
import { getTelemetryEnvOverride } from "../../utils/analytics.js";
|
||||
import { STEP_CA_PRIVATE_ALLOWLIST_KEY } from "../../utils/step-ca-egress.js";
|
||||
import { SECRET_SOURCE_PRIVATE_ALLOWLIST_KEY } from "../../utils/secret-source-egress.js";
|
||||
import {
|
||||
NOTIFICATION_PRIVATE_ALLOWLIST_KEY,
|
||||
parseNotificationAllowlist,
|
||||
@@ -582,25 +581,6 @@ export function registerUserSettingsRoutes(
|
||||
"Step CA endpoint",
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/secret-source-private-endpoints:
|
||||
* get:
|
||||
* summary: Get the private hosts secret sources (1Password Connect) may contact (admin only)
|
||||
* tags:
|
||||
* - Users
|
||||
* patch:
|
||||
* summary: Replace that allowlist (admin only)
|
||||
* tags:
|
||||
* - Users
|
||||
*/
|
||||
registerPrivateEndpointAllowlist(
|
||||
"/secret-source-private-endpoints",
|
||||
SECRET_SOURCE_PRIVATE_ALLOWLIST_KEY,
|
||||
"update_secret_source_private_endpoints",
|
||||
"secret source endpoint",
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/step-ca-settings:
|
||||
|
||||
@@ -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 = () => [];
|
||||
}
|
||||
@@ -1,15 +1,13 @@
|
||||
import { createCurrentSecretSourceRepository } from "../database/repositories/factory.js";
|
||||
import type { SecretSourceRecord } from "../database/repositories/secret-source-repository.js";
|
||||
import {
|
||||
isSecretReference,
|
||||
parseSecretReference,
|
||||
resolveConnectReference,
|
||||
} from "../utils/onepassword-connect.js";
|
||||
import { readSecretSourcePrivateAllowlist } from "../utils/secret-source-egress.js";
|
||||
getSecretResolver,
|
||||
requireSecretResolver,
|
||||
} from "./connect/secret-resolver-registry.js";
|
||||
|
||||
/**
|
||||
* Expands "op://vault/item/field" references in a resolved host's secret
|
||||
* fields into the actual secrets, fetched from the user's secret source.
|
||||
* Expands "<scheme>://..." references ("op://vault/item/field" for 1Password
|
||||
* 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
|
||||
* gets its plaintext credentials - so terminal, SFTP, Docker, metrics and
|
||||
@@ -30,6 +28,8 @@ export const SECRET_FIELDS = [
|
||||
"telnetPassword",
|
||||
] as const;
|
||||
|
||||
const REFERENCE_PATTERN = /^([a-z][a-z0-9+.-]*):\/\//i;
|
||||
|
||||
const CACHE_TTL_MS = 60_000;
|
||||
const cache = new Map<string, { value: string; expiresAt: number }>();
|
||||
|
||||
@@ -38,37 +38,27 @@ export function clearExternalSecretCache(): void {
|
||||
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 = (
|
||||
source: SecretSourceRecord,
|
||||
userId: string,
|
||||
reference: string,
|
||||
) => Promise<string>;
|
||||
|
||||
async function defaultResolver(
|
||||
source: SecretSourceRecord,
|
||||
userId: string,
|
||||
reference: string,
|
||||
): Promise<string> {
|
||||
const ref = parseSecretReference(reference);
|
||||
if (!ref) throw new Error(`Invalid secret reference: ${reference}`);
|
||||
const repository = createCurrentSecretSourceRepository();
|
||||
return resolveConnectReference(
|
||||
{
|
||||
baseUrl: source.baseUrl,
|
||||
token: repository.decryptToken(source),
|
||||
allowedPrivateHosts: await readSecretSourcePrivateAllowlist(),
|
||||
},
|
||||
ref,
|
||||
);
|
||||
}
|
||||
|
||||
/** The user's own source first, else a shared one. */
|
||||
export async function pickSecretSource(
|
||||
userId: string,
|
||||
): Promise<SecretSourceRecord | null> {
|
||||
const sources =
|
||||
await createCurrentSecretSourceRepository().listVisibleToUser(userId);
|
||||
return (
|
||||
sources.find((source) => source.userId === userId) ?? sources[0] ?? null
|
||||
);
|
||||
const scheme = referenceScheme(reference);
|
||||
const resolver = requireSecretResolver(scheme);
|
||||
return resolver.resolve(userId, reference);
|
||||
}
|
||||
|
||||
export async function resolveSecretReference(
|
||||
@@ -76,22 +66,15 @@ export async function resolveSecretReference(
|
||||
reference: string,
|
||||
deps: {
|
||||
resolver?: SecretResolver;
|
||||
pickSource?: (userId: string) => Promise<SecretSourceRecord | null>;
|
||||
now?: () => number;
|
||||
} = {},
|
||||
): Promise<string> {
|
||||
const now = deps.now ?? Date.now;
|
||||
const source = await (deps.pickSource ?? pickSecretSource)(userId);
|
||||
if (!source) {
|
||||
throw new Error(
|
||||
"This host uses a secret reference but no secret source is configured",
|
||||
);
|
||||
}
|
||||
const cacheKey = `${source.id}:${reference.trim()}`;
|
||||
const cacheKey = `${userId}:${reference.trim()}`;
|
||||
const cached = cache.get(cacheKey);
|
||||
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 });
|
||||
return value;
|
||||
}
|
||||
@@ -108,3 +91,8 @@ export async function resolveExternalSecretRefs(
|
||||
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));
|
||||
}
|
||||
@@ -7,6 +7,12 @@
|
||||
* The protocol credentials themselves stay in core, next to the host, because
|
||||
* core's sharing model (per-recipient secret snapshots, personal overrides)
|
||||
* 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 {
|
||||
@@ -14,9 +20,12 @@ import type {
|
||||
PluginHostProtocol,
|
||||
PluginProtocolTarget,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import { assertCapability } from "./permissions.js";
|
||||
import { getActor } from "./actor.js";
|
||||
import type { DisposableBag } from "./disposables.js";
|
||||
import { registerSecretResolver } from "../hosts/connect/secret-resolver-registry.js";
|
||||
|
||||
type AuditFn = (
|
||||
action: string,
|
||||
@@ -26,6 +35,7 @@ type AuditFn = (
|
||||
|
||||
interface Deps {
|
||||
manifest: PluginManifest;
|
||||
bag: DisposableBag;
|
||||
audit: AuditFn;
|
||||
}
|
||||
|
||||
@@ -149,12 +159,45 @@ async function resolveRecipientAuth(
|
||||
|
||||
export function createPluginCredentials({
|
||||
manifest,
|
||||
bag,
|
||||
audit,
|
||||
}: Deps): PluginCredentials {
|
||||
const pluginId = manifest.id;
|
||||
const declared = manifest.capabilities;
|
||||
|
||||
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) => {
|
||||
const details = `${protocol} credentials for host ${hostId}`;
|
||||
try {
|
||||
|
||||
@@ -804,7 +804,11 @@ export function createPluginContext(
|
||||
available: () => isElectronIpcAvailable(),
|
||||
},
|
||||
|
||||
credentials: createPluginCredentials({ manifest, audit: auditCall }),
|
||||
credentials: createPluginCredentials({
|
||||
manifest,
|
||||
bag: handle.bag,
|
||||
audit: auditCall,
|
||||
}),
|
||||
|
||||
audit: {
|
||||
// Attribution comes from the runtime: the actor, never a plugin value.
|
||||
|
||||
@@ -15,6 +15,7 @@ import { PluginLoader, type LoadedPlugin } from "./loader.js";
|
||||
import type { PluginPermissionContribution } from "./manifest.js";
|
||||
import { invalidatePluginPermissionCache } from "./permissions.js";
|
||||
import { setSshAuthTypeOwnerSource } from "../hosts/connect/auth-provider-registry.js";
|
||||
import { setSecretResolverOwnerSource } from "../hosts/connect/secret-resolver-registry.js";
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -405,6 +405,10 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
||||
const { runWakeOnLanSettingsMigration } =
|
||||
await import("./utils/crypto-migration/wake-on-lan-settings-migration.js");
|
||||
await runWakeOnLanSettingsMigration();
|
||||
|
||||
const { runSecretSourcesTokenMigration } =
|
||||
await import("./utils/crypto-migration/secret-sources-token-migration.js");
|
||||
await runSecretSourcesTokenMigration();
|
||||
} catch (error) {
|
||||
systemLogger.warn("Plugin runtime failed to initialize", {
|
||||
operation: "plugin_init",
|
||||
|
||||
@@ -1,46 +1,38 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentSecretSourceRepository: () => ({
|
||||
listVisibleToUser: async () => [],
|
||||
decryptToken: () => "",
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({ get: async () => null }),
|
||||
}));
|
||||
|
||||
const {
|
||||
clearExternalSecretCache,
|
||||
isSecretReference,
|
||||
resolveExternalSecretRefs,
|
||||
resolveSecretReference,
|
||||
} = await import("../../hosts/external-secrets.js");
|
||||
const { setSecretResolverOwnerSource, resetSecretResolverRegistryForTests } =
|
||||
await import("../../hosts/connect/secret-resolver-registry.js");
|
||||
|
||||
const source = {
|
||||
id: "src-1",
|
||||
userId: "alice",
|
||||
name: "1P",
|
||||
kind: "onepassword-connect",
|
||||
baseUrl: "https://connect.internal",
|
||||
token: "enc",
|
||||
shared: false,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
};
|
||||
describe("isSecretReference", () => {
|
||||
it("recognises a scheme:// reference without confusing it with a secret", () => {
|
||||
expect(isSecretReference("op://v/i/f")).toBe(true);
|
||||
expect(isSecretReference(" op://v/i/f")).toBe(true);
|
||||
expect(isSecretReference("vault+https://x")).toBe(true);
|
||||
expect(isSecretReference("hunter2")).toBe(false);
|
||||
expect(isSecretReference(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("external secret references", () => {
|
||||
beforeEach(() => clearExternalSecretCache());
|
||||
|
||||
it("replaces references in the host's secret fields and leaves plain secrets alone", async () => {
|
||||
const resolver = vi.fn(async (_s, ref: string) => `resolved:${ref}`);
|
||||
const resolver = vi.fn(
|
||||
async (_userId: string, ref: string) => `resolved:${ref}`,
|
||||
);
|
||||
const host: Record<string, unknown> = {
|
||||
password: "op://Infra/box/password",
|
||||
key: "-----BEGIN OPENSSH PRIVATE KEY-----",
|
||||
sudoPassword: "op://Infra/box/sudo",
|
||||
username: "op://not-a-secret-field",
|
||||
};
|
||||
await resolveExternalSecretRefs(host, "alice", {
|
||||
resolver,
|
||||
pickSource: async () => source,
|
||||
});
|
||||
await resolveExternalSecretRefs(host, "alice", { resolver });
|
||||
expect(host.password).toBe("resolved:op://Infra/box/password");
|
||||
expect(host.sudoPassword).toBe("resolved:op://Infra/box/sudo");
|
||||
expect(host.key).toBe("-----BEGIN OPENSSH PRIVATE KEY-----");
|
||||
@@ -48,10 +40,10 @@ describe("external secret references", () => {
|
||||
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");
|
||||
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);
|
||||
expect(resolver).toHaveBeenCalledTimes(1);
|
||||
@@ -60,12 +52,26 @@ describe("external secret references", () => {
|
||||
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(
|
||||
resolveSecretReference("bob", "op://v/i/f", {
|
||||
resolver: async () => "x",
|
||||
pickSource: async () => null,
|
||||
}),
|
||||
).rejects.toThrow(/no secret source/);
|
||||
resolveSecretReference("bob", "op://v/i/f", {}),
|
||||
).rejects.toThrow(/needs the .* plugin|no enabled plugin resolves/);
|
||||
});
|
||||
|
||||
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 { invalidatePluginPermissionCache } from "../../plugins/permissions.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";
|
||||
|
||||
function contextFor(capabilities: string[]) {
|
||||
function contextFor(
|
||||
capabilities: string[],
|
||||
contributes?: PluginManifest["contributes"],
|
||||
) {
|
||||
const manifest = {
|
||||
id: "demo",
|
||||
name: "demo",
|
||||
@@ -73,6 +81,7 @@ function contextFor(capabilities: string[]) {
|
||||
category: "Productivity",
|
||||
engine: { termix: ">=2.9.0", api: "1" },
|
||||
capabilities,
|
||||
contributes,
|
||||
} as PluginManifest;
|
||||
const handle = createPluginHandle("demo", { activate: () => {} });
|
||||
return createPluginContext(manifest, handle);
|
||||
@@ -104,6 +113,7 @@ beforeEach(() => {
|
||||
state.resolution = null;
|
||||
state.resolverCalls.length = 0;
|
||||
state.hosts.set(7, { ...baseHost });
|
||||
resetSecretResolverRegistryForTests();
|
||||
});
|
||||
|
||||
describe("ctx.credentials.resolveHostProtocol", () => {
|
||||
@@ -226,3 +236,72 @@ describe("ctx.credentials.resolveHostProtocol", () => {
|
||||
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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(
|
||||
hosts: string[],
|
||||
): Promise<string[]> {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -611,22 +611,6 @@
|
||||
"selectAVaultProfile": "Select a Vault profile...",
|
||||
"vaultProfileHint": "Settings come from the shared profile; you'll sign in to Vault via OIDC when you connect. No secrets are stored.",
|
||||
"vaultNewProfile": "New profile",
|
||||
"secretRefHint": "You can paste a 1Password reference (op://vault/item/field) instead of the secret; it is fetched from your secret source when connecting.",
|
||||
"secretSourcesManage": "Manage secret sources",
|
||||
"secretSourcesTitle": "Secret sources",
|
||||
"secretSourcesDesc": "External password managers Termix reads secrets from at connect time. Currently 1Password Connect (self-hosted). The access token is stored encrypted with your data key.",
|
||||
"secretSourceNew": "New source",
|
||||
"secretSourceUrlLabel": "Connect server URL",
|
||||
"secretSourceTokenLabel": "Connect access token",
|
||||
"secretSourceTokenKeep": "Leave empty to keep the current token",
|
||||
"secretSourceSharedLabel": "Share with all users (admins only; resolves while you are signed in)",
|
||||
"secretSourceShared": "shared",
|
||||
"secretSourceTest": "Test",
|
||||
"secretSourceTestOk": "Connected — {{count}} vault(s) visible",
|
||||
"secretSourceTestFailed": "Connection test failed",
|
||||
"secretSourceRequired": "Name, server URL and token are required",
|
||||
"secretSourceSaved": "Secret source saved",
|
||||
"secretSourceDeleted": "Secret source deleted",
|
||||
"vaultManageProfiles": "Manage Vault profiles",
|
||||
"vaultAddrLabel": "Vault Address",
|
||||
"vaultNamespaceLabel": "Namespace",
|
||||
@@ -2103,9 +2087,6 @@
|
||||
"stepCaProvisioner": "OIDC provisioner name",
|
||||
"stepCaSaved": "Step CA settings saved",
|
||||
"stepCaSaveFailed": "Failed to save Step CA settings",
|
||||
"secretSourcePrivateEndpoints": "Allowed private secret source hosts",
|
||||
"secretSourcePrivateEndpointsDesc": "Private hosts that secret sources (1Password Connect) may contact. Separate them with commas.",
|
||||
"updateSecretSourceEndpointsFailed": "Failed to update the secret source endpoint allowlist",
|
||||
"stepCaPrivateEndpoints": "Allowed private Step CA hosts",
|
||||
"stepCaPrivateEndpointsDesc": "Private hosts the Step CA certificate flow may contact: the CA itself and, if internal, your identity provider. Separate them with commas.",
|
||||
"updateStepCaEndpointsFailed": "Failed to update the Step CA endpoint allowlist",
|
||||
|
||||
@@ -5,8 +5,6 @@ import {
|
||||
getNotificationPrivateEndpoints,
|
||||
getStepCaPrivateEndpoints,
|
||||
setStepCaPrivateEndpoints as setStepCaPrivateEndpointsApi,
|
||||
getSecretSourcePrivateEndpoints,
|
||||
setSecretSourcePrivateEndpoints as setSecretSourcePrivateEndpointsApi,
|
||||
setNotificationPrivateEndpoints as setNotificationPrivateEndpointsApi,
|
||||
} from "@/api/private-endpoints-api";
|
||||
import {
|
||||
@@ -142,8 +140,6 @@ export function AdminSettingsPanel({
|
||||
const [stepCaPrivateEndpoints, setStepCaPrivateEndpoints] = useState<
|
||||
string[]
|
||||
>([]);
|
||||
const [secretSourcePrivateEndpoints, setSecretSourcePrivateEndpoints] =
|
||||
useState<string[]>([]);
|
||||
const [stepCaSettings, setStepCaSettings] = useState({
|
||||
caUrl: "",
|
||||
fingerprint: "",
|
||||
@@ -351,7 +347,6 @@ export function AdminSettingsPanel({
|
||||
analytics,
|
||||
notificationEndpoints,
|
||||
stepCaEndpoints,
|
||||
secretSourceEndpoints,
|
||||
branding,
|
||||
] = await Promise.allSettled([
|
||||
getRegistrationAllowed(),
|
||||
@@ -366,7 +361,6 @@ export function AdminSettingsPanel({
|
||||
getAnalyticsEnabled(),
|
||||
getNotificationPrivateEndpoints(),
|
||||
getStepCaPrivateEndpoints(),
|
||||
getSecretSourcePrivateEndpoints(),
|
||||
getBranding(),
|
||||
]);
|
||||
|
||||
@@ -401,9 +395,6 @@ export function AdminSettingsPanel({
|
||||
if (stepCaEndpoints.status === "fulfilled") {
|
||||
setStepCaPrivateEndpoints(stepCaEndpoints.value);
|
||||
}
|
||||
if (secretSourceEndpoints.status === "fulfilled") {
|
||||
setSecretSourcePrivateEndpoints(secretSourceEndpoints.value);
|
||||
}
|
||||
if (notificationEndpoints.status === "fulfilled") {
|
||||
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[]) {
|
||||
const previous = stepCaPrivateEndpoints;
|
||||
setStepCaPrivateEndpoints(hosts);
|
||||
@@ -1033,10 +1011,6 @@ export function AdminSettingsPanel({
|
||||
notificationPrivateEndpoints={notificationPrivateEndpoints}
|
||||
stepCaPrivateEndpoints={stepCaPrivateEndpoints}
|
||||
onSaveStepCaPrivateEndpoints={handleSaveStepCaPrivateEndpoints}
|
||||
secretSourcePrivateEndpoints={secretSourcePrivateEndpoints}
|
||||
onSaveSecretSourcePrivateEndpoints={
|
||||
handleSaveSecretSourcePrivateEndpoints
|
||||
}
|
||||
stepCaSettings={stepCaSettings}
|
||||
setStepCaSettings={setStepCaSettings}
|
||||
handleSaveStepCaSettings={handleSaveStepCaSettings}
|
||||
|
||||
@@ -31,8 +31,6 @@ type GeneralSettingsSectionProps = {
|
||||
onSaveNotificationPrivateEndpoints: (hosts: string[]) => void;
|
||||
stepCaPrivateEndpoints: string[];
|
||||
onSaveStepCaPrivateEndpoints: (hosts: string[]) => void;
|
||||
secretSourcePrivateEndpoints: string[];
|
||||
onSaveSecretSourcePrivateEndpoints: (hosts: string[]) => void;
|
||||
stepCaSettings: { caUrl: string; fingerprint: string; provisioner: string };
|
||||
setStepCaSettings: Dispatch<
|
||||
SetStateAction<{ caUrl: string; fingerprint: string; provisioner: string }>
|
||||
@@ -72,8 +70,6 @@ export function AdminGeneralSettingsSection({
|
||||
onSaveNotificationPrivateEndpoints,
|
||||
stepCaPrivateEndpoints,
|
||||
onSaveStepCaPrivateEndpoints,
|
||||
secretSourcePrivateEndpoints,
|
||||
onSaveSecretSourcePrivateEndpoints,
|
||||
stepCaSettings,
|
||||
setStepCaSettings,
|
||||
handleSaveStepCaSettings,
|
||||
@@ -167,28 +163,6 @@ export function AdminGeneralSettingsSection({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5 py-2">
|
||||
<span className="text-xs font-medium">
|
||||
{t("admin.secretSourcePrivateEndpoints")}
|
||||
</span>
|
||||
<span className="text-[11px] leading-snug text-muted-foreground">
|
||||
{t("admin.secretSourcePrivateEndpointsDesc")}
|
||||
</span>
|
||||
<Input
|
||||
className="rounded-none"
|
||||
defaultValue={secretSourcePrivateEndpoints.join(", ")}
|
||||
placeholder="connect.internal, 10.0.0.5"
|
||||
onBlur={(event) =>
|
||||
onSaveSecretSourcePrivateEndpoints(
|
||||
event.target.value
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 border-t border-border pt-3 mt-2">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("admin.stepCa")}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { useRef, useState } from "react";
|
||||
import {
|
||||
SecretReferenceHint,
|
||||
SecretSourceManager,
|
||||
} from "./SecretSourceManager";
|
||||
import { PluginComponent } from "@/plugin-host/component-registry";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
import { Copy, Info, Lock, Upload, X } from "lucide-react";
|
||||
@@ -296,12 +293,14 @@ export function CredentialEditorView({
|
||||
value={credForm.password}
|
||||
onChange={(e) => setCredField("password", e.target.value)}
|
||||
/>
|
||||
<SecretReferenceHint
|
||||
<PluginComponent
|
||||
id="secret-sources.hint"
|
||||
onManage={() => setShowSecretSources((v) => !v)}
|
||||
/>
|
||||
</div>
|
||||
{showSecretSources && (
|
||||
<SecretSourceManager
|
||||
<PluginComponent
|
||||
id="secret-sources.manager"
|
||||
onClose={() => setShowSecretSources(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -89,10 +89,7 @@ import { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
|
||||
import { SshAuthProviderFields } from "./SshAuthProviderFields";
|
||||
import type { PluginSettingsField } from "@termix/plugin-sdk/manifest";
|
||||
import { ensureLegacyAuthUI } from "@/auth/legacy-auth-ui";
|
||||
import {
|
||||
SecretReferenceHint,
|
||||
SecretSourceManager,
|
||||
} from "./SecretSourceManager";
|
||||
import { PluginComponent } from "@/plugin-host/component-registry";
|
||||
import {
|
||||
toCredentialOption,
|
||||
type CredentialOption,
|
||||
@@ -698,14 +695,16 @@ export function HostEditor({
|
||||
}}
|
||||
onChange={(e) => setField("password", e.target.value)}
|
||||
/>
|
||||
<SecretReferenceHint
|
||||
<PluginComponent
|
||||
id="secret-sources.hint"
|
||||
onManage={() => setShowSecretSources((v) => !v)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(authMethod === "password" || authMethod === "key") &&
|
||||
showSecretSources && (
|
||||
<SecretSourceManager
|
||||
<PluginComponent
|
||||
id="secret-sources.manager"
|
||||
onClose={() => setShowSecretSources(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user