mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat(sdk): let plugins mint a short-lived user session and read a user's locale (#1666)
ctx.auth.issueUserToken(userId, { purpose, ttlSeconds }) gives background
work a session for calling Termix's own API as an existing user. It needs
users:impersonate, defaults to 300s and is capped at 900s, is stored as a
normal session row marked as plugin-issued, and every issue is audited.
Such sessions are refused on routes that change the user's password,
second factors, API keys or linked logins.
ctx.users.getLocale(userId) reads the user's UI language under users:read.
SDK 1.0.11, plugin API 1.3.0.
This commit is contained in:
1 parent
3892381b95
commit
b63852ee5c
12 files changed
+642
-39
No files matched your search
Generated
+1
-1
@@ -15058,7 +15058,7 @@
|
||||
},
|
||||
"packages/plugin-sdk": {
|
||||
"name": "@termix-ssh/plugin-sdk",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@deadendjs/swagger-jsdoc": "^8.1.2",
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# Changelog
|
||||
|
||||
## 1.0.11
|
||||
|
||||
Plugin API 1.3.0.
|
||||
|
||||
### Added
|
||||
|
||||
- `ctx.auth.issueUserToken()` mints a short-lived session for a user, for calling Termix's own API as them from background work. Needs `users:impersonate` and `engine.api` 1.3
|
||||
- `ctx.users.getLocale()` reads a user's UI language. Needs `users:read` and `engine.api` 1.3
|
||||
|
||||
## 1.0.10
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Termix plugin guide
|
||||
|
||||
SDK 1.0.10 | Plugin API 1.2.0
|
||||
SDK 1.0.11 | Plugin API 1.3.0
|
||||
|
||||
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
|
||||
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
|
||||
@@ -225,6 +225,8 @@ Bundled as ESM for Node 22+ into `dist/backend.js`. Backend imports of your own
|
||||
| `audit.record` | none | Write your own audit line |
|
||||
| `sync` | none | Sync your tables to a linked desktop |
|
||||
| `auth` | `auth:provide` | Login methods, second factors, SSH auth types |
|
||||
| `auth.issueUserToken` | `users:impersonate` | A short-lived session for calling Termix's API as a user |
|
||||
| `users.getLocale` | `users:read` | A user's UI language |
|
||||
| `system` | `system:tls` | The server's HTTPS certificate |
|
||||
| `plugins.list()` | `plugins:read` | Which plugins are installed |
|
||||
| `asUser(userId, fn)` | always audited | Run background work as a user |
|
||||
@@ -237,6 +239,13 @@ In a route the actor is the signed-in user, set by Termix. `ctx.currentActor()`
|
||||
work with no request (a timer, an event) uses `ctx.asUser(userId, fn)`, which is audited and still applies
|
||||
that user's permissions.
|
||||
|
||||
Background work that must call Termix's own HTTP API (the Termix CLI, say) asks for a session with
|
||||
`ctx.auth.issueUserToken(userId, { purpose, ttlSeconds })` and sends it as `Authorization: Bearer <token>`.
|
||||
It needs `users:impersonate` and `engine.api` `^1.3`, lasts 300 seconds by default and 900 at most, and is
|
||||
audited as `plugin_user_token_issued`. The user's permissions apply, and it is refused on routes that change
|
||||
their password, second factors, API keys or linked logins. `ctx.users.getLocale(userId)` gives the user's
|
||||
UI language, like `zh_CN`, or null, for writing to them in it.
|
||||
|
||||
### Routes
|
||||
|
||||
Routes live at `/plugin-api/<id>/`, sockets at `/plugin-ws/<id>/`, on the main Termix port. You never need
|
||||
@@ -754,37 +763,37 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
|
||||
|
||||
## Capabilities
|
||||
|
||||
| Capability | Risk | What it allows |
|
||||
| -------------------- | -------- | ------------------------------------------------------ |
|
||||
| `credentials:read` | critical | Read stored secrets for hosts the user can reach |
|
||||
| `system:tls` | critical | Replace the HTTPS certificate, answer CA domain checks |
|
||||
| `ssh:connect` | high | Run any command on hosts, with the user's access |
|
||||
| `process:spawn` | high | Run programs on the Termix server |
|
||||
| `users:write` | high | Add users and change their roles |
|
||||
| `users:impersonate` | high | Run background work as another user, audited |
|
||||
| `db:core-refs` | high | Query core users, roles and hosts tables (read only) |
|
||||
| `auth:provide` | high | Add login methods, second factors, SSH auth types |
|
||||
| `device:serial` | high | Open serial and USB ports on the server |
|
||||
| `notify:hub` | high | Receive every alert (the alerts hub only) |
|
||||
| `hosts:write` | medium | Add and edit hosts |
|
||||
| `credentials:use` | medium | Connect with saved credentials without seeing them |
|
||||
| `credentials:write` | medium | Save new credentials |
|
||||
| `network:outbound` | medium | Outbound HTTP through `ctx.fetch` |
|
||||
| `network:serve` | medium | HTTP routes and sockets |
|
||||
| `network:broadcast` | medium | Local network broadcasts, like Wake-on-LAN |
|
||||
| `users:read` | medium | Usernames and roles |
|
||||
| `events:core` | medium | Listen to core events |
|
||||
| `notify:send` | medium | Send alerts |
|
||||
| `audit:read` | medium | Read the audit log |
|
||||
| `desktop:window` | medium | Open windows in the desktop app |
|
||||
| `hosts:read` | low | Read hosts the user can see |
|
||||
| `db:own` | low | Your own tables |
|
||||
| `kv:own` | low | Small key value state |
|
||||
| `files:own` | low | Your own folder on disk |
|
||||
| `secrets:own` | low | Your own encrypted secrets |
|
||||
| `settings:read-core` | low | Read Termix server settings |
|
||||
| `plugins:read` | low | See installed plugins |
|
||||
| `ui:surface` | low | Add tabs, panels and settings |
|
||||
| Capability | Risk | What it allows |
|
||||
| -------------------- | -------- | ------------------------------------------------------- |
|
||||
| `credentials:read` | critical | Read stored secrets for hosts the user can reach |
|
||||
| `system:tls` | critical | Replace the HTTPS certificate, answer CA domain checks |
|
||||
| `ssh:connect` | high | Run any command on hosts, with the user's access |
|
||||
| `process:spawn` | high | Run programs on the Termix server |
|
||||
| `users:write` | high | Add users and change their roles |
|
||||
| `users:impersonate` | high | Act as another user, or get a session for them, audited |
|
||||
| `db:core-refs` | high | Query core users, roles and hosts tables (read only) |
|
||||
| `auth:provide` | high | Add login methods, second factors, SSH auth types |
|
||||
| `device:serial` | high | Open serial and USB ports on the server |
|
||||
| `notify:hub` | high | Receive every alert (the alerts hub only) |
|
||||
| `hosts:write` | medium | Add and edit hosts |
|
||||
| `credentials:use` | medium | Connect with saved credentials without seeing them |
|
||||
| `credentials:write` | medium | Save new credentials |
|
||||
| `network:outbound` | medium | Outbound HTTP through `ctx.fetch` |
|
||||
| `network:serve` | medium | HTTP routes and sockets |
|
||||
| `network:broadcast` | medium | Local network broadcasts, like Wake-on-LAN |
|
||||
| `users:read` | medium | Usernames, roles and UI language |
|
||||
| `events:core` | medium | Listen to core events |
|
||||
| `notify:send` | medium | Send alerts |
|
||||
| `audit:read` | medium | Read the audit log |
|
||||
| `desktop:window` | medium | Open windows in the desktop app |
|
||||
| `hosts:read` | low | Read hosts the user can see |
|
||||
| `db:own` | low | Your own tables |
|
||||
| `kv:own` | low | Small key value state |
|
||||
| `files:own` | low | Your own folder on disk |
|
||||
| `secrets:own` | low | Your own encrypted secrets |
|
||||
| `settings:read-core` | low | Read Termix server settings |
|
||||
| `plugins:read` | low | See installed plugins |
|
||||
| `ui:surface` | low | Add tabs, panels and settings |
|
||||
|
||||
An update that adds a capability waits for an admin to approve it.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@termix-ssh/plugin-sdk",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -1464,6 +1464,20 @@ export interface PluginSecondFactor {
|
||||
reset?: (userId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface PluginUserTokenOptions {
|
||||
/** Why the token exists, kept in the audit log and on the session. */
|
||||
purpose: string;
|
||||
/** Seconds until it expires. Defaults to 300 and is capped at 900. */
|
||||
ttlSeconds?: number;
|
||||
}
|
||||
|
||||
export interface PluginUserToken {
|
||||
/** Send as `Authorization: Bearer <token>` to core's own API. */
|
||||
token: string;
|
||||
/** ISO time the session ends. */
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Login methods, second factors and SSH auth types. Every register call needs
|
||||
* auth:provide and a matching contributes.auth entry, and is undone on
|
||||
@@ -1525,6 +1539,26 @@ export interface PluginAuth {
|
||||
* delete a provider people still sign in with.
|
||||
*/
|
||||
countLinkedUsers: (provider: string) => Promise<number>;
|
||||
/**
|
||||
* A short-lived session for an existing user, for calling core's own HTTP
|
||||
* API as them from work with no request behind it, like a scheduled task
|
||||
* driving the Termix CLI. The user's RBAC applies unchanged. Needs
|
||||
* users:impersonate, not auth:provide, and every call is audited.
|
||||
*
|
||||
* The session is marked with the plugin and purpose. It cannot change the
|
||||
* user's password, second factors, API keys or linked logins, so it can
|
||||
* never outlive itself.
|
||||
*/
|
||||
issueUserToken: (
|
||||
userId: string,
|
||||
options: PluginUserTokenOptions,
|
||||
) => Promise<PluginUserToken>;
|
||||
}
|
||||
|
||||
/** What a plugin may learn about a user. Needs users:read. */
|
||||
export interface PluginUsers {
|
||||
/** The user's UI language, like "zh_CN", or null when they never chose one. */
|
||||
getLocale: (userId: string) => Promise<string | null>;
|
||||
}
|
||||
|
||||
export interface PluginOpenIsolatedWindowRequest {
|
||||
@@ -2013,8 +2047,13 @@ export interface PluginContext {
|
||||
readonly hosts: PluginHosts;
|
||||
/** SSH through core's connect pipeline. Needs ssh:connect and credentials:use. */
|
||||
readonly ssh: PluginSsh;
|
||||
/** Login methods, second factors and SSH auth types. Needs auth:provide. */
|
||||
/**
|
||||
* Login methods, second factors and SSH auth types. Needs auth:provide,
|
||||
* except issueUserToken, which needs users:impersonate.
|
||||
*/
|
||||
readonly auth: PluginAuth;
|
||||
/** What a plugin may learn about a user. Needs users:read. */
|
||||
readonly users: PluginUsers;
|
||||
/** Opens Electron windows outside the main renderer. Needs desktop:window. */
|
||||
readonly desktop: PluginDesktop;
|
||||
/** Host protocol credentials and the user's saved SSH keys. See PluginCredentials. */
|
||||
@@ -2039,8 +2078,9 @@ export interface PluginContext {
|
||||
* no request behind it. Always audited, and every core API inside still
|
||||
* applies that user's RBAC.
|
||||
*
|
||||
* Needs users:impersonate. That and a `userId` option on services.get or
|
||||
* secrets.getShared are the only ways a plugin can name a user.
|
||||
* Needs users:impersonate. That, auth.issueUserToken and a `userId` option
|
||||
* on services.get or secrets.getShared are the only ways a plugin can act
|
||||
* as a user it names.
|
||||
*/
|
||||
asUser: <T>(userId: string, fn: () => Promise<T> | T) => Promise<T>;
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ export {
|
||||
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
|
||||
* version by the server.
|
||||
*/
|
||||
export const PLUGIN_API_VERSION = "1.2.0";
|
||||
export const PLUGIN_API_VERSION = "1.3.0";
|
||||
|
||||
/** The API major, the default engine.api for a new plugin. */
|
||||
export const SUPPORTED_PLUGIN_API_VERSION = String(
|
||||
|
||||
@@ -174,6 +174,8 @@ export interface FakeContextOptions {
|
||||
linkedUsers?: Record<string, number>;
|
||||
/** Failures before ctx.auth.loginRateLimit locks a key. Defaults to 5. */
|
||||
loginAttemptLimit?: number;
|
||||
/** What ctx.users.getLocale answers, by user id. A missing user answers null. */
|
||||
locales?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface FakeProcessOptions {
|
||||
@@ -247,6 +249,8 @@ export interface FakeAuthRegistrations {
|
||||
}>;
|
||||
/** Failures recorded through ctx.auth.loginRateLimit, by "<ip>|<key>". */
|
||||
loginFailures: Map<string, number>;
|
||||
/** Every ctx.auth.issueUserToken call, in order. */
|
||||
userTokens: Array<{ userId: string; purpose: string; ttlSeconds?: number }>;
|
||||
}
|
||||
|
||||
export interface FakePluginContext {
|
||||
@@ -574,6 +578,7 @@ export function createFakeContext(
|
||||
completedLogins: [],
|
||||
revokedSessions: [],
|
||||
loginFailures: new Map(),
|
||||
userTokens: [],
|
||||
};
|
||||
const sshClient = options.sshClient ?? {};
|
||||
let actor = options.actor;
|
||||
@@ -1140,6 +1145,18 @@ export function createFakeContext(
|
||||
},
|
||||
countLinkedUsers: async (provider) =>
|
||||
options.linkedUsers?.[provider] ?? 0,
|
||||
issueUserToken: async (userId, tokenOptions) => {
|
||||
auth.userTokens.push({ userId, ...tokenOptions });
|
||||
const ttl = Math.min(tokenOptions.ttlSeconds ?? 300, 900);
|
||||
return {
|
||||
token: `fake-token-${auth.userTokens.length}`,
|
||||
expiresAt: new Date(Date.now() + ttl * 1000).toISOString(),
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
users: {
|
||||
getLocale: async (userId) => options.locales?.[userId] ?? null,
|
||||
},
|
||||
|
||||
desktop: {
|
||||
@@ -1442,6 +1459,8 @@ export interface MockContextOptions {
|
||||
linkedUsers?: Record<string, number>;
|
||||
/** See FakeContextOptions. */
|
||||
loginAttemptLimit?: number;
|
||||
/** See FakeContextOptions. */
|
||||
locales?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface MockPluginContext extends FakePluginContext {
|
||||
@@ -1458,7 +1477,8 @@ export interface MockPluginContext extends FakePluginContext {
|
||||
* topic outside the plugin's own namespace, settings:read-core on
|
||||
* ctx.settings.readCore, ssh:connect plus credentials:use on ctx.ssh,
|
||||
* notify:send on ctx.notify (notify:hub on serve), network:outbound on ctx.fetch, process:spawn on
|
||||
* ctx.process, system:tls on ctx.system, plugins:read on ctx.plugins, and auth:provide on ctx.auth. Reading a plugin's own settings is deliberately
|
||||
* ctx.process, system:tls on ctx.system, plugins:read on ctx.plugins, auth:provide on ctx.auth
|
||||
* (users:impersonate on issueUserToken), and users:read on ctx.users. Reading a plugin's own settings is deliberately
|
||||
* ungated. As the SDK grows a member, add its gate here in the same shape.
|
||||
*/
|
||||
export function createMockCtx(
|
||||
@@ -1495,6 +1515,7 @@ export function createMockCtx(
|
||||
baseUrl: options.baseUrl,
|
||||
linkedUsers: options.linkedUsers,
|
||||
loginAttemptLimit: options.loginAttemptLimit,
|
||||
locales: options.locales,
|
||||
manifest: {
|
||||
capabilities: options.capabilities ?? [],
|
||||
...options.manifest,
|
||||
@@ -1786,6 +1807,17 @@ export function createMockCtx(
|
||||
require("auth:provide");
|
||||
return ctx.auth.countLinkedUsers(provider);
|
||||
},
|
||||
issueUserToken: async (userId, tokenOptions) => {
|
||||
require("users:impersonate");
|
||||
return ctx.auth.issueUserToken(userId, tokenOptions);
|
||||
},
|
||||
},
|
||||
|
||||
users: {
|
||||
getLocale: async (userId) => {
|
||||
require("users:read");
|
||||
return ctx.users.getLocale(userId);
|
||||
},
|
||||
},
|
||||
|
||||
desktop: {
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { getActor, getActorSessionId } from "./actor.js";
|
||||
import type { DisposableBag } from "./disposables.js";
|
||||
import { redactHostSecrets } from "./ctx-hosts.js";
|
||||
import { issueUserToken } from "./ctx-users.js";
|
||||
import {
|
||||
getSshAuthProvider,
|
||||
listSshAuthProviders,
|
||||
@@ -901,5 +902,7 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
|
||||
await import("../database/repositories/factory.js");
|
||||
return createCurrentUserAuthRepository().countUsersForProvider(provider);
|
||||
},
|
||||
issueUserToken: (userId, options) =>
|
||||
issueUserToken(manifest, userId, options),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* ctx.users, and ctx.auth.issueUserToken: what a plugin may learn about a
|
||||
* user, and a short-lived session for calling core's own API as one.
|
||||
*/
|
||||
|
||||
import type {
|
||||
PluginUserToken,
|
||||
PluginUserTokenOptions,
|
||||
PluginUsers,
|
||||
} from "@termix-ssh/plugin-sdk/backend";
|
||||
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
|
||||
import { assertCapability } from "./permissions.js";
|
||||
import { getActor } from "./actor.js";
|
||||
|
||||
const DEFAULT_TOKEN_TTL_SECONDS = 300;
|
||||
const MAX_TOKEN_TTL_SECONDS = 900;
|
||||
const MAX_PURPOSE_LENGTH = 200;
|
||||
|
||||
export function createPluginUsers(manifest: PluginManifest): PluginUsers {
|
||||
return {
|
||||
getLocale: async (userId) => {
|
||||
await assertCapability(
|
||||
manifest.id,
|
||||
"users:read",
|
||||
manifest.capabilities,
|
||||
"users_get_locale",
|
||||
);
|
||||
const { createCurrentUserPreferenceRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const preferences =
|
||||
await createCurrentUserPreferenceRepository().findByUserId(userId);
|
||||
return preferences?.language ?? null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function issueUserToken(
|
||||
manifest: PluginManifest,
|
||||
userId: string,
|
||||
options: PluginUserTokenOptions,
|
||||
): Promise<PluginUserToken> {
|
||||
await assertCapability(
|
||||
manifest.id,
|
||||
"users:impersonate",
|
||||
manifest.capabilities,
|
||||
"user_token_issued",
|
||||
);
|
||||
const { logAudit } = await import("../utils/audit-logger.js");
|
||||
const purpose = String(options?.purpose ?? "")
|
||||
.trim()
|
||||
.slice(0, MAX_PURPOSE_LENGTH);
|
||||
const ttlSeconds = Math.min(
|
||||
options?.ttlSeconds ?? DEFAULT_TOKEN_TTL_SECONDS,
|
||||
MAX_TOKEN_TTL_SECONDS,
|
||||
);
|
||||
const audit = (success: boolean, errorMessage?: string) =>
|
||||
logAudit({
|
||||
userId: getActor() ?? null,
|
||||
username: `plugin:${manifest.id}`,
|
||||
action: "plugin_user_token_issued",
|
||||
resourceType: "user",
|
||||
resourceId: userId,
|
||||
details: JSON.stringify({ pluginId: manifest.id, purpose, ttlSeconds }),
|
||||
success,
|
||||
errorMessage,
|
||||
});
|
||||
|
||||
try {
|
||||
if (!purpose) {
|
||||
throw new Error("ctx.auth.issueUserToken needs a purpose");
|
||||
}
|
||||
if (!Number.isInteger(ttlSeconds) || ttlSeconds <= 0) {
|
||||
throw new Error("ttlSeconds must be a positive whole number");
|
||||
}
|
||||
const { createCurrentUserRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const user = userId
|
||||
? await createCurrentUserRepository().findById(userId)
|
||||
: null;
|
||||
if (!user) throw new Error(`User "${userId}" does not exist`);
|
||||
|
||||
const { AuthManager } = await import("../utils/auth-manager.js");
|
||||
const issued = await AuthManager.getInstance().issuePluginSession(user.id, {
|
||||
pluginId: manifest.id,
|
||||
purpose,
|
||||
ttlSeconds,
|
||||
});
|
||||
await audit(true);
|
||||
return issued;
|
||||
} catch (error) {
|
||||
await audit(false, error instanceof Error ? error.message : String(error));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,7 @@ import { createPluginFetch, createPluginNotify } from "./ctx-notify.js";
|
||||
import { createPluginProcess } from "./ctx-process.js";
|
||||
import { createPluginSystem } from "./ctx-system.js";
|
||||
import { createPluginPlugins } from "./ctx-plugins.js";
|
||||
import { createPluginUsers } from "./ctx-users.js";
|
||||
import { createPluginCredentials } from "./ctx-credentials.js";
|
||||
import { isElectronIpcAvailable } from "../utils/electron-ipc-bridge.js";
|
||||
|
||||
@@ -957,6 +958,7 @@ export function createPluginContext(
|
||||
auth: createPluginAuth({ manifest, bag: handle.bag, audit: auditCall }),
|
||||
system: createPluginSystem({ manifest, bag: handle.bag, audit: auditCall }),
|
||||
plugins: createPluginPlugins(manifest),
|
||||
users: createPluginUsers(manifest),
|
||||
|
||||
desktop: {
|
||||
openIsolatedWindow: (request) => desktopOpenIsolatedWindow(request),
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* ctx.auth.issueUserToken mints a short-lived session core's own auth
|
||||
* middleware accepts as the named user, under users:impersonate, audited and
|
||||
* kept off account security routes. ctx.users.getLocale reads the user's UI
|
||||
* language under users:read.
|
||||
*/
|
||||
|
||||
import crypto from "crypto";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
grants: [] as string[],
|
||||
users: new Map<string, { id: string; username: string; isAdmin: boolean }>(),
|
||||
sessions: new Map<string, Record<string, unknown>>(),
|
||||
languages: new Map<string, string | null>(),
|
||||
audits: [] as Array<Record<string, unknown>>,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({
|
||||
db: {},
|
||||
getDb: () => ({}),
|
||||
saveMemoryDatabaseToFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginPermissionGrantRepository: () => ({
|
||||
listByPlugin: async (pluginId: string) =>
|
||||
state.grants.map((capability) => ({ pluginId, capability })),
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({ get: async () => null }),
|
||||
createCurrentSessionRepository: () => ({
|
||||
create: async (session: Record<string, unknown>) => {
|
||||
state.sessions.set(session.id as string, session);
|
||||
return session;
|
||||
},
|
||||
findById: async (id: string) => state.sessions.get(id) ?? null,
|
||||
touch: async () => {},
|
||||
revoke: async (id: string) => state.sessions.delete(id),
|
||||
}),
|
||||
createCurrentUserRepository: () => ({
|
||||
findById: async (id: string) => state.users.get(id) ?? null,
|
||||
}),
|
||||
createCurrentUserPreferenceRepository: () => ({
|
||||
findByUserId: async (userId: string) =>
|
||||
state.languages.has(userId)
|
||||
? { userId, language: state.languages.get(userId) }
|
||||
: null,
|
||||
}),
|
||||
createCurrentApiKeyRepository: () => ({}),
|
||||
createCurrentTrustedDeviceRepository: () => ({}),
|
||||
getCurrentSettingValue: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/audit-logger.js", () => ({
|
||||
logAudit: async (entry: Record<string, unknown>) => {
|
||||
state.audits.push(entry);
|
||||
},
|
||||
getRequestMeta: () => ({ ipAddress: "", userAgent: "" }),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/system-crypto.js", () => ({
|
||||
SystemCrypto: {
|
||||
getInstance: () => ({
|
||||
getJWTSecret: async () => "a".repeat(64),
|
||||
getEncryptionKey: async () => crypto.randomBytes(32),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/user-keys.js", () => ({
|
||||
UserKeyManager: {
|
||||
getInstance: () => ({
|
||||
hasUserDEK: () => true,
|
||||
tryGetUserDEK: () => null,
|
||||
invalidate: vi.fn(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/crypto-migration/dek-migration.js", () => ({
|
||||
adoptRecoveredDEK: vi.fn(async () => {}),
|
||||
migratePasswordUserAtLogin: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
const { createPluginContext, createPluginHandle } =
|
||||
await import("../../plugins/ctx.js");
|
||||
const { invalidatePluginPermissionCache } =
|
||||
await import("../../plugins/permissions.js");
|
||||
const { AuthManager } = await import("../../utils/auth-manager.js");
|
||||
const { registerSecondFactor, resetAuthRegistryForTests } =
|
||||
await import("../../auth/registry.js");
|
||||
const { PluginCapabilityError } =
|
||||
await import("@termix-ssh/plugin-sdk/backend");
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticate = authManager.createAuthMiddleware();
|
||||
const requireAdmin = authManager.createAdminMiddleware();
|
||||
|
||||
function contextFor(capabilities: string[], grants = capabilities) {
|
||||
state.grants = grants;
|
||||
const manifest = {
|
||||
id: "ai",
|
||||
name: "AI",
|
||||
version: "1.0.0",
|
||||
capabilities,
|
||||
} as never;
|
||||
return createPluginContext(manifest, createPluginHandle("ai", {} as never));
|
||||
}
|
||||
|
||||
type Middleware = typeof authenticate;
|
||||
|
||||
async function call(middleware: Middleware, token: string, url: string) {
|
||||
const req = {
|
||||
cookies: {},
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
method: "POST",
|
||||
originalUrl: url,
|
||||
url,
|
||||
} as unknown as Parameters<Middleware>[0] & { userId?: string };
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
body: null as unknown,
|
||||
status(code: number) {
|
||||
res.statusCode = code;
|
||||
return res;
|
||||
},
|
||||
json(body: unknown) {
|
||||
res.body = body;
|
||||
return res;
|
||||
},
|
||||
clearCookie() {
|
||||
return res;
|
||||
},
|
||||
};
|
||||
let passed = false;
|
||||
await middleware(req, res as never, () => {
|
||||
passed = true;
|
||||
});
|
||||
return { passed, status: res.statusCode, body: res.body, userId: req.userId };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.users = new Map([
|
||||
["u1", { id: "u1", username: "alice", isAdmin: false }],
|
||||
["admin", { id: "admin", username: "root", isAdmin: true }],
|
||||
]);
|
||||
state.sessions.clear();
|
||||
state.languages.clear();
|
||||
state.audits = [];
|
||||
invalidatePluginPermissionCache();
|
||||
resetAuthRegistryForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("ctx.auth.issueUserToken", () => {
|
||||
it("mints a session core's auth middleware accepts as that user", async () => {
|
||||
const ctx = contextFor(["users:impersonate"]);
|
||||
|
||||
const { token, expiresAt } = await ctx.auth.issueUserToken("u1", {
|
||||
purpose: "nightly report",
|
||||
});
|
||||
const result = await call(authenticate, token, "/host/db/host");
|
||||
|
||||
expect(result.passed).toBe(true);
|
||||
expect(result.userId).toBe("u1");
|
||||
const session = [...state.sessions.values()][0];
|
||||
expect(session).toMatchObject({
|
||||
userId: "u1",
|
||||
deviceType: "plugin",
|
||||
deviceInfo: "ai: nightly report",
|
||||
expiresAt,
|
||||
});
|
||||
const payload = jwt.decode(token) as { exp: number; iat: number };
|
||||
expect(payload.exp - payload.iat).toBe(300);
|
||||
});
|
||||
|
||||
it("keeps the user's own permissions", async () => {
|
||||
const ctx = contextFor(["users:impersonate"]);
|
||||
const user = await ctx.auth.issueUserToken("u1", { purpose: "check" });
|
||||
const admin = await ctx.auth.issueUserToken("admin", { purpose: "check" });
|
||||
|
||||
const refused = await call(requireAdmin, user.token, "/users/list");
|
||||
expect(refused.passed).toBe(false);
|
||||
expect(refused.status).toBe(403);
|
||||
expect((await call(requireAdmin, admin.token, "/users/list")).passed).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("caps the lifetime at 900 seconds", async () => {
|
||||
const ctx = contextFor(["users:impersonate"]);
|
||||
|
||||
const { token, expiresAt } = await ctx.auth.issueUserToken("u1", {
|
||||
purpose: "long task",
|
||||
ttlSeconds: 86_400,
|
||||
});
|
||||
|
||||
const payload = jwt.decode(token) as { exp: number; iat: number };
|
||||
expect(payload.exp - payload.iat).toBe(900);
|
||||
expect(new Date(expiresAt).getTime() - Date.now()).toBeLessThanOrEqual(
|
||||
900_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("stops working when the session expires", async () => {
|
||||
const ctx = contextFor(["users:impersonate"]);
|
||||
const { token } = await ctx.auth.issueUserToken("u1", {
|
||||
purpose: "short",
|
||||
ttlSeconds: 60,
|
||||
});
|
||||
|
||||
vi.useFakeTimers({ now: Date.now() + 61_000 });
|
||||
const result = await call(authenticate, token, "/host/db/host");
|
||||
|
||||
expect(result.passed).toBe(false);
|
||||
expect(result.status).toBe(401);
|
||||
});
|
||||
|
||||
it("audits every issue with the plugin, the user and the purpose", async () => {
|
||||
const ctx = contextFor(["users:impersonate"]);
|
||||
|
||||
await ctx.auth.issueUserToken("u1", { purpose: "nightly report" });
|
||||
|
||||
const entry = state.audits.find(
|
||||
(audit) => audit.action === "plugin_user_token_issued",
|
||||
);
|
||||
expect(entry).toMatchObject({
|
||||
username: "plugin:ai",
|
||||
resourceType: "user",
|
||||
resourceId: "u1",
|
||||
success: true,
|
||||
});
|
||||
expect(JSON.parse(entry!.details as string)).toEqual({
|
||||
pluginId: "ai",
|
||||
purpose: "nightly report",
|
||||
ttlSeconds: 300,
|
||||
});
|
||||
});
|
||||
|
||||
it("is refused without a granted users:impersonate", async () => {
|
||||
for (const ctx of [
|
||||
contextFor([]),
|
||||
contextFor(["users:impersonate"], []),
|
||||
contextFor(["users:read"]),
|
||||
]) {
|
||||
await expect(
|
||||
ctx.auth.issueUserToken("u1", { purpose: "x" }),
|
||||
).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||
}
|
||||
expect(state.sessions.size).toBe(0);
|
||||
expect(state.audits.every((audit) => audit.success === false)).toBe(true);
|
||||
});
|
||||
|
||||
it("is refused for a user that does not exist, or without a purpose", async () => {
|
||||
const ctx = contextFor(["users:impersonate"]);
|
||||
|
||||
await expect(
|
||||
ctx.auth.issueUserToken("ghost", { purpose: "x" }),
|
||||
).rejects.toThrow(/does not exist/);
|
||||
await expect(
|
||||
ctx.auth.issueUserToken("u1", { purpose: " " }),
|
||||
).rejects.toThrow(/purpose/);
|
||||
expect(state.sessions.size).toBe(0);
|
||||
expect(
|
||||
state.audits.filter(
|
||||
(audit) =>
|
||||
audit.action === "plugin_user_token_issued" && !audit.success,
|
||||
),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("cannot change how the user signs in or mint lasting credentials", async () => {
|
||||
registerSecondFactor({ id: "totp", pluginId: "totp" } as never);
|
||||
const ctx = contextFor(["users:impersonate"]);
|
||||
const { token } = await ctx.auth.issueUserToken("admin", {
|
||||
purpose: "ops",
|
||||
});
|
||||
|
||||
for (const url of [
|
||||
"/users/change-password",
|
||||
"/users/link-external-to-password",
|
||||
"/users/admin/reset-password",
|
||||
"/users/admin/u1/second-factors",
|
||||
"/plugin-api/totp/setup",
|
||||
]) {
|
||||
const result = await call(authenticate, token, url);
|
||||
expect(result.passed, url).toBe(false);
|
||||
expect(result.body).toMatchObject({ code: "PLUGIN_SESSION_NOT_ALLOWED" });
|
||||
}
|
||||
const apiKeys = await call(requireAdmin, token, "/users/api-keys");
|
||||
expect(apiKeys.passed).toBe(false);
|
||||
expect(apiKeys.body).toMatchObject({ code: "PLUGIN_SESSION_NOT_ALLOWED" });
|
||||
expect(
|
||||
(await call(authenticate, token, "/plugin-api/other/run")).passed,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves a normal login session free to use those routes", async () => {
|
||||
const token = await authManager.generateJWTToken("u1", {
|
||||
deviceType: "web",
|
||||
deviceInfo: "Chrome",
|
||||
});
|
||||
|
||||
expect(
|
||||
(await call(authenticate, token, "/users/change-password")).passed,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.users.getLocale", () => {
|
||||
it("returns the stored language, or null", async () => {
|
||||
state.languages.set("u1", "zh_CN");
|
||||
state.languages.set("admin", null);
|
||||
const ctx = contextFor(["users:read"]);
|
||||
|
||||
await expect(ctx.users.getLocale("u1")).resolves.toBe("zh_CN");
|
||||
await expect(ctx.users.getLocale("admin")).resolves.toBeNull();
|
||||
await expect(ctx.users.getLocale("nobody")).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("is refused without a granted users:read", async () => {
|
||||
state.languages.set("u1", "zh_CN");
|
||||
|
||||
await expect(
|
||||
contextFor(["users:read"], []).users.getLocale("u1"),
|
||||
).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||
await expect(
|
||||
contextFor(["users:impersonate"]).users.getLocale("u1"),
|
||||
).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import bcrypt from "bcryptjs";
|
||||
import { nanoid } from "nanoid";
|
||||
import { inArray, isNotNull } from "drizzle-orm";
|
||||
import type { ExternalSessionRef } from "../auth/types.js";
|
||||
import { listSecondFactors } from "../auth/registry.js";
|
||||
import type { DeviceType } from "./user-agent-parser.js";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { sessions } from "../database/db/schema.js";
|
||||
@@ -44,6 +45,11 @@ interface JWTPayload {
|
||||
pendingTOTP?: boolean;
|
||||
/** Set on a short-lived socket ticket, which only a socket upgrade takes. */
|
||||
purpose?: "ws";
|
||||
/**
|
||||
* A plugin minted the session. Read from the session row on verify, never
|
||||
* from the token, so a refreshed token stays marked.
|
||||
*/
|
||||
pluginSession?: boolean;
|
||||
dataKeyWrap?: WrappedDataKey;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
@@ -103,6 +109,26 @@ const IMPERSONATION_PATH_ALLOWLIST = [/^\/host\/db\//, /^\/credentials(\/|$)/];
|
||||
|
||||
const PLUGIN_API_PATH = /^\/plugin-api\/([a-z][a-z0-9-]*)(\/|$)/;
|
||||
|
||||
/** sessions.device_type of a session a plugin minted with ctx.auth.issueUserToken. */
|
||||
export const PLUGIN_SESSION_DEVICE_TYPE = "plugin";
|
||||
|
||||
// Routes that change how a user signs in or mint a credential that outlives
|
||||
// the session. A plugin-minted session is refused on them, and on every
|
||||
// route of a plugin that provides a second factor.
|
||||
const PLUGIN_SESSION_BLOCKLIST = [
|
||||
/^\/users\/(change-password|api-keys|link-external-to-password|unlink-external-from-password|admin\/reset-password)(\/|$)/,
|
||||
/^\/users\/admin\/[^/]+\/second-factors(\/|$)/,
|
||||
];
|
||||
|
||||
/** Whether a session a plugin minted may call this path. */
|
||||
export function allowsPluginSession(path: string): boolean {
|
||||
if (PLUGIN_SESSION_BLOCKLIST.some((pattern) => pattern.test(path))) {
|
||||
return false;
|
||||
}
|
||||
const plugin = PLUGIN_API_PATH.exec(path)?.[1];
|
||||
return !listSecondFactors().some((factor) => factor.pluginId === plugin);
|
||||
}
|
||||
|
||||
let pluginAllowsImpersonation: (pluginId: string) => boolean = () => false;
|
||||
|
||||
/** Set by the plugin runtime: whether a running plugin opted in. */
|
||||
@@ -407,6 +433,38 @@ class AuthManager {
|
||||
} as jwt.SignOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* A session a plugin mints for a user, to call core's API as them. It is
|
||||
* an ordinary session row, so it verifies, expires and shows up like a
|
||||
* login, marked with the plugin and why.
|
||||
*/
|
||||
async issuePluginSession(
|
||||
userId: string,
|
||||
options: { pluginId: string; purpose: string; ttlSeconds: number },
|
||||
): Promise<{ token: string; expiresAt: string }> {
|
||||
const sessionId = nanoid();
|
||||
const token = jwt.sign(
|
||||
{ userId, sessionId } satisfies JWTPayload,
|
||||
await this.systemCrypto.getJWTSecret(),
|
||||
{ expiresIn: options.ttlSeconds },
|
||||
);
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(
|
||||
now.getTime() + options.ttlSeconds * 1000,
|
||||
).toISOString();
|
||||
await createCurrentSessionRepository().create({
|
||||
id: sessionId,
|
||||
userId,
|
||||
jwtToken: token,
|
||||
deviceType: PLUGIN_SESSION_DEVICE_TYPE,
|
||||
deviceInfo: `${options.pluginId}: ${options.purpose}`,
|
||||
createdAt: now.toISOString(),
|
||||
expiresAt,
|
||||
lastActiveAt: now.toISOString(),
|
||||
});
|
||||
return { token, expiresAt };
|
||||
}
|
||||
|
||||
async verifyJWTToken(
|
||||
token: string,
|
||||
options: { allowSocketTicket?: boolean } = {},
|
||||
@@ -440,6 +498,8 @@ class AuthManager {
|
||||
if (new Date(sessionRecord.expiresAt).getTime() < Date.now()) {
|
||||
return null;
|
||||
}
|
||||
payload.pluginSession =
|
||||
sessionRecord.deviceType === PLUGIN_SESSION_DEVICE_TYPE;
|
||||
|
||||
await this.migrateDataKeyFromPayload(payload);
|
||||
} catch (dbError) {
|
||||
@@ -787,6 +847,8 @@ class AuthManager {
|
||||
});
|
||||
}
|
||||
|
||||
if (this.refusePluginSession(req, res, payload)) return;
|
||||
|
||||
if (payload.sessionId) {
|
||||
try {
|
||||
const sessionRepository = createCurrentSessionRepository();
|
||||
@@ -953,6 +1015,21 @@ class AuthManager {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Sends the 403 and returns true when a plugin session may not use this route. */
|
||||
private refusePluginSession(
|
||||
req: Request,
|
||||
res: Response,
|
||||
payload: JWTPayload,
|
||||
): boolean {
|
||||
const path = (req.originalUrl || req.url || "").split("?")[0];
|
||||
if (!payload.pluginSession || allowsPluginSession(path)) return false;
|
||||
res.status(403).json({
|
||||
error: "A session a plugin issued cannot use this route",
|
||||
code: "PLUGIN_SESSION_NOT_ALLOWED",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
createDataAccessMiddleware() {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
@@ -1007,6 +1084,8 @@ class AuthManager {
|
||||
});
|
||||
}
|
||||
|
||||
if (this.refusePluginSession(req, res, payload)) return;
|
||||
|
||||
try {
|
||||
const user = await createCurrentUserRepository().findById(
|
||||
payload.userId,
|
||||
|
||||
Reference in new issue
Block a user