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