diff --git a/package-lock.json b/package-lock.json index 76ad8760e..f80abb5f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/plugin-sdk/CHANGELOG.md b/packages/plugin-sdk/CHANGELOG.md index 8ef1c7ed5..08db60eba 100644 --- a/packages/plugin-sdk/CHANGELOG.md +++ b/packages/plugin-sdk/CHANGELOG.md @@ -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 diff --git a/packages/plugin-sdk/PLUGIN-GUIDE.md b/packages/plugin-sdk/PLUGIN-GUIDE.md index 9c219fed9..db1c6a21a 100644 --- a/packages/plugin-sdk/PLUGIN-GUIDE.md +++ b/packages/plugin-sdk/PLUGIN-GUIDE.md @@ -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 `. +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//`, sockets at `/plugin-ws//`, 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. diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index d3475798c..ca163ae7f 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -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": { diff --git a/packages/plugin-sdk/src/backend.ts b/packages/plugin-sdk/src/backend.ts index 19c1bf0dd..e0d27c189 100644 --- a/packages/plugin-sdk/src/backend.ts +++ b/packages/plugin-sdk/src/backend.ts @@ -1464,6 +1464,20 @@ export interface PluginSecondFactor { reset?: (userId: string) => Promise; } +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 ` 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; + /** + * 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; +} + +/** 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; } 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: (userId: string, fn: () => Promise | T) => Promise; diff --git a/packages/plugin-sdk/src/manifest.ts b/packages/plugin-sdk/src/manifest.ts index 8732e8173..c60138b44 100644 --- a/packages/plugin-sdk/src/manifest.ts +++ b/packages/plugin-sdk/src/manifest.ts @@ -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( diff --git a/packages/plugin-sdk/src/testing.ts b/packages/plugin-sdk/src/testing.ts index 4b8868002..4515a62ea 100644 --- a/packages/plugin-sdk/src/testing.ts +++ b/packages/plugin-sdk/src/testing.ts @@ -174,6 +174,8 @@ export interface FakeContextOptions { linkedUsers?: Record; /** 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; } export interface FakeProcessOptions { @@ -247,6 +249,8 @@ export interface FakeAuthRegistrations { }>; /** Failures recorded through ctx.auth.loginRateLimit, by "|". */ loginFailures: Map; + /** 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; /** See FakeContextOptions. */ loginAttemptLimit?: number; + /** See FakeContextOptions. */ + locales?: Record; } 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: { diff --git a/src/backend/plugins/ctx-ssh-auth.ts b/src/backend/plugins/ctx-ssh-auth.ts index bc67adc59..36d64401c 100644 --- a/src/backend/plugins/ctx-ssh-auth.ts +++ b/src/backend/plugins/ctx-ssh-auth.ts @@ -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), }; } diff --git a/src/backend/plugins/ctx-users.ts b/src/backend/plugins/ctx-users.ts new file mode 100644 index 000000000..7794cec07 --- /dev/null +++ b/src/backend/plugins/ctx-users.ts @@ -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 { + 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; + } +} diff --git a/src/backend/plugins/ctx.ts b/src/backend/plugins/ctx.ts index 81334536a..eac8156fc 100644 --- a/src/backend/plugins/ctx.ts +++ b/src/backend/plugins/ctx.ts @@ -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), diff --git a/src/backend/tests/plugins/ctx-users.test.ts b/src/backend/tests/plugins/ctx-users.test.ts new file mode 100644 index 000000000..e814f0080 --- /dev/null +++ b/src/backend/tests/plugins/ctx-users.test.ts @@ -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(), + sessions: new Map>(), + languages: new Map(), + audits: [] as Array>, +})); + +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) => { + 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) => { + 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[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); + }); +}); diff --git a/src/backend/utils/auth-manager.ts b/src/backend/utils/auth-manager.ts index cfa26475d..0c6eccabc 100644 --- a/src/backend/utils/auth-manager.ts +++ b/src/backend/utils/auth-manager.ts @@ -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,