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

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

No files matched your search

+56 -1
View File
@@ -752,7 +752,7 @@ sessions shared with a user from the `sessions.sharedWithMe` action.
(`snippets.access`, a hard dependency), fleets (`fleets.access`), tunnels
(`tunnels.access`), Docker (`docker.containers`, `docker.events`), host metrics
(`host-metrics.viewers`) and wake-on-lan (`wake-on-lan.send` v1,
`wake(hostId)`, which B20 provides) as services, notification channels through
`wake(hostId)`, which **B20** provides) as services, notification channels through
`ctx.notify`, the HTTP step through `ctx.fetch`, hosts through `ctx.hosts` and
`ctx.ssh.resolveHost`, and commands through `ctx.ssh.withConnection`. Every run
executes inside `ctx.asUser(<owner>)`, so each of those calls is the owner's.
@@ -845,6 +845,38 @@ several widget-owning plugins relied on through the `homepage` namespace's
core fallback moved to core's `common.*`, which is what that fallback is
actually for.
**Wake-on-LAN (B20)** imports nothing from core. It owns no table: `macAddress`
and `broadcastAddress` are host settings (`contributes.settings.host`), moved
out of `ssh_data.mac_address`/`wol_broadcast_address` by a boot migration in
`src/backend/utils/crypto-migration/wake-on-lan-settings-migration.ts`. A
"Wake" host action sends the magic packet, gated on `network:broadcast`
(medium, added to the capability catalog this step). It provides
`wake-on-lan.send` v1 (`wake(hostId): Promise<void>`, which resolves the
host's MAC and broadcast address itself and checks the caller's own
`wake-on-lan.send` permission), which automations (B17) already declared as
an optional dependency.
**Secret sources (B20)** imports nothing from core. It adopts `secret_sources`
(rename only; the access token is not a column - see below), and provides no
service, only `ctx.credentials.registerSecretResolver("op", resolve)`. The
1Password Connect client, its egress allowlist (an admin `privateEndpoints`
textarea, the same shape as the ai plugin's) and the source manager UI (a
component slot core's host editor and credential editor render through
`app.registerComponent("secret-sources.hint" | "secret-sources.manager", ...)`
and `PluginComponent`, replacing the static imports those two core files used
to carry) all moved in. The access token follows the pattern the ai plugin set
for provider keys: it lives in `ctx.secrets` under `"source:<id>"` rather than
in the table, so it is encrypted with the installation key rather than the
owner's data key and is readable whenever the plugin runs, not only while the
owner is signed in. A shared source's token is still only ever read for its
own owner: `resolveConnectReference` and the `/test` route both go through
`ctx.asUser(<owner>)` before reading it, so a recipient using a shared source
never touches the raw value directly. `src/backend/utils/crypto-migration/secret-sources-token-migration.ts`
moves an existing owner-DEK-encrypted token out of the column and into
`ctx.secrets` at boot, the same shape as `ai-settings-migration.ts`'s provider
key move. The `/users/secret-source-private-endpoints` route and its admin UI
are gone; the allowlist is the plugin's own admin setting now.
#### Host status is core
The status dot in the host list, the dashboard and the homepage widget is
@@ -1276,6 +1308,7 @@ not do.
"sshAuthTypes": ["corp-ca"], // host auth types this plugin handles
"loginMethods": ["corp-sso"],
"secondFactors": ["pin"],
"secretSchemes": ["op"], // "<scheme>://..." secret references this plugin resolves
},
"settings": {
"admin": [
@@ -1380,6 +1413,10 @@ accept the kinds `button` and `component`.
`contributes.auth` lists the ids a plugin registers through `ctx.auth`. A
register call for an id not listed here throws. Core also reads
`sshAuthTypes` from disabled plugins, to say which plugin a host needs.
`secretSchemes` (**B20**) is the same idea for
`ctx.credentials.registerSecretResolver`: core reads it from disabled
plugins too, so a reference whose scheme belongs to a disabled plugin can
name that plugin in its error instead of saying nothing resolves it.
`contributes.hostCapability`, `provides`, `requires`, `providesSecret` and
`requiresSecret` are carried forward from v1 unchanged because the service and
@@ -1416,6 +1453,7 @@ deciding, not the mechanism.
| `credentials:use` | medium | Connect using a host's stored credentials, without seeing them |
| `network:outbound` | medium | Make outbound requests |
| `network:serve` | medium | Open a listening port |
| `network:broadcast` | medium | Send network broadcast packets |
| `users:read` | medium | See usernames and roles, never hashes or 2FA state |
| `events:core` | medium | Subscribe to and emit core events carrying other users' data |
| `notify:send` | medium | Send notifications through configured channels |
@@ -1477,6 +1515,7 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
| `ctx.desktop.openIsolatedWindow` | `desktop:window` | **B8** |
| `ctx.desktop.launchNativeRdp` / `.available` | `desktop:window` (launch only) | **B14** |
| `ctx.credentials.resolveHostProtocol` | `credentials:read` | **B14** |
| `ctx.credentials.registerSecretResolver` | `auth:provide` | **B20** |
| `ctx.audit.record` | none, the actor is the runtime's | **B9** |
| `ctx.schedule.every` / `.after` | none | **B16** |
| `ctx.fetch` | `network:outbound` | **B17**, signal **B18** |
@@ -1636,6 +1675,22 @@ resolution (the owner's shared snapshot or their own override), never the
owner's raw secret; no connect access, or no host, is `null`. Every call is
audited as `plugin_credentials_read`, allowed or refused.
**B20** added `ctx.credentials.registerSecretResolver(scheme, resolve)`,
behind `auth:provide` like `ctx.auth`'s registrations, for a plugin that
expands `"<scheme>://..."` references in a host's secret fields into the
real secret (secret-sources registers `"op"` for
`"op://vault/item/field"`). The scheme must be listed in
`contributes.auth.secretSchemes`, which exists for the same reason
`sshAuthTypes` does: so core can name the plugin a disabled scheme needs. A
core registry in `src/backend/hosts/connect/secret-resolver-registry.ts`
mirrors the SSH auth provider registry one-for-one (one owner per scheme,
`SecretResolverMissingError` naming the plugin when none is registered).
`resolve` is called once per host resolution, for the user the field's owner
resolves as, by `src/backend/hosts/external-secrets.ts`, which is scheme-
generic now: it detects a reference by its `scheme://` prefix, looks up the
registered resolver, and caches the result for a minute. Core registers
nothing here itself.
**B16** added `ctx.hosts.status`, the plugin side of core's host status
check (see [Host status](#host-status-is-core)):
+8 -5
View File
@@ -375,11 +375,6 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
there: it writes host rows only and never plugin settings. Owner: D1, with
the generic host-settings export and import.
- **B17 (automations), for B20:** the wake-on-lan step calls the optional
`wake-on-lan.send` v1 service, `wake(hostId): Promise<void>`, as the
automation's owner; the provider resolves the host's MAC and checks access
itself. Until B20 provides it, the step is offered nowhere and an existing
one is skipped with "Needs the wake-on-lan plugin". Owner: B20.
- **B17 (automations), for D2:** the ctx table says `ctx.events.on` needs
`events:core` for core topics, but the runtime only checks `emit`.
docker, file-manager, host-metrics, session-recording and snippets
@@ -450,6 +445,14 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
own B10 step moved off of (per `plugins/tmux-monitor/CHANGELOG.md`) without
updating this frontend constant to `/plugin-api/tmux-monitor`. Not
something this step touched or verified further. Owner: D0.
- **Pre-existing, found in B20:** `npm run test:plugins` fails on three tests
unrelated to wake-on-lan or secret-sources: `ssh-terminal` and `tunnels`
both assert `mock.wsRoutes` equals `[{ path, raw }]`, but the mock now also
carries `handler` and `options` (added for **B15**'s doubles, per the
contract's "Tests" section), so the exact-equality assertion fails; and
`web-endpoint`'s "answers 503 while the tunnels plugin is not available"
test gets 502. Confirmed present on `dev-2.9.0` before this step (checked
by stashing B20's changes and re-running). Owner: D0.
## Manual checks after 2.9.0
@@ -292,6 +292,10 @@
"secondFactors": {
"type": "array",
"items": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,63}$" }
},
"secretSchemes": {
"type": "array",
"items": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,63}$" }
}
}
},
+15
View File
@@ -1257,6 +1257,21 @@ export interface PluginCredentials {
hostId: number,
protocol: PluginHostProtocol,
) => Promise<PluginProtocolTarget | null>;
/**
* Registers this plugin as the resolver for "<scheme>://..." references in
* a host's secret fields (secret-sources registers "op" for
* "op://vault/item/field"). Needs auth:provide, like registerSshAuthProvider.
*
* `resolve` runs once per host resolution, for the user the field's owner
* resolves as (the host owner, or the recipient for a personal override),
* and its result is cached briefly by the connect pipeline. A scheme with no
* registered resolver fails the connect with a message naming the plugin
* that would provide it.
*/
registerSecretResolver: (
scheme: string,
resolve: (userId: string, reference: string) => Promise<string>,
) => void;
}
/**
+11 -3
View File
@@ -257,6 +257,8 @@ export interface PluginAuthContribution {
loginMethods?: string[];
/** Second factor ids. */
secondFactors?: string[];
/** Schemes this plugin resolves for "<scheme>://..." secret references, e.g. "op". */
secretSchemes?: string[];
}
export interface PluginContributions {
@@ -790,11 +792,16 @@ function validateAuthContribution(value: unknown, errors: string[]): void {
}
rejectUnknown(
value,
["sshAuthTypes", "loginMethods", "secondFactors"],
["sshAuthTypes", "loginMethods", "secondFactors", "secretSchemes"],
"contributes.auth",
errors,
);
for (const key of ["sshAuthTypes", "loginMethods", "secondFactors"]) {
for (const key of [
"sshAuthTypes",
"loginMethods",
"secondFactors",
"secretSchemes",
]) {
const list = value[key];
if (list === undefined) continue;
if (!Array.isArray(list)) {
@@ -1343,7 +1350,8 @@ export function parseManifest(raw: unknown): ParsedManifest {
const contributesAuth =
(auth?.sshAuthTypes?.length ?? 0) +
(auth?.loginMethods?.length ?? 0) +
(auth?.secondFactors?.length ?? 0) >
(auth?.secondFactors?.length ?? 0) +
(auth?.secretSchemes?.length ?? 0) >
0;
if (contributesAuth && !manifest.capabilities.includes("auth:provide")) {
errors.push(
+16
View File
@@ -126,6 +126,11 @@ export interface FakeAuthRegistrations {
secondFactors: PluginSecondFactor[];
/** "<userId>:<factorId>" for every recorded enrolment. */
enrollments: Set<string>;
/** Secret schemes registered through ctx.credentials.registerSecretResolver. */
secretResolvers: Map<
string,
(userId: string, reference: string) => Promise<string>
>;
}
export interface FakePluginContext {
@@ -336,6 +341,7 @@ export function createFakeContext(
loginMethods: [],
secondFactors: [],
enrollments: new Set(),
secretResolvers: new Map(),
};
const sshClient = options.sshClient ?? {};
let actor = options.actor;
@@ -800,6 +806,12 @@ export function createFakeContext(
credentialReads.push({ hostId, protocol });
return options.protocolTargets?.[`${hostId}:${protocol}`] ?? null;
},
registerSecretResolver: (scheme, resolve) => {
auth.secretResolvers.set(scheme, resolve);
disposals.push(() => {
auth.secretResolvers.delete(scheme);
});
},
},
notify: {
@@ -1223,6 +1235,10 @@ export function createMockCtx(
require("credentials:read");
return ctx.credentials.resolveHostProtocol(hostId, protocol);
},
registerSecretResolver: (scheme, resolve) => {
require("auth:provide");
ctx.credentials.registerSecretResolver(scheme, resolve);
},
},
notify: {