diff --git a/DESIGN-inline-policy.md b/DESIGN-inline-policy.md index 4f21d28..7408d59 100644 --- a/DESIGN-inline-policy.md +++ b/DESIGN-inline-policy.md @@ -217,10 +217,10 @@ consumers: **Behavior:** -- **No `consumers` section (default):** Inline policy is allowed for all callers. This preserves frictionless operation for single-user local deployments. -- **`consumers` section present:** Only callers that pass a valid token via the `x-abbenay-token` gRPC metadata header and whose consumer entry has `inline_policy: true` can use inline policy. Unauthorized requests with an inline policy receive `PERMISSION_DENIED`. +- **No `consumers` section on localhost / unix socket (default):** Inline policy is allowed for all callers (local DX). Non-loopback binds without consumers fail closed (see DR-037). +- **`consumers` section present:** Callers need `chat` plus `inline_policy`, with a valid `x-abbenay-token`. Unauthorized requests receive `PERMISSION_DENIED`. -The consumer model provides per-app granularity — the admin can trust APME without trusting all Python clients. Token-based auth was chosen over client-type gating for this reason (see DR-024). +The consumer model provides per-app granularity — the admin can trust APME without trusting all Python clients. Token-based auth was chosen over client-type gating for this reason (see DR-024 / DR-037). ## Alternatives Considered diff --git a/config.container.example.yaml b/config.container.example.yaml index 5320f37..d1d7d5a 100644 --- a/config.container.example.yaml +++ b/config.container.example.yaml @@ -69,12 +69,19 @@ providers: # models: # llama3.2: {} -# ── Consumer authentication (optional) ──────────────────────────────── -# Uncomment to require tokens for programmatic clients. +# ── Consumer authentication (required for non-loopback gRPC) ────────── +# Binding --grpc-host 0.0.0.0 (container default) refuses to start without +# a consumers section unless --allow-open-auth / --insecure is passed. +# Pass the token env when starting the container, e.g. -e APME_TOKEN=... -# consumers: -# apme: -# token_env: "APME_TOKEN" -# capabilities: -# inline_policy: true -# mcp_register: true +consumers: + apme: + token_env: "APME_TOKEN" + capabilities: + chat: true + inline_policy: true + mcp_register: true + secrets: true + config: true + providers: true + shutdown: true diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index be4a2a9..0f3bb49 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -185,6 +185,53 @@ via `/api/mcp/connections`. Pending connection consents and tool approvals auto-deny after **5 minutes** if the user never responds, so abandoned `initialize` / `tools/call` requests cannot leak entries in the pending maps. +### Consumer authentication (`consumers`) — DR-037 + +Named consumer applications authenticate to gRPC with a token in the +`x-abbenay-token` metadata header. Each consumer is granted a capability +matrix; sensitive RPCs require both a matching token and the relevant flag. + +```yaml +consumers: + apme: + token_env: "APME_TOKEN" # env var holding the consumer token + # token_keychain: "APME_TOKEN" # future: keychain-backed token + capabilities: + chat: true # Chat / SessionChat / SummarizeSession + inline_policy: true # PolicyConfig on chat requests + mcp_register: true # RegisterMcpServer / UnregisterMcpServer + secrets: true # Get/Set/Delete/ListSecret, GetKeyStatus + config: true # Get/UpdateConfig, policy CRUD, web start/stop + providers: true # ConfigureProvider / RemoveProvider / DiscoverModels + shutdown: true # Shutdown RPC +``` + +| Capability | Gated RPCs | +|------------|------------| +| `chat` | `Chat`, `SessionChat`, `SummarizeSession` | +| `inline_policy` | Inline `PolicyConfig` on chat (also needs `chat`) | +| `mcp_register` | `RegisterMcpServer`, `UnregisterMcpServer`, `ReconnectMcpServer` | +| `secrets` | `GetSecret`, `SetSecret`, `DeleteSecret`, `ListSecrets`, `GetKeyStatus` | +| `config` | `GetConfig`, `UpdateConfig`, `CreatePolicy`, `DeletePolicy`, `StartWebServer`, `StopWebServer` | +| `providers` | `ConfigureProvider`, `RemoveProvider`, `DiscoverModels` | +| `shutdown` | `Shutdown` | + +**Behavior:** + +| Bind / config | Empty `consumers` | `consumers` configured | +|---------------|-------------------|------------------------| +| Unix socket or loopback TCP (`127.0.0.1`, `::1`) | Allow-all (local DX) | Token + capability required for sensitive RPCs | +| Non-loopback TCP (`0.0.0.0`, LAN IP, …) | **Refuse to start** unless `--allow-open-auth` or `--insecure` | Token + capability required | + +Token comparison uses `crypto.timingSafeEqual` (equal-length buffers). Wrong +or missing tokens receive `PERMISSION_DENIED`. Health/status/list discovery +RPCs stay ungated so probes and local tooling keep working. + +> **WARNING — open auth:** `--allow-open-auth` (or `--insecure`, which implies +> it) on a non-loopback bind restores allow-all when `consumers` is empty. +> Prefer configuring consumers. HTTP API auth is separate (`server` / Bearer); +> see above. + ### Session ownership Every session is stamped with an `owner` principal: diff --git a/docs/CONTAINER.md b/docs/CONTAINER.md index 7f1b743..999c98c 100644 --- a/docs/CONTAINER.md +++ b/docs/CONTAINER.md @@ -105,7 +105,9 @@ podman run -d --name abbenay \ abbenay:latest ``` -With consumer authentication (for programmatic clients like APME): +Consumer authentication is **required** for the default image CMD (`--grpc-host +0.0.0.0`). Use `config.container.example.yaml` (includes a `consumers` section) +and pass the consumer token env: ```bash podman run -d --name abbenay \ @@ -118,6 +120,9 @@ podman run -d --name abbenay \ abbenay:latest ``` +Without a `consumers` section the daemon refuses to start on `0.0.0.0` unless +you pass `--allow-open-auth` or `--insecure` (not recommended). + ### Verify it's running ```bash @@ -416,6 +421,12 @@ when connecting by IP). ### Insecure tradeoffs `--insecure` on `0.0.0.0` restores the old plaintext behavior. API keys, chat, -provider config, and tools travel unencrypted. Prefer `--grpc-tls`. Always -configure a `consumers` section in `config.yaml` when exposing gRPC beyond a -trusted network. +provider config, and tools travel unencrypted. Prefer `--grpc-tls`. + +**Consumers (DR-037):** Non-loopback gRPC binds refuse to start when +`consumers` is missing/empty unless `--allow-open-auth` or `--insecure` is +set. The image default CMD uses `--grpc-tls` on `0.0.0.0`, so mount a config +with a `consumers` section (see `config.container.example.yaml`) and pass the +token env (e.g. `-e APME_TOKEN=...`). Clients send the token as gRPC metadata +`x-abbenay-token`. See +[CONFIGURATION.md](./CONFIGURATION.md#consumer-authentication-consumers). diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index cb474a6..b994b2d 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -143,9 +143,11 @@ abbenay chat -m openai/gpt-4o # interactive chat aby daemon # short alias # TCP gRPC (optional) — loopback plaintext OK; non-loopback needs TLS or --insecure +# Non-loopback also requires a consumers section (or --allow-open-auth / --insecure) abbenay daemon --grpc-port 50051 -abbenay daemon --grpc-port 50051 --grpc-host 0.0.0.0 --grpc-tls +abbenay daemon --grpc-port 50051 --grpc-host 0.0.0.0 --grpc-tls # needs consumers in config abbenay daemon --grpc-port 50051 --grpc-host 0.0.0.0 --insecure # not recommended +abbenay daemon --grpc-port 50051 --grpc-host 0.0.0.0 --grpc-tls --allow-open-auth # not recommended ``` ### gRPC TCP / TLS flags @@ -155,9 +157,14 @@ abbenay daemon --grpc-port 50051 --grpc-host 0.0.0.0 --insecure # not recommen | `--grpc-port ` | (off) | Also listen for gRPC on TCP | | `--grpc-host ` | `127.0.0.1` | Bind address; `0.0.0.0` for containers | | `--grpc-tls` | off | Enable TLS (auto-generated self-signed certs) | -| `--insecure` | off | Allow plaintext on non-loopback binds | - -See [CONTAINER.md](./CONTAINER.md#security-grpc-bind-tls-and---insecure) for +| `--insecure` | off | Allow plaintext on non-loopback binds; also permits empty consumers | +| `--allow-open-auth` | off | Allow empty consumers on non-loopback binds (prefer configuring `consumers`) | + +Non-loopback binds without a `consumers` section fail closed unless +`--allow-open-auth` or `--insecure` is set. See +[CONFIGURATION.md](./CONFIGURATION.md#consumer-authentication-consumers) for +the capability model and +[CONTAINER.md](./CONTAINER.md#security-grpc-bind-tls-and---insecure) for client trust and insecure tradeoffs. ### CLI list commands diff --git a/docs/TESTING.md b/docs/TESTING.md index f8b6e51..c23e8da 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -87,7 +87,9 @@ Tests that start real servers, make real HTTP/gRPC calls. |------|----------------| | `tests/integration/grpc-streaming.test.ts` | gRPC unary RPCs + streaming + cancellation + concurrency | | `tests/integration/grpc-tls.test.ts` | gRPC TLS bind policy + SetSecret/GetSecret over TLS | -| `tests/integration/grpc-bind-e2e.test.ts` | Subprocess E2E: localhost OK, 0.0.0.0 refuse, TLS/insecure OK | +| `tests/integration/grpc-bind-e2e.test.ts` | Subprocess E2E: localhost OK, 0.0.0.0 refuse, TLS/insecure + consumers/open-auth | +| `tests/integration/consumer-auth.test.ts` | Consumer capability gating on sensitive RPCs | +| `src/daemon/server/consumer-auth.test.ts` | Unit: timing-safe tokens, fail-closed bind, capability matrix | | `tests/integration/web-sse.test.ts` | Web API endpoints + SSE chat + config Zod validation / workspace allowlist | | `tests/integration/openai-compat.test.ts` | OpenAI-compatible API: /v1/models, streaming, non-streaming, errors, tool calls | | `tests/integration/sessions.test.ts` | Session REST API: CRUD endpoints, session chat SSE streaming + persistence | diff --git a/docs/decisions.md b/docs/decisions.md index 2350867..b040bde 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -583,3 +583,23 @@ the path exists. writes, path traversal into workspace config paths, type confusion, and field injection (findings H4/H5). Auth (DR-030) authenticates the caller but does not constrain payload shape or write targets. + +--- + +## DR-037: Mandatory consumer auth for non-localhost gRPC + +**Date:** 2026-07-17 +**Decision:** Extend the DR-024 consumer model so that (1) binding gRPC to a +non-loopback address with an empty/missing `consumers` section fails closed +unless `--allow-open-auth` or `--insecure` is set; (2) when `consumers` is +configured, all sensitive RPCs (secrets, config, providers, shutdown, chat +including SummarizeSession, MCP register, inline policy) require a matching +`x-abbenay-token` and the corresponding capability; (3) token comparison uses +`crypto.timingSafeEqual` on equal-length buffers. Localhost / unix-socket +binds with empty consumers remain allow-all for local DX. HTTP consumer auth +remains out of scope (covered by DR-030 Bearer auth). +**Rationale:** Previously only Chat (inline policy) and RegisterMcpServer were +gated, and empty consumers meant allow-all even on `0.0.0.0` — findings H8/H10. +Fail-closed on network exposure closes unauthenticated secret/config/shutdown +access while keeping single-user local workflows frictionless. Timing-safe +compare closes token oracle leaks. diff --git a/packages/daemon/src/core/config.ts b/packages/daemon/src/core/config.ts index 0c2832c..30f3527 100644 --- a/packages/daemon/src/core/config.ts +++ b/packages/daemon/src/core/config.ts @@ -108,12 +108,23 @@ export interface McpServerConfig { /** * Capabilities a consumer can be granted. * Extensible: add new boolean flags as new gated features are introduced. + * When a `consumers` section is present, sensitive RPCs require the matching flag. */ export interface ConsumerCapabilities { - /** Allow sending inline PolicyConfig on ChatRequest */ + /** Allow sending inline PolicyConfig on ChatRequest / SessionChatRequest */ inline_policy?: boolean; /** Allow dynamic MCP server registration via RegisterMcpServer RPC */ mcp_register?: boolean; + /** Allow GetSecret / SetSecret / DeleteSecret / ListSecrets / GetKeyStatus */ + secrets?: boolean; + /** Allow GetConfig / UpdateConfig / policy CRUD / web server start-stop */ + config?: boolean; + /** Allow ConfigureProvider / RemoveProvider / DiscoverModels */ + providers?: boolean; + /** Allow Shutdown RPC */ + shutdown?: boolean; + /** Allow Chat / SessionChat (base chat; inline_policy is separate) */ + chat?: boolean; } /** diff --git a/packages/daemon/src/daemon/daemon.ts b/packages/daemon/src/daemon/daemon.ts index 5b08c80..348ec80 100644 --- a/packages/daemon/src/daemon/daemon.ts +++ b/packages/daemon/src/daemon/daemon.ts @@ -21,7 +21,13 @@ import { } from './transport.js'; import { DaemonState } from './state.js'; import { createAbbenayService } from './server/abbenay-service.js'; -import { stopEmbeddedWebServer } from './web/server.js'; +import { + assertConsumersConfiguredForBind, + buildConsumerAuthContext, + hasConfiguredConsumers, + resolveAllowOpenAuth, +} from './server/consumer-auth.js';import { stopEmbeddedWebServer } from './web/server.js'; +import { loadConfig } from '../core/config.js'; import { resolveTcpGrpcBind, type GrpcTlsOptions, @@ -91,6 +97,11 @@ export interface DaemonOptions { grpcHost?: string; /** TLS / insecure bind options for the TCP gRPC listener. */ grpcTls?: GrpcTlsOptions; + /** + * Explicit open consumer auth (`--allow-open-auth`). + * Also implied by `--insecure` / `ABBENAY_ALLOW_OPEN_AUTH`. + */ + allowOpenAuth?: boolean; } /** @@ -122,9 +133,19 @@ export async function startDaemon(opts?: DaemonOptions): Promise { const abbenayProto = (proto as unknown as { abbenay: { v1: { Abbenay: { service: grpc.ServiceDefinition } } } }).abbenay.v1; server = new grpc.Server(); - + + const allowOpenAuth = resolveAllowOpenAuth({ + allowOpenAuth: opts?.allowOpenAuth, + insecure: opts?.grpcTls?.insecure, + }); + const authContext = buildConsumerAuthContext({ + grpcHost: opts?.grpcHost, + grpcPort: opts?.grpcPort, + allowOpenAuth, + }); + // Add Abbenay service - const abbenayService = createAbbenayService(state); + const abbenayService = createAbbenayService(state, authContext); server.addService(abbenayProto.Abbenay.service, abbenayService); const socketPath = getDefaultSocketPath(); @@ -154,15 +175,30 @@ export async function startDaemon(opts?: DaemonOptions): Promise { const tlsOpts: GrpcTlsOptions = opts.grpcTls ?? {}; const resolved = resolveTcpGrpcBind(grpcHost, tlsOpts); + // DR-037: non-loopback binds require consumers (or explicit open auth) + assertConsumersConfiguredForBind(grpcHost, loadConfig(), { allowOpenAuth }); + if (!resolved.tlsEnabled && tlsOpts.insecure) { console.warn( `[Daemon] WARNING: gRPC is bound to ${grpcHost} with --insecure (plaintext). ` + 'API keys, chat, and provider config travel unencrypted. Prefer --grpc-tls.', ); + } + // Only warn when open auth is actually active (empty consumers + escape hatch). + // When consumers are configured, RPCs remain gated even with --allow-open-auth/--insecure. + if ( + allowOpenAuth + && !authContext.loopbackOnly + && !hasConfiguredConsumers(loadConfig()) + ) { + console.warn( + `[Daemon] WARNING: gRPC on ${grpcHost} allows open consumer auth ` + + '(--allow-open-auth / --insecure). Sensitive RPCs are not gated by consumers.', + ); } else if (resolved.tlsEnabled && (grpcHost === '0.0.0.0' || grpcHost === '::')) { console.warn( `[Daemon] gRPC TLS is enabled on ${grpcHost} — accessible from any network interface. ` + - 'Configure a consumers section in config.yaml to require authentication.', + 'Consumer authentication is required for sensitive RPCs.', ); } diff --git a/packages/daemon/src/daemon/index.ts b/packages/daemon/src/daemon/index.ts index 0f62524..477f8cc 100644 --- a/packages/daemon/src/daemon/index.ts +++ b/packages/daemon/src/daemon/index.ts @@ -25,13 +25,17 @@ import { VERSION } from '../version.js'; import { resolveHttpApiToken } from './web/http-security.js'; import type { GrpcTlsOptions } from './grpc-tls.js'; -/** Shared CLI flags for TCP gRPC bind + TLS policy. */ +/** Shared CLI flags for TCP gRPC bind + TLS / consumer-auth policy. */ function addGrpcBindOptions(cmd: Command): Command { return cmd .option('--grpc-port ', 'Also listen for gRPC on this TCP port (for remote/container access)') .option('--grpc-host ', 'Host/IP to bind gRPC TCP listener (default: 127.0.0.1, use 0.0.0.0 for containers)') .option('--grpc-tls', 'Enable TLS on the TCP gRPC listener (auto-generates self-signed certs)') - .option('--insecure', 'Allow plaintext gRPC on non-loopback binds (not recommended; prefer --grpc-tls)'); + .option('--insecure', 'Allow plaintext gRPC on non-loopback binds (not recommended; prefer --grpc-tls). Also permits empty consumers (open auth).') + .option( + '--allow-open-auth', + 'Allow empty consumers on non-loopback gRPC binds (not recommended; configure consumers instead)', + ); } function grpcTlsFromCli(options: { @@ -44,6 +48,13 @@ function grpcTlsFromCli(options: { }; } +function allowOpenAuthFromCli(options: { + allowOpenAuth?: boolean; + insecure?: boolean; +}): boolean { + return !!options.allowOpenAuth || !!options.insecure; +} + function printTable(headers: string[], rows: string[][]): void { const widths = headers.map((h, i) => Math.max(h.length, ...rows.map(r => (r[i] || '').length)), @@ -78,6 +89,7 @@ addGrpcBindOptions( grpcPort: options.grpcPort ? validatePort(options.grpcPort) : undefined, grpcHost: options.grpcHost, grpcTls: grpcTlsFromCli(options), + allowOpenAuth: allowOpenAuthFromCli(options), }); } catch (error) { console.error('Failed to start daemon:', error); @@ -124,6 +136,7 @@ interface ServerOptions { grpcPort?: number; grpcHost?: string; grpcTls?: GrpcTlsOptions; + allowOpenAuth?: boolean; /** Lines printed after the server URL, before "Press Ctrl+C" */ bannerLines?: (url: string, mcpStarted: boolean) => string[]; } @@ -142,7 +155,7 @@ function validatePort(raw: string): number { } async function runServer(opts: ServerOptions): Promise { - const { port, host, mcp, grpcPort, grpcHost, grpcTls, bannerLines } = opts; + const { port, host, mcp, grpcPort, grpcHost, grpcTls, allowOpenAuth, bannerLines } = opts; const { token: apiToken, source: tokenSource } = resolveHttpApiToken(); const authEnabled = tokenSource !== 'disabled'; @@ -191,7 +204,13 @@ async function runServer(opts: ServerOptions): Promise { }); } else { console.log('No daemon running, starting in-process...'); - const daemonState = await startDaemon({ keepAlive: false, grpcPort, grpcHost, grpcTls }); + const daemonState = await startDaemon({ + keepAlive: false, + grpcPort, + grpcHost, + grpcTls, + allowOpenAuth, + }); const { url, app, security } = await startEmbeddedWebServer(daemonState, port, host); if (mcp && app) { @@ -232,6 +251,7 @@ addGrpcBindOptions( grpcPort, grpcHost, grpcTls, + allowOpenAuth: allowOpenAuthFromCli(options), bannerLines: (url, mcpStarted) => { const lines = [ '', @@ -276,6 +296,7 @@ addGrpcBindOptions( grpcPort: options.grpcPort ? validatePort(options.grpcPort) : undefined, grpcHost: options.grpcHost, grpcTls: grpcTlsFromCli(options), + allowOpenAuth: allowOpenAuthFromCli(options), bannerLines: (url, mcpStarted) => { const lines = [`Abbenay Web Dashboard: ${url}`]; if (mcpStarted) lines.push(`MCP Server: ${url}/mcp`); @@ -307,6 +328,7 @@ addGrpcBindOptions( grpcPort: options.grpcPort ? validatePort(options.grpcPort) : undefined, grpcHost: options.grpcHost, grpcTls: grpcTlsFromCli(options), + allowOpenAuth: allowOpenAuthFromCli(options), bannerLines: (url, mcpStarted) => { const lines = [ `Abbenay API server: ${url}`, diff --git a/packages/daemon/src/daemon/server/abbenay-service.test.ts b/packages/daemon/src/daemon/server/abbenay-service.test.ts index a0e309e..23750d9 100644 --- a/packages/daemon/src/daemon/server/abbenay-service.test.ts +++ b/packages/daemon/src/daemon/server/abbenay-service.test.ts @@ -116,6 +116,11 @@ describe('configFileToProto', () => { capabilities: { inline_policy: true, mcp_register: true, + secrets: true, + config: true, + providers: true, + shutdown: false, + chat: true, }, }, }, @@ -125,6 +130,11 @@ describe('configFileToProto', () => { expect(proto.consumers!['apme'].token_env).toBe('APME_TOKEN'); expect(proto.consumers!['apme'].capabilities!.inline_policy).toBe(true); expect(proto.consumers!['apme'].capabilities!.mcp_register).toBe(true); + expect(proto.consumers!['apme'].capabilities!.secrets).toBe(true); + expect(proto.consumers!['apme'].capabilities!.config).toBe(true); + expect(proto.consumers!['apme'].capabilities!.providers).toBe(true); + expect(proto.consumers!['apme'].capabilities!.shutdown).toBe(false); + expect(proto.consumers!['apme'].capabilities!.chat).toBe(true); }); }); @@ -203,7 +213,12 @@ describe('protoToConfigFile', () => { consumers: { apme: { token_env: 'APME_TOKEN', - capabilities: { inline_policy: true, mcp_register: false }, + capabilities: { + inline_policy: true, + mcp_register: false, + secrets: true, + chat: true, + }, }, }, }; @@ -212,6 +227,8 @@ describe('protoToConfigFile', () => { expect(roundTripped.consumers!['apme'].token_env).toBe('APME_TOKEN'); expect(roundTripped.consumers!['apme'].capabilities.inline_policy).toBe(true); expect(roundTripped.consumers!['apme'].capabilities.mcp_register).toBe(false); + expect(roundTripped.consumers!['apme'].capabilities.secrets).toBe(true); + expect(roundTripped.consumers!['apme'].capabilities.chat).toBe(true); }); it('provider with env var key instead of keychain', () => { diff --git a/packages/daemon/src/daemon/server/abbenay-service.ts b/packages/daemon/src/daemon/server/abbenay-service.ts index be5c02b..8073ca1 100644 --- a/packages/daemon/src/daemon/server/abbenay-service.ts +++ b/packages/daemon/src/daemon/server/abbenay-service.ts @@ -21,6 +21,27 @@ import { import { DEFAULT_WEB_PORT } from '../../core/constants.js'; import { getProviderTemplates } from '../../core/engines.js'; import { LOCAL_SESSION_OWNER } from '../../core/session-store.js'; +import { + authorizeConsumer, + matchConsumerByToken, + DEFAULT_CONSUMER_AUTH_CONTEXT, + type ConsumerAuthContext, + type ConsumerCapability, + type AuthResult, +} from './consumer-auth.js'; + +export type { ConsumerAuthContext, ConsumerCapability, AuthResult }; +export { + authorizeConsumer, + matchConsumerByToken, + DEFAULT_CONSUMER_AUTH_CONTEXT, + hasConfiguredConsumers, + buildConsumerAuthContext, + assertConsumersConfiguredForBind, + resolveAllowOpenAuth, + timingSafeEqualString, + ConsumerAuthBindError, +} from './consumer-auth.js'; interface ProtoClientInfo { client_type?: string | number; @@ -252,7 +273,15 @@ interface ToolPolicyConfigMsgProto { interface ConsumerConfigMsgProto { token_env?: string; token_keychain?: string; - capabilities?: { inline_policy?: boolean; mcp_register?: boolean }; + capabilities?: { + inline_policy?: boolean; + mcp_register?: boolean; + secrets?: boolean; + config?: boolean; + providers?: boolean; + shutdown?: boolean; + chat?: boolean; + }; } interface ConfigureProviderRequestProto { @@ -367,9 +396,59 @@ function toRole(protoRole: string | number): string { } /** - * Create the Abbenay service handlers + * Deny a unary RPC when consumer auth fails. Returns true when the call may proceed. */ -export function createAbbenayService(state: DaemonState) { +function requireCapability( + call: { metadata: grpc.Metadata }, + capability: ConsumerCapability, + authContext: ConsumerAuthContext, + callback: grpc.sendUnaryData, +): boolean { + const auth = authorizeConsumer(call, loadConfig() || { providers: {} }, capability, authContext); + if (!auth.allowed) { + callback({ + code: grpc.status.PERMISSION_DENIED, + message: auth.reason || 'Permission denied', + }); + return false; + } + if (auth.consumer) { + console.log(`[Service] ${capability} authorized for consumer "${auth.consumer}"`); + } + return true; +} + +/** + * Deny a server-streaming RPC when consumer auth fails. Returns auth on success, null when denied. + * Emits a gRPC PERMISSION_DENIED status (same as unary gates) so clients see a real RPC error, + * not only an in-band ChatChunk error. + */ +function requireCapabilityStream( + call: grpc.ServerWritableStream, + capability: ConsumerCapability, + authContext: ConsumerAuthContext, +): AuthResult | null { + const auth = authorizeConsumer(call, loadConfig() || { providers: {} }, capability, authContext); + if (!auth.allowed) { + const err = Object.assign(new Error(auth.reason || 'Permission denied'), { + code: grpc.status.PERMISSION_DENIED, + details: auth.reason || 'Permission denied', + }); + call.emit('error', err); + return null; + } + return auth; +} + +/** + * Create the Abbenay service handlers. + * + * @param authContext Bind/open-mode policy. Defaults to local DX (empty consumers allowed). + */ +export function createAbbenayService( + state: DaemonState, + authContext: ConsumerAuthContext = DEFAULT_CONSUMER_AUTH_CONTEXT, +) { return { /** * Register a client with the daemon @@ -485,6 +564,7 @@ export function createAbbenayService(state: DaemonState) { call: grpc.ServerUnaryCall, callback: grpc.sendUnaryData ): void { + if (!requireCapability(call, 'providers', authContext, callback)) return; const engineId = call.request.engine_id || call.request.engineId || ''; if (!engineId) { callback({ @@ -531,6 +611,9 @@ export function createAbbenayService(state: DaemonState) { * Streaming chat */ Chat(call: grpc.ServerWritableStream): void { + const chatAuth = requireCapabilityStream(call, 'chat', authContext); + if (!chatAuth) return; + const request = call.request; const model = request.model; @@ -591,8 +674,13 @@ export function createAbbenayService(state: DaemonState) { return; } - // Consumer authorization gate (DR-024) - const auth = authorizeConsumer(call, loadConfig(), 'inline_policy'); + // Consumer authorization gate (DR-024 / DR-037) + const auth = authorizeConsumer( + call, + loadConfig() || { providers: {} }, + 'inline_policy', + authContext, + ); if (!auth.allowed) { call.write({ error: { code: 'PERMISSION_DENIED', message: auth.reason } }); call.end(); @@ -665,6 +753,7 @@ export function createAbbenayService(state: DaemonState) { call: grpc.ServerUnaryCall, callback: grpc.sendUnaryData ): void { + if (!requireCapability(call, 'secrets', authContext, callback)) return; const key = call.request.key; state.secretStore.get(key).then((value) => { @@ -687,6 +776,7 @@ export function createAbbenayService(state: DaemonState) { call: grpc.ServerUnaryCall, callback: grpc.sendUnaryData ): void { + if (!requireCapability(call, 'secrets', authContext, callback)) return; const { key, value } = call.request; state.secretStore.set(key, value).then(() => { @@ -708,6 +798,7 @@ export function createAbbenayService(state: DaemonState) { call: grpc.ServerUnaryCall, callback: grpc.sendUnaryData ): void { + if (!requireCapability(call, 'secrets', authContext, callback)) return; state.secretStore.delete(call.request.key).then(() => { callback(null, {}); }).catch((error: unknown) => { @@ -725,6 +816,7 @@ export function createAbbenayService(state: DaemonState) { call: grpc.ServerUnaryCall, callback: grpc.sendUnaryData ): void { + if (!requireCapability(call, 'secrets', authContext, callback)) return; // Return known key names with availability status const engines = getEngines(); const checks = engines.filter(e => e.requiresKey).map(async (e) => { @@ -861,6 +953,7 @@ export function createAbbenayService(state: DaemonState) { call: grpc.ServerUnaryCall, callback: grpc.sendUnaryData ): void { + if (!requireCapability(call, 'config', authContext, callback)) return; try { const location = call.request.location || 'user'; let config: ConfigFile; @@ -887,6 +980,7 @@ export function createAbbenayService(state: DaemonState) { call: grpc.ServerUnaryCall, callback: grpc.sendUnaryData ): void { + if (!requireCapability(call, 'config', authContext, callback)) return; try { const location = call.request.location || 'user'; const protoConfig = call.request.config; @@ -975,6 +1069,7 @@ export function createAbbenayService(state: DaemonState) { call: grpc.ServerUnaryCall, callback: grpc.sendUnaryData ): void { + if (!requireCapability(call, 'config', authContext, callback)) return; const port = call.request.port || DEFAULT_WEB_PORT; if (isWebServerRunning()) { @@ -1011,6 +1106,7 @@ export function createAbbenayService(state: DaemonState) { call: grpc.ServerUnaryCall, callback: grpc.sendUnaryData ): void { + if (!requireCapability(call, 'config', authContext, callback)) return; stopEmbeddedWebServer().then(() => { callback(null, {}); }).catch((error: unknown) => { @@ -1028,6 +1124,7 @@ export function createAbbenayService(state: DaemonState) { call: grpc.ServerUnaryCall, callback: grpc.sendUnaryData ): void { + if (!requireCapability(call, 'shutdown', authContext, callback)) return; console.log('[gRPC] Shutdown requested'); callback(null, {}); @@ -1114,6 +1211,9 @@ export function createAbbenayService(state: DaemonState) { }, SessionChat(call: grpc.ServerWritableStream): void { + const chatAuth = requireCapabilityStream(call, 'chat', authContext); + if (!chatAuth) return; + const sessionId = call.request.session_id || call.request.sessionId || ''; const userMsg = call.request.message; @@ -1161,8 +1261,13 @@ export function createAbbenayService(state: DaemonState) { return; } - // Consumer authorization gate (DR-024) - const auth = authorizeConsumer(call, loadConfig(), 'inline_policy'); + // Consumer authorization gate (DR-024 / DR-037) + const auth = authorizeConsumer( + call, + loadConfig() || { providers: {} }, + 'inline_policy', + authContext, + ); if (!auth.allowed) { call.write({ error: { code: 'PERMISSION_DENIED', message: auth.reason } }); call.end(); @@ -1276,6 +1381,9 @@ export function createAbbenayService(state: DaemonState) { callback({ code: grpc.status.UNIMPLEMENTED, message: 'Sessions not yet implemented' }); }, SummarizeSession(call: grpc.ServerUnaryCall<{ session_id?: string; sessionId?: string; summarize_model?: string; summarizeModel?: string }, object>, callback: grpc.sendUnaryData): void { + // Same capability as Chat — summarization invokes the LLM and can burn provider keys. + if (!requireCapability(call, 'chat', authContext, callback)) return; + const sessionId = call.request.session_id || call.request.sessionId || ''; if (!sessionId) { callback({ code: grpc.status.INVALID_ARGUMENT, message: 'session_id is required' }); @@ -1373,15 +1481,7 @@ export function createAbbenayService(state: DaemonState) { return; } - // Consumer auth: require mcp_register capability - const authResult = authorizeConsumer(call, loadConfig(), 'mcp_register'); - if (!authResult.allowed) { - callback({ code: grpc.status.PERMISSION_DENIED, message: authResult.reason! }); - return; - } - if (authResult.consumer) { - console.log(`[Service] RegisterMcpServer authorized for consumer "${authResult.consumer}"`); - } + if (!requireCapability(call, 'mcp_register', authContext, callback)) return; // Resolve registering client ID from gRPC metadata const clientIdMeta = call.metadata.get('x-abbenay-client-id'); @@ -1420,6 +1520,7 @@ export function createAbbenayService(state: DaemonState) { * Unregister a dynamically registered MCP server */ UnregisterMcpServer(call: grpc.ServerUnaryCall, callback: grpc.sendUnaryData): void { + if (!requireCapability(call, 'mcp_register', authContext, callback)) return; const serverId = call.request.server_id || call.request.serverId || ''; if (!serverId) { callback({ code: grpc.status.INVALID_ARGUMENT, message: 'server_id is required' }); @@ -1453,6 +1554,7 @@ export function createAbbenayService(state: DaemonState) { call: grpc.ServerUnaryCall, callback: grpc.sendUnaryData ): void { + if (!requireCapability(call, 'providers', authContext, callback)) return; (async () => { try { const providerId = call.request.provider_id || call.request.providerId || ''; @@ -1524,6 +1626,7 @@ export function createAbbenayService(state: DaemonState) { call: grpc.ServerUnaryCall, callback: grpc.sendUnaryData ): void { + if (!requireCapability(call, 'providers', authContext, callback)) return; (async () => { try { const providerId = call.request.provider_id || call.request.providerId || ''; @@ -1590,6 +1693,7 @@ export function createAbbenayService(state: DaemonState) { call: grpc.ServerUnaryCall, callback: grpc.sendUnaryData ): void { + if (!requireCapability(call, 'secrets', authContext, callback)) return; const source = call.request.source || ''; const name = call.request.name || ''; @@ -1666,6 +1770,7 @@ export function createAbbenayService(state: DaemonState) { call: grpc.ServerUnaryCall, callback: grpc.sendUnaryData ): void { + if (!requireCapability(call, 'mcp_register', authContext, callback)) return; const serverId = call.request.server_id || call.request.serverId || ''; if (!serverId) { callback({ code: grpc.status.INVALID_ARGUMENT, message: 'server_id is required' }); @@ -1688,6 +1793,7 @@ export function createAbbenayService(state: DaemonState) { call: grpc.ServerUnaryCall, callback: grpc.sendUnaryData ): void { + if (!requireCapability(call, 'config', authContext, callback)) return; try { const name = call.request.name || ''; const protoConfig = call.request.config; @@ -1726,6 +1832,7 @@ export function createAbbenayService(state: DaemonState) { call: grpc.ServerUnaryCall, callback: grpc.sendUnaryData ): void { + if (!requireCapability(call, 'config', authContext, callback)) return; try { const name = call.request.name || ''; if (!name) { @@ -1891,6 +1998,11 @@ export function configFileToProto(config: ConfigFile): ConfigProto { capabilities: { inline_policy: ccfg.capabilities?.inline_policy, mcp_register: ccfg.capabilities?.mcp_register, + secrets: ccfg.capabilities?.secrets, + config: ccfg.capabilities?.config, + providers: ccfg.capabilities?.providers, + shutdown: ccfg.capabilities?.shutdown, + chat: ccfg.capabilities?.chat, }, }; } @@ -1968,6 +2080,11 @@ export function protoToConfigFile(proto: ConfigProto): ConfigFile { capabilities: { inline_policy: ccfg.capabilities?.inline_policy, mcp_register: ccfg.capabilities?.mcp_register, + secrets: ccfg.capabilities?.secrets, + config: ccfg.capabilities?.config, + providers: ccfg.capabilities?.providers, + shutdown: ccfg.capabilities?.shutdown, + chat: ccfg.capabilities?.chat, }, }; } @@ -2058,7 +2175,7 @@ function transportProtoToConfig(transport: McpTransportProto): McpServerConfig { throw new Error(`Unknown transport type: "${type}". Must be "stdio", "http", or "sse".`); } -// ── Consumer authorization (DR-024 / DR-025) ────────────────────────── +// ── Consumer authorization (DR-024 / DR-025 / DR-037) ───────────────── /** * Resolve the session owner principal for a gRPC call. @@ -2069,83 +2186,15 @@ export function resolveGrpcSessionOwner( call: { metadata: grpc.Metadata }, config: ConfigFile | null, ): string { - const consumers = config?.consumers; - if (!consumers || Object.keys(consumers).length === 0) { - return LOCAL_SESSION_OWNER; - } - const metadata = call.metadata.get('x-abbenay-token'); const token = metadata.length > 0 ? String(metadata[0]) : undefined; - if (!token) { - return LOCAL_SESSION_OWNER; + const name = matchConsumerByToken(config, token); + if (name) { + return `consumer:${name}`; } - - for (const [name, consumer] of Object.entries(consumers)) { - const expectedToken = consumer.token_env - ? process.env[consumer.token_env] - : undefined; - if (expectedToken && token === expectedToken) { - return `consumer:${name}`; - } - } - return LOCAL_SESSION_OWNER; } -/** @internal Exported for testing. */ -export interface AuthResult { - allowed: boolean; - consumer?: string; - reason?: string; -} - -export type ConsumerCapability = 'inline_policy' | 'mcp_register'; - -const CAPABILITY_LABELS: Record = { - inline_policy: 'Inline policy', - mcp_register: 'MCP registration', -}; - -/** @internal Exported for testing. */ -export function authorizeConsumer( - call: { metadata: grpc.Metadata }, - config: ConfigFile, - capability: ConsumerCapability, -): AuthResult { - const consumers = config.consumers; - - if (!consumers || Object.keys(consumers).length === 0) { - return { allowed: true }; - } - - const metadata = call.metadata.get('x-abbenay-token'); - const token = metadata.length > 0 ? String(metadata[0]) : undefined; - - if (!token) { - return { - allowed: false, - reason: `${CAPABILITY_LABELS[capability]} requires consumer authentication. Set the x-abbenay-token gRPC metadata header.`, - }; - } - - for (const [name, consumer] of Object.entries(consumers)) { - const expectedToken = consumer.token_env - ? process.env[consumer.token_env] - : undefined; - - if (!expectedToken) continue; - - if (token === expectedToken && consumer.capabilities?.[capability]) { - return { allowed: true, consumer: name }; - } - } - - return { - allowed: false, - reason: `Consumer token not recognized or lacks ${CAPABILITY_LABELS[capability]} capability.`, - }; -} - /** @deprecated Use authorizeConsumer(call, config, 'mcp_register') instead. */ export function authorizeMcpRegister( call: grpc.ServerUnaryCall, diff --git a/packages/daemon/src/daemon/server/consumer-auth.test.ts b/packages/daemon/src/daemon/server/consumer-auth.test.ts new file mode 100644 index 0000000..1e7152e --- /dev/null +++ b/packages/daemon/src/daemon/server/consumer-auth.test.ts @@ -0,0 +1,315 @@ +/** + * Unit tests for gRPC consumer authorization (DR-037). + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import * as grpc from '@grpc/grpc-js'; +import * as crypto from 'node:crypto'; +import { + authorizeConsumer, + assertConsumersConfiguredForBind, + buildConsumerAuthContext, + hasConfiguredConsumers, + matchConsumerByToken, + resolveAllowOpenAuth, + timingSafeEqualString, + ConsumerAuthBindError, + DEFAULT_CONSUMER_AUTH_CONTEXT, + type ConsumerCapability, +} from './consumer-auth.js'; +import type { ConfigFile } from '../../core/config.js'; + +function mockGrpcCall(token?: string): { metadata: grpc.Metadata } { + const metadata = new grpc.Metadata(); + if (token) { + metadata.add('x-abbenay-token', token); + } + return { metadata }; +} + +const ALL_CAPS: Record = { + inline_policy: true, + mcp_register: true, + secrets: true, + config: true, + providers: true, + shutdown: true, + chat: true, +}; + +function withEnv(key: string, value: string, fn: () => void): void { + const prev = process.env[key]; + process.env[key] = value; + try { + fn(); + } finally { + if (prev === undefined) delete process.env[key]; + else process.env[key] = prev; + } +} + +describe('timingSafeEqualString', () => { + it('returns true for equal strings', () => { + expect(timingSafeEqualString('secret', 'secret')).toBe(true); + }); + + it('returns false for unequal same-length strings', () => { + expect(timingSafeEqualString('secret', 'secreX')).toBe(false); + }); + + it('returns false for different lengths', () => { + expect(timingSafeEqualString('short', 'longer-token')).toBe(false); + }); + + it('compares equal-length buffers without throwing (timing-safe path)', () => { + // Implementation calls crypto.timingSafeEqual when lengths match. + // ESM prevents spying on crypto exports; assert behavioral contract instead. + expect(timingSafeEqualString('same-len!', 'same-len!')).toBe(true); + expect(timingSafeEqualString('same-len!', 'same-len?')).toBe(false); + expect(typeof crypto.timingSafeEqual).toBe('function'); + }); +}); + +describe('hasConfiguredConsumers / resolveAllowOpenAuth / buildConsumerAuthContext', () => { + it('detects missing and empty consumers', () => { + expect(hasConfiguredConsumers(null)).toBe(false); + expect(hasConfiguredConsumers({ providers: {} })).toBe(false); + expect(hasConfiguredConsumers({ consumers: {} })).toBe(false); + expect(hasConfiguredConsumers({ + consumers: { apme: { token_env: 'T', capabilities: {} } }, + })).toBe(true); + }); + + it('resolves open auth from flags and env', () => { + expect(resolveAllowOpenAuth({})).toBe(false); + expect(resolveAllowOpenAuth({ allowOpenAuth: true })).toBe(true); + expect(resolveAllowOpenAuth({ insecure: true })).toBe(true); + expect(resolveAllowOpenAuth({ env: { ABBENAY_ALLOW_OPEN_AUTH: '1' } })).toBe(true); + expect(resolveAllowOpenAuth({ env: { ABBENAY_ALLOW_OPEN_AUTH: 'true' } })).toBe(true); + expect(resolveAllowOpenAuth({ env: { ABBENAY_ALLOW_OPEN_AUTH: '0' } })).toBe(false); + }); + + it('builds loopback-only context without TCP port', () => { + expect(buildConsumerAuthContext({})).toEqual({ + loopbackOnly: true, + allowOpenAuth: false, + }); + }); + + it('marks non-loopback TCP as not loopback-only', () => { + expect(buildConsumerAuthContext({ + grpcPort: 50051, + grpcHost: '0.0.0.0', + })).toEqual({ loopbackOnly: false, allowOpenAuth: false }); + + expect(buildConsumerAuthContext({ + grpcPort: 50051, + grpcHost: '127.0.0.1', + })).toEqual({ loopbackOnly: true, allowOpenAuth: false }); + }); +}); + +describe('assertConsumersConfiguredForBind', () => { + it('allows loopback without consumers', () => { + expect(() => + assertConsumersConfiguredForBind('127.0.0.1', {}, { allowOpenAuth: false }), + ).not.toThrow(); + }); + + it('refuses non-loopback without consumers and without open auth', () => { + expect(() => + assertConsumersConfiguredForBind('0.0.0.0', { providers: {} }, { allowOpenAuth: false }), + ).toThrow(ConsumerAuthBindError); + expect(() => + assertConsumersConfiguredForBind('0.0.0.0', { consumers: {} }, { allowOpenAuth: false }), + ).toThrow(/consumers|--allow-open-auth|--insecure/); + }); + + it('allows non-loopback with configured consumers', () => { + expect(() => + assertConsumersConfiguredForBind( + '0.0.0.0', + { consumers: { apme: { token_env: 'T', capabilities: { chat: true } } } }, + { allowOpenAuth: false }, + ), + ).not.toThrow(); + }); + + it('allows non-loopback without consumers when open auth is explicit', () => { + expect(() => + assertConsumersConfiguredForBind('0.0.0.0', {}, { allowOpenAuth: true }), + ).not.toThrow(); + }); +}); + +describe('matchConsumerByToken', () => { + afterEach(() => { + delete process.env.TEST_CONSUMER_TOKEN; + }); + + it('matches with timing-safe compare', () => { + process.env.TEST_CONSUMER_TOKEN = 'tok-abc'; + const config: ConfigFile = { + consumers: { + apme: { token_env: 'TEST_CONSUMER_TOKEN', capabilities: { chat: true } }, + }, + }; + expect(matchConsumerByToken(config, 'tok-abc')).toBe('apme'); + expect(matchConsumerByToken(config, 'wrong')).toBeUndefined(); + expect(matchConsumerByToken(config, undefined)).toBeUndefined(); + }); +}); + +describe('authorizeConsumer', () => { + afterEach(() => { + delete process.env.TEST_TOKEN; + }); + + it('allows when no consumers on localhost (default-open local DX)', () => { + const result = authorizeConsumer( + mockGrpcCall(), + { providers: {} }, + 'secrets', + DEFAULT_CONSUMER_AUTH_CONTEXT, + ); + expect(result.allowed).toBe(true); + }); + + it('allows when consumers section is empty on localhost', () => { + const result = authorizeConsumer( + mockGrpcCall(), + { providers: {}, consumers: {} }, + 'shutdown', + { loopbackOnly: true, allowOpenAuth: false }, + ); + expect(result.allowed).toBe(true); + }); + + it('denies when consumers empty on non-localhost without open auth', () => { + const result = authorizeConsumer( + mockGrpcCall(), + { providers: {} }, + 'secrets', + { loopbackOnly: false, allowOpenAuth: false }, + ); + expect(result.allowed).toBe(false); + expect(result.reason).toMatch(/beyond localhost|consumers/i); + }); + + it('allows empty consumers on non-localhost with open auth', () => { + const result = authorizeConsumer( + mockGrpcCall(), + { providers: {} }, + 'secrets', + { loopbackOnly: false, allowOpenAuth: true }, + ); + expect(result.allowed).toBe(true); + }); + + it('rejects when consumers configured but no token provided', () => { + withEnv('TEST_TOKEN', 'secret123', () => { + const result = authorizeConsumer(mockGrpcCall(), { + consumers: { + apme: { token_env: 'TEST_TOKEN', capabilities: { inline_policy: true } }, + }, + }, 'inline_policy'); + expect(result.allowed).toBe(false); + expect(result.reason).toContain('x-abbenay-token'); + }); + }); + + it('rejects wrong token', () => { + withEnv('TEST_TOKEN', 'secret123', () => { + const result = authorizeConsumer(mockGrpcCall('wrong-token'), { + consumers: { + apme: { token_env: 'TEST_TOKEN', capabilities: { secrets: true } }, + }, + }, 'secrets'); + expect(result.allowed).toBe(false); + expect(result.reason).toContain('not recognized'); + }); + }); + + it('allows when token matches with required capability', () => { + withEnv('TEST_TOKEN', 'secret123', () => { + const result = authorizeConsumer(mockGrpcCall('secret123'), { + consumers: { + apme: { token_env: 'TEST_TOKEN', capabilities: { secrets: true } }, + }, + }, 'secrets'); + expect(result.allowed).toBe(true); + expect(result.consumer).toBe('apme'); + }); + }); + + it('capability matrix: deny when capability missing', () => { + withEnv('TEST_TOKEN', 'secret123', () => { + const cases: ConsumerCapability[] = [ + 'secrets', 'config', 'providers', 'shutdown', 'chat', 'mcp_register', 'inline_policy', + ]; + for (const capability of cases) { + const result = authorizeConsumer(mockGrpcCall('secret123'), { + consumers: { + limited: { token_env: 'TEST_TOKEN', capabilities: {} }, + }, + }, capability); + expect(result.allowed, capability).toBe(false); + } + }); + }); + + it('capability matrix: allow each capability independently', () => { + withEnv('TEST_TOKEN', 'secret123', () => { + const cases: ConsumerCapability[] = [ + 'secrets', 'config', 'providers', 'shutdown', 'chat', 'mcp_register', 'inline_policy', + ]; + for (const capability of cases) { + const result = authorizeConsumer(mockGrpcCall('secret123'), { + consumers: { + apme: { + token_env: 'TEST_TOKEN', + capabilities: { [capability]: true }, + }, + }, + }, capability); + expect(result.allowed, capability).toBe(true); + expect(result.consumer).toBe('apme'); + } + }); + }); + + it('allows full-capability consumer for all gated ops', () => { + withEnv('TEST_TOKEN', 'full-token', () => { + for (const capability of Object.keys(ALL_CAPS) as ConsumerCapability[]) { + const result = authorizeConsumer(mockGrpcCall('full-token'), { + consumers: { + admin: { token_env: 'TEST_TOKEN', capabilities: { ...ALL_CAPS } }, + }, + }, capability); + expect(result.allowed, capability).toBe(true); + } + }); + }); + + it('rejects secrets when consumer only has chat', () => { + withEnv('TEST_TOKEN', 't', () => { + const result = authorizeConsumer(mockGrpcCall('t'), { + consumers: { + chatter: { token_env: 'TEST_TOKEN', capabilities: { chat: true } }, + }, + }, 'secrets'); + expect(result.allowed).toBe(false); + }); + }); + + it('rejects shutdown when consumer only has config', () => { + withEnv('TEST_TOKEN', 't', () => { + const result = authorizeConsumer(mockGrpcCall('t'), { + consumers: { + cfg: { token_env: 'TEST_TOKEN', capabilities: { config: true } }, + }, + }, 'shutdown'); + expect(result.allowed).toBe(false); + }); + }); +}); diff --git a/packages/daemon/src/daemon/server/consumer-auth.ts b/packages/daemon/src/daemon/server/consumer-auth.ts new file mode 100644 index 0000000..7c78833 --- /dev/null +++ b/packages/daemon/src/daemon/server/consumer-auth.ts @@ -0,0 +1,231 @@ +/** + * Consumer authorization for gRPC (DR-024 / DR-025 / DR-037). + * + * - Localhost / unix socket: empty `consumers` remains allow-all (local DX). + * - Non-loopback bind: empty `consumers` fails closed unless explicit open mode + * (`--allow-open-auth` or `--insecure`). + * - When `consumers` is configured: sensitive RPCs require a matching token + * (timing-safe) and the requested capability. + */ + +import * as crypto from 'node:crypto'; +import type * as grpc from '@grpc/grpc-js'; +import type { ConfigFile, ConsumerCapabilities } from '../../core/config.js'; +import { isLoopbackHost } from '../grpc-tls.js'; + +/** Capabilities a consumer may be granted. */ +export type ConsumerCapability = keyof ConsumerCapabilities; + +export interface AuthResult { + allowed: boolean; + consumer?: string; + reason?: string; +} + +/** + * Runtime auth policy derived from bind address + CLI flags. + * Passed into the gRPC service so RPC gates match startup policy. + */ +export interface ConsumerAuthContext { + /** + * True when gRPC is only reachable via unix socket and/or loopback TCP. + * Empty consumers are allowed (default-open local DX). + */ + loopbackOnly: boolean; + /** + * Explicit open mode: `--allow-open-auth` and/or `--insecure`. + * Permits empty consumers on non-loopback binds (not recommended). + */ + allowOpenAuth: boolean; +} + +/** Default: local DX (unix / loopback). Used by unit tests and unix-only binds. */ +export const DEFAULT_CONSUMER_AUTH_CONTEXT: ConsumerAuthContext = { + loopbackOnly: true, + allowOpenAuth: false, +}; + +const CAPABILITY_LABELS: Record = { + inline_policy: 'Inline policy', + mcp_register: 'MCP registration', + secrets: 'Secrets', + config: 'Configuration', + providers: 'Provider management', + shutdown: 'Shutdown', + chat: 'Chat', +}; + +export class ConsumerAuthBindError extends Error { + constructor(message: string) { + super(message); + this.name = 'ConsumerAuthBindError'; + } +} + +/** True when config has at least one named consumer entry. */ +export function hasConfiguredConsumers(config: ConfigFile | null | undefined): boolean { + const consumers = config?.consumers; + return !!consumers && Object.keys(consumers).length > 0; +} + +/** + * Resolve whether open auth is explicitly requested via CLI/env. + * `--insecure` counts as open mode (E2E / container escape hatch). + */ +export function resolveAllowOpenAuth(opts: { + allowOpenAuth?: boolean; + insecure?: boolean; + env?: NodeJS.ProcessEnv; +}): boolean { + if (opts.allowOpenAuth || opts.insecure) { + return true; + } + const env = opts.env ?? process.env; + const raw = (env.ABBENAY_ALLOW_OPEN_AUTH || '').trim().toLowerCase(); + return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on'; +} + +/** + * Build auth context from TCP bind host (if any) and open-mode flags. + * No TCP port → loopback-only (unix socket). + */ +export function buildConsumerAuthContext(opts: { + grpcHost?: string; + grpcPort?: number; + allowOpenAuth?: boolean; + insecure?: boolean; + env?: NodeJS.ProcessEnv; +}): ConsumerAuthContext { + const allowOpenAuth = resolveAllowOpenAuth(opts); + if (!opts.grpcPort) { + return { loopbackOnly: true, allowOpenAuth }; + } + const host = opts.grpcHost ?? '127.0.0.1'; + return { + loopbackOnly: isLoopbackHost(host), + allowOpenAuth, + }; +} + +/** + * Refuse non-loopback TCP binds when no consumers are configured and open + * mode was not explicitly requested. + */ +export function assertConsumersConfiguredForBind( + host: string, + config: ConfigFile | null | undefined, + opts: { allowOpenAuth: boolean }, +): void { + if (isLoopbackHost(host)) { + return; + } + if (hasConfiguredConsumers(config)) { + return; + } + if (opts.allowOpenAuth) { + return; + } + throw new ConsumerAuthBindError( + `Refusing to bind gRPC on non-loopback address "${host}" without configured consumers. ` + + 'Add a consumers section to config.yaml (recommended), or pass --allow-open-auth / --insecure ' + + 'to explicitly allow unauthenticated access (not recommended).', + ); +} + +/** + * Timing-safe string equality for consumer tokens. + * Returns false when lengths differ (after a same-length dummy compare). + */ +export function timingSafeEqualString(a: string, b: string): boolean { + const bufA = Buffer.from(a); + const bufB = Buffer.from(b); + if (bufA.length !== bufB.length) { + crypto.timingSafeEqual(bufA, bufA); + return false; + } + return crypto.timingSafeEqual(bufA, bufB); +} + +/** + * Find the consumer name whose token matches (timing-safe). + * Returns undefined when no consumers are configured or no match. + */ +export function matchConsumerByToken( + config: ConfigFile | null | undefined, + token: string | undefined, + env: NodeJS.ProcessEnv = process.env, +): string | undefined { + const consumers = config?.consumers; + if (!consumers || Object.keys(consumers).length === 0 || !token) { + return undefined; + } + + let matched: string | undefined; + for (const [name, consumer] of Object.entries(consumers)) { + const expectedToken = consumer.token_env ? env[consumer.token_env] : undefined; + if (!expectedToken) continue; + if (timingSafeEqualString(token, expectedToken)) { + // Continue scanning so compare work is not short-circuited by early return + // on the first candidate (minor hardening; first match still wins). + if (matched === undefined) { + matched = name; + } + } + } + return matched; +} + +function extractToken(call: { metadata: grpc.Metadata }): string | undefined { + const metadata = call.metadata.get('x-abbenay-token'); + return metadata.length > 0 ? String(metadata[0]) : undefined; +} + +/** + * Authorize a gRPC call for a specific consumer capability. + * + * @param authContext Bind/open-mode policy (defaults to local DX allow-all when empty). + */ +export function authorizeConsumer( + call: { metadata: grpc.Metadata }, + config: ConfigFile, + capability: ConsumerCapability, + authContext: ConsumerAuthContext = DEFAULT_CONSUMER_AUTH_CONTEXT, +): AuthResult { + if (!hasConfiguredConsumers(config)) { + if (authContext.loopbackOnly || authContext.allowOpenAuth) { + return { allowed: true }; + } + return { + allowed: false, + reason: + 'Consumer authentication is required when gRPC is bound beyond localhost. ' + + 'Configure a consumers section in config.yaml, or restart with --allow-open-auth / --insecure.', + }; + } + + const token = extractToken(call); + if (!token) { + return { + allowed: false, + reason: `${CAPABILITY_LABELS[capability]} requires consumer authentication. Set the x-abbenay-token gRPC metadata header.`, + }; + } + + const name = matchConsumerByToken(config, token); + if (!name) { + return { + allowed: false, + reason: `Consumer token not recognized or lacks ${CAPABILITY_LABELS[capability]} capability.`, + }; + } + + const consumer = config.consumers![name]!; + if (consumer.capabilities?.[capability]) { + return { allowed: true, consumer: name }; + } + + return { + allowed: false, + reason: `Consumer token not recognized or lacks ${CAPABILITY_LABELS[capability]} capability.`, + }; +} diff --git a/packages/daemon/src/state.test.ts b/packages/daemon/src/state.test.ts index d3b6ca6..d97d50b 100644 --- a/packages/daemon/src/state.test.ts +++ b/packages/daemon/src/state.test.ts @@ -977,151 +977,11 @@ function mockGrpcCall(token?: string): { metadata: grpc.Metadata } { return { metadata }; } -describe('authorizeConsumer', () => { - it('should allow when no consumers section (default-open)', () => { +describe('authorizeConsumer (re-export smoke)', () => { + it('should allow when no consumers section (default-open local DX)', () => { const result = authorizeConsumer(mockGrpcCall(), { providers: {} }, 'inline_policy'); expect(result.allowed).toBe(true); }); - - it('should allow when consumers section is empty', () => { - const result = authorizeConsumer(mockGrpcCall(), { providers: {}, consumers: {} }, 'inline_policy'); - expect(result.allowed).toBe(true); - }); - - it('should reject when consumers configured but no token provided', () => { - const prev = process.env.TEST_TOKEN; - process.env.TEST_TOKEN = 'secret123'; - try { - const result = authorizeConsumer(mockGrpcCall(), { - providers: {}, - consumers: { - apme: { token_env: 'TEST_TOKEN', capabilities: { inline_policy: true } }, - }, - }, 'inline_policy'); - expect(result.allowed).toBe(false); - expect(result.reason).toContain('x-abbenay-token'); - } finally { - if (prev === undefined) delete process.env.TEST_TOKEN; - else process.env.TEST_TOKEN = prev; - } - }); - - it('should reject when token does not match any consumer (inline_policy)', () => { - const prev = process.env.TEST_TOKEN; - process.env.TEST_TOKEN = 'secret123'; - try { - const result = authorizeConsumer(mockGrpcCall('wrong-token'), { - providers: {}, - consumers: { - apme: { token_env: 'TEST_TOKEN', capabilities: { inline_policy: true } }, - }, - }, 'inline_policy'); - expect(result.allowed).toBe(false); - expect(result.reason).toContain('not recognized'); - } finally { - if (prev === undefined) delete process.env.TEST_TOKEN; - else process.env.TEST_TOKEN = prev; - } - }); - - it('should allow when token matches consumer with inline_policy capability', () => { - const prev = process.env.TEST_TOKEN; - process.env.TEST_TOKEN = 'secret123'; - try { - const result = authorizeConsumer(mockGrpcCall('secret123'), { - providers: {}, - consumers: { - apme: { token_env: 'TEST_TOKEN', capabilities: { inline_policy: true } }, - }, - }, 'inline_policy'); - expect(result.allowed).toBe(true); - expect(result.consumer).toBe('apme'); - } finally { - if (prev === undefined) delete process.env.TEST_TOKEN; - else process.env.TEST_TOKEN = prev; - } - }); - - it('should reject when token matches but consumer lacks requested capability', () => { - const prev = process.env.TEST_TOKEN; - process.env.TEST_TOKEN = 'secret123'; - try { - const result = authorizeConsumer(mockGrpcCall('secret123'), { - providers: {}, - consumers: { - limited: { token_env: 'TEST_TOKEN', capabilities: {} }, - }, - }, 'inline_policy'); - expect(result.allowed).toBe(false); - } finally { - if (prev === undefined) delete process.env.TEST_TOKEN; - else process.env.TEST_TOKEN = prev; - } - }); - - it('should allow mcp_register when token matches with that capability', () => { - const prev = process.env.TEST_TOKEN; - process.env.TEST_TOKEN = 'mcp-secret'; - try { - const result = authorizeConsumer(mockGrpcCall('mcp-secret'), { - providers: {}, - consumers: { - apme: { token_env: 'TEST_TOKEN', capabilities: { mcp_register: true } }, - }, - }, 'mcp_register'); - expect(result.allowed).toBe(true); - expect(result.consumer).toBe('apme'); - } finally { - if (prev === undefined) delete process.env.TEST_TOKEN; - else process.env.TEST_TOKEN = prev; - } - }); - - it('should reject mcp_register when consumer only has inline_policy', () => { - const prev = process.env.TEST_TOKEN; - process.env.TEST_TOKEN = 'mcp-secret'; - try { - const result = authorizeConsumer(mockGrpcCall('mcp-secret'), { - providers: {}, - consumers: { - apme: { token_env: 'TEST_TOKEN', capabilities: { inline_policy: true } }, - }, - }, 'mcp_register'); - expect(result.allowed).toBe(false); - } finally { - if (prev === undefined) delete process.env.TEST_TOKEN; - else process.env.TEST_TOKEN = prev; - } - }); - - it('should reject mcp_register when no token provided', () => { - const result = authorizeConsumer(mockGrpcCall(), { - providers: {}, - consumers: { - apme: { token_env: 'TEST_TOKEN', capabilities: { mcp_register: true } }, - }, - }, 'mcp_register'); - expect(result.allowed).toBe(false); - expect(result.reason).toContain('consumer authentication'); - }); - - it('should reject mcp_register when token does not match', () => { - const prev = process.env.TEST_TOKEN; - process.env.TEST_TOKEN = 'mcp-secret'; - try { - const result = authorizeConsumer(mockGrpcCall('wrong-token'), { - providers: {}, - consumers: { - apme: { token_env: 'TEST_TOKEN', capabilities: { mcp_register: true } }, - }, - }, 'mcp_register'); - expect(result.allowed).toBe(false); - expect(result.reason).toContain('not recognized'); - } finally { - if (prev === undefined) delete process.env.TEST_TOKEN; - else process.env.TEST_TOKEN = prev; - } - }); }); // ── stripJsonFences ────────────────────────────────────────────────────────── diff --git a/packages/daemon/tests/integration/consumer-auth.test.ts b/packages/daemon/tests/integration/consumer-auth.test.ts new file mode 100644 index 0000000..3d45ad3 --- /dev/null +++ b/packages/daemon/tests/integration/consumer-auth.test.ts @@ -0,0 +1,342 @@ +/** + * Integration: consumer capability gating on sensitive gRPC RPCs (H8/H10 / DR-037). + */ + +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'; +import * as grpc from '@grpc/grpc-js'; +import * as protoLoader from '@grpc/proto-loader'; +import * as path from 'node:path'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const PROTO_PATH = path.resolve(__dirname, '../../../../proto/abbenay/v1/service.proto'); +const PROTO_INCLUDE = path.resolve(__dirname, '../../../../proto'); + +const TOKEN_ENV = 'ABBENAY_TEST_CONSUMER_TOKEN'; +const GOOD_TOKEN = 'integration-consumer-token'; + +const mockLoadConfig = vi.fn(); +const mockLoadWorkspaceConfig = vi.fn().mockReturnValue(null); +const mockMergeConfigs = vi.fn().mockImplementation( + (user: { providers?: object }) => user || { providers: {} }, +); +const mockMergeMultipleWorkspaceConfigs = vi.fn().mockImplementation( + (base: { providers?: object }) => base || { providers: {} }, +); +const mockResolveEngineModelId = vi.fn().mockImplementation( + (name: string, cfg: { model_id?: string }) => cfg.model_id || name, +); +const mockSaveConfig = vi.fn(); + +vi.mock('../../src/core/config.js', () => ({ + loadConfig: (...a: unknown[]) => mockLoadConfig(...a), + loadWorkspaceConfig: (...a: unknown[]) => mockLoadWorkspaceConfig(...a), + mergeConfigs: (...a: unknown[]) => mockMergeConfigs(...a), + mergeMultipleWorkspaceConfigs: (...a: unknown[]) => mockMergeMultipleWorkspaceConfigs(...a), + resolveEngineModelId: (...a: unknown[]) => mockResolveEngineModelId(...a), + saveConfig: (...a: unknown[]) => mockSaveConfig(...a), + saveWorkspaceConfig: vi.fn(), + getUserConfigPath: () => '/tmp/abbenay-test-config.yaml', + getWorkspaceConfigPath: () => '/tmp/abbenay-test-ws-config.yaml', + isValidVirtualName: (n: string) => /^[a-z0-9][a-z0-9._-]*$/.test(n), +})); + +vi.mock('../../src/core/engines.js', () => ({ + getEngines: () => [ + { id: 'mock', requiresKey: false, supportsTools: false, createModel: () => { throw new Error('mock'); } }, + ], + getEngine: (id: string) => (id === 'mock' ? { id: 'mock', requiresKey: false } : undefined), + fetchModels: async () => [], + streamChat: async function* () { yield { type: 'done', finishReason: 'stop' }; }, + getProviderTemplates: () => [], +})); + +const mockSecretStoreData = new Map(); +vi.mock('../../src/daemon/secrets/keychain.js', () => ({ + KeychainSecretStore: class { + async get(key: string): Promise { return mockSecretStoreData.get(key) ?? null; } + async set(key: string, value: string): Promise { mockSecretStoreData.set(key, value); } + async delete(key: string): Promise { return mockSecretStoreData.delete(key); } + async has(key: string): Promise { return mockSecretStoreData.has(key); } + }, +})); + +let mockSessionsDir: string; +vi.mock('../../src/core/paths.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getSessionsDir: () => mockSessionsDir, + }; +}); + +import { DaemonState } from '../../src/daemon/state.js'; +import { createAbbenayService } from '../../src/daemon/server/abbenay-service.js'; + +function loadProto() { + const packageDef = protoLoader.loadSync(PROTO_PATH, { + keepCase: true, + longs: String, + enums: String, + defaults: true, + oneofs: true, + includeDirs: [PROTO_INCLUDE], + }); + return grpc.loadPackageDefinition(packageDef); +} + +function meta(token?: string): grpc.Metadata { + const m = new grpc.Metadata(); + if (token) m.add('x-abbenay-token', token); + return m; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function callUnary(client: any, method: string, request: object, token?: string): Promise { + return new Promise((resolve, reject) => { + client[method](request, meta(token), (error: grpc.ServiceError | null, response: unknown) => { + if (error) reject(error); + else resolve(response); + }); + }); +} + +async function expectPermissionDenied(promise: Promise): Promise { + try { + await promise; + expect.fail('expected PERMISSION_DENIED'); + } catch (err) { + const e = err as grpc.ServiceError; + expect(e.code).toBe(grpc.status.PERMISSION_DENIED); + } +} + +describe('Consumer auth RPC gating', () => { + let server: grpc.Server; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let client: any; + let state: DaemonState; + + beforeAll(async () => { + process.env[TOKEN_ENV] = GOOD_TOKEN; + mockSessionsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'abbenay-consumer-auth-')); + + // Distinct token env per consumer so capability matrix is unambiguous. + mockLoadConfig.mockReturnValue({ + providers: {}, + consumers: { + chatter: { + token_env: 'ABBENAY_TEST_CHAT_TOKEN', + capabilities: { chat: true }, + }, + secrets_only: { + token_env: 'ABBENAY_TEST_SECRETS_TOKEN', + capabilities: { secrets: true }, + }, + config_only: { + token_env: 'ABBENAY_TEST_CONFIG_TOKEN', + capabilities: { config: true }, + }, + providers_only: { + token_env: 'ABBENAY_TEST_PROVIDERS_TOKEN', + capabilities: { providers: true }, + }, + shutdown_only: { + token_env: 'ABBENAY_TEST_SHUTDOWN_TOKEN', + capabilities: { shutdown: true }, + }, + full: { + token_env: TOKEN_ENV, + capabilities: { + chat: true, + secrets: true, + config: true, + providers: true, + shutdown: true, + mcp_register: true, + inline_policy: true, + }, + }, + }, + }); + + process.env.ABBENAY_TEST_CHAT_TOKEN = 'chat-only'; + process.env.ABBENAY_TEST_SECRETS_TOKEN = 'secrets-only'; + process.env.ABBENAY_TEST_CONFIG_TOKEN = 'config-only'; + process.env.ABBENAY_TEST_PROVIDERS_TOKEN = 'providers-only'; + process.env.ABBENAY_TEST_SHUTDOWN_TOKEN = 'shutdown-only'; + + state = new DaemonState(); + const proto = loadProto(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const abbenayProto = (proto as any).abbenay.v1; + + server = new grpc.Server(); + server.addService(abbenayProto.Abbenay.service, createAbbenayService(state)); + + const port = await new Promise((resolve, reject) => { + server.bindAsync('127.0.0.1:0', grpc.ServerCredentials.createInsecure(), (err, p) => { + if (err) reject(err); + else resolve(p); + }); + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + client = new (abbenayProto as any).Abbenay(`127.0.0.1:${port}`, grpc.credentials.createInsecure()); + await new Promise((resolve, reject) => { + client.waitForReady(new Date(Date.now() + 5000), (err: Error | null) => { + if (err) reject(err); + else resolve(); + }); + }); + }); + + afterAll(async () => { + client?.close(); + await new Promise((resolve) => server?.tryShutdown(() => resolve())); + fs.rmSync(mockSessionsDir, { recursive: true, force: true }); + delete process.env[TOKEN_ENV]; + delete process.env.ABBENAY_TEST_CHAT_TOKEN; + delete process.env.ABBENAY_TEST_SECRETS_TOKEN; + delete process.env.ABBENAY_TEST_CONFIG_TOKEN; + delete process.env.ABBENAY_TEST_PROVIDERS_TOKEN; + delete process.env.ABBENAY_TEST_SHUTDOWN_TOKEN; + }); + + afterEach(() => { + mockSecretStoreData.clear(); + }); + + it('denies wrong token on SetSecret', async () => { + await expectPermissionDenied(callUnary(client, 'SetSecret', { key: 'K', value: 'v' }, 'wrong')); + }); + + it('denies missing token on GetSecret', async () => { + await expectPermissionDenied(callUnary(client, 'GetSecret', { key: 'K' })); + }); + + it('denies SetSecret without secrets capability', async () => { + await expectPermissionDenied(callUnary(client, 'SetSecret', { key: 'K', value: 'v' }, 'chat-only')); + }); + + it('allows SetSecret/GetSecret/DeleteSecret with secrets capability', async () => { + await callUnary(client, 'SetSecret', { key: 'K1', value: 'v1' }, 'secrets-only'); + const got = await callUnary(client, 'GetSecret', { key: 'K1' }, 'secrets-only'); + expect(got.value).toBe('v1'); + await callUnary(client, 'DeleteSecret', { key: 'K1' }, 'secrets-only'); + const missing = await callUnary(client, 'GetSecret', { key: 'K1' }, 'secrets-only'); + expect(missing.value).toBe(''); + }); + + it('denies UpdateConfig without config capability', async () => { + await expectPermissionDenied(callUnary(client, 'UpdateConfig', { + config: { providers: {} }, + }, 'secrets-only')); + }); + + it('allows UpdateConfig with config capability', async () => { + await callUnary(client, 'UpdateConfig', { + config: { providers: {} }, + }, 'config-only'); + expect(mockSaveConfig).toHaveBeenCalled(); + }); + + it('denies ConfigureProvider without providers capability', async () => { + await expectPermissionDenied(callUnary(client, 'ConfigureProvider', { + provider_id: 'p1', + engine: 'mock', + }, 'config-only')); + }); + + it('allows ConfigureProvider with providers capability', async () => { + const res = await callUnary(client, 'ConfigureProvider', { + provider_id: 'p1', + engine: 'mock', + }, 'providers-only'); + expect(res.success).toBe(true); + }); + + it('denies Shutdown without shutdown capability', async () => { + await expectPermissionDenied(callUnary(client, 'Shutdown', {}, 'chat-only')); + }); + + it('allows HealthCheck without consumer token (ungated)', async () => { + const res = await callUnary(client, 'HealthCheck', {}); + expect(res.healthy).toBe(true); + }); + + it('full token can call secrets and config', async () => { + await callUnary(client, 'SetSecret', { key: 'FULL', value: 'ok' }, GOOD_TOKEN); + await callUnary(client, 'GetConfig', {}, GOOD_TOKEN); + }); + + it('denies SummarizeSession without chat capability', async () => { + await expectPermissionDenied(callUnary(client, 'SummarizeSession', { + session_id: 'sess-nope', + }, 'secrets-only')); + }); + + it('allows SummarizeSession past auth with chat capability', async () => { + // Capability check runs before session lookup — missing session is not PERMISSION_DENIED. + try { + await callUnary(client, 'SummarizeSession', { session_id: 'missing-session' }, 'chat-only'); + } catch (err: unknown) { + const code = (err as { code?: number }).code; + expect(code).not.toBe(grpc.status.PERMISSION_DENIED); + return; + } + // If it somehow succeeds, that also means auth passed. + }); +}); + +describe('Consumer auth deny-all on non-localhost without consumers', () => { + let server: grpc.Server; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let client: any; + + beforeAll(async () => { + mockSessionsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'abbenay-consumer-open-')); + mockLoadConfig.mockReturnValue({ providers: {} }); + + const state = new DaemonState(); + const proto = loadProto(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const abbenayProto = (proto as any).abbenay.v1; + + server = new grpc.Server(); + server.addService( + abbenayProto.Abbenay.service, + createAbbenayService(state, { loopbackOnly: false, allowOpenAuth: false }), + ); + + const port = await new Promise((resolve, reject) => { + server.bindAsync('127.0.0.1:0', grpc.ServerCredentials.createInsecure(), (err, p) => { + if (err) reject(err); + else resolve(p); + }); + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + client = new (abbenayProto as any).Abbenay(`127.0.0.1:${port}`, grpc.credentials.createInsecure()); + await new Promise((resolve, reject) => { + client.waitForReady(new Date(Date.now() + 5000), (err: Error | null) => { + if (err) reject(err); + else resolve(); + }); + }); + }); + + afterAll(async () => { + client?.close(); + await new Promise((resolve) => server?.tryShutdown(() => resolve())); + fs.rmSync(mockSessionsDir, { recursive: true, force: true }); + }); + + it('denies SetSecret when serving non-localhost policy without consumers', async () => { + await expectPermissionDenied(callUnary(client, 'SetSecret', { key: 'K', value: 'v' })); + }); +}); diff --git a/packages/daemon/tests/integration/grpc-bind-e2e.test.ts b/packages/daemon/tests/integration/grpc-bind-e2e.test.ts index 6eac6df..e5fa28e 100644 --- a/packages/daemon/tests/integration/grpc-bind-e2e.test.ts +++ b/packages/daemon/tests/integration/grpc-bind-e2e.test.ts @@ -1,10 +1,11 @@ /** - * E2E (subprocess): gRPC TCP bind security policy (C2). + * E2E (subprocess): gRPC TCP bind security policy (C2 + DR-037 consumers). * - * - 127.0.0.1 plaintext → starts (local DX) + * - 127.0.0.1 plaintext → starts (local DX; empty consumers OK) * - 0.0.0.0 without TLS/--insecure → refused - * - 0.0.0.0 with --grpc-tls → starts - * - 0.0.0.0 with --insecure → starts + * - 0.0.0.0 with --grpc-tls but no consumers → refused (DR-037) + * - 0.0.0.0 with --grpc-tls + --allow-open-auth → starts + * - 0.0.0.0 with --insecure → starts (open auth implied) */ import { describe, it, expect, afterEach } from 'vitest'; @@ -133,20 +134,35 @@ describe('E2E: gRPC TCP bind security (C2)', () => { expect(output).toMatch(/--insecure|--grpc-tls/); }); - it('starts on 0.0.0.0 with --grpc-tls (auto self-signed)', async () => { + it('refuses 0.0.0.0 with --grpc-tls when no consumers configured', async () => { const port = await freePort(); const child = spawnDaemon([ '--grpc-port', String(port), '--grpc-host', '0.0.0.0', '--grpc-tls', ]); + const { code, output } = await child.waitExit(); + expect(code).not.toBe(0); + expect(output).toMatch(/without configured consumers|Failed to start daemon/); + expect(output).toMatch(/--allow-open-auth|--insecure/); + }); + + it('starts on 0.0.0.0 with --grpc-tls and --allow-open-auth', async () => { + const port = await freePort(); + const child = spawnDaemon([ + '--grpc-port', String(port), + '--grpc-host', '0.0.0.0', + '--grpc-tls', + '--allow-open-auth', + ]); await child.waitFor(/gRPC listening on 0\.0\.0\.0:\d+ \(TLS\)/); expect(child.output()).toMatch(/auto-generated self-signed cert/); + expect(child.output()).toMatch(/open consumer auth/); const caPath = path.join(child.runtimeDir, 'abbenay', 'tls', 'ca.crt'); expect(fs.existsSync(caPath)).toBe(true); }); - it('starts on 0.0.0.0 with --insecure (explicit plaintext opt-in)', async () => { + it('starts on 0.0.0.0 with --insecure (plaintext + open auth opt-in)', async () => { const port = await freePort(); const child = spawnDaemon([ '--grpc-port', String(port), @@ -154,6 +170,92 @@ describe('E2E: gRPC TCP bind security (C2)', () => { '--insecure', ]); await child.waitFor(/gRPC listening on 0\.0\.0\.0:\d+ \(plaintext\)/); - expect(child.output()).toMatch(/--insecure \(plaintext\)/); + expect(child.output()).toMatch(/--insecure \(plaintext\)|open consumer auth/); + }); + + it('starts on 0.0.0.0 with --grpc-tls when consumers are configured', async () => { + const port = await freePort(); + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'abbenay-e2e-home-')); + const runtimeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'abbenay-e2e-rt-')); + + // Platform config paths (see core/paths.ts) + const configDir = process.platform === 'darwin' + ? path.join(homeDir, 'Library', 'Application Support', 'abbenay') + : path.join(homeDir, '.config', 'abbenay'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, 'config.yaml'), + [ + 'consumers:', + ' e2e:', + ' token_env: E2E_CONSUMER_TOKEN', + ' capabilities:', + ' chat: true', + ' secrets: true', + '', + ].join('\n'), + ); + + let buf = ''; + const proc = spawn( + process.execPath, + ['--import', 'tsx', DAEMON_ENTRY, 'daemon', + '--grpc-port', String(port), + '--grpc-host', '0.0.0.0', + '--grpc-tls'], + { + env: { + ...process.env, + HOME: homeDir, + XDG_RUNTIME_DIR: runtimeDir, + XDG_CONFIG_HOME: path.join(homeDir, '.config'), + E2E_CONSUMER_TOKEN: 'e2e-token', + ABBENAY_DEBUG: '0', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + proc.stdout?.on('data', (c: Buffer) => { buf += c.toString(); }); + proc.stderr?.on('data', (c: Buffer) => { buf += c.toString(); }); + + const child = { + proc, + runtimeDir: homeDir, + output: () => buf, + waitFor: (pattern: RegExp, timeoutMs = 15000) => new Promise((resolve, reject) => { + const start = Date.now(); + const timer = setInterval(() => { + if (pattern.test(buf)) { + clearInterval(timer); + resolve(); + } else if (Date.now() - start > timeoutMs) { + clearInterval(timer); + reject(new Error(`Timeout waiting for ${pattern}. Output:\n${buf}`)); + } else if (proc.exitCode !== null && !pattern.test(buf)) { + clearInterval(timer); + reject(new Error(`Process exited ${proc.exitCode} before match. Output:\n${buf}`)); + } + }, 50); + }), + kill: async () => { + if (proc.exitCode !== null) return; + proc.kill('SIGTERM'); + await new Promise((resolve) => { + const t = setTimeout(() => { + try { proc.kill('SIGKILL'); } catch { /* ignore */ } + resolve(); + }, 3000); + proc.on('exit', () => { + clearTimeout(t); + resolve(); + }); + }); + fs.rmSync(runtimeDir, { recursive: true, force: true }); + }, + }; + children.push(child as SpawnResult); + + await child.waitFor(/gRPC listening on 0\.0\.0\.0:\d+ \(TLS\)/); + expect(child.output()).toMatch(/Consumer authentication is required/); }); }); diff --git a/proto/abbenay/v1/service.proto b/proto/abbenay/v1/service.proto index da0b411..c626df8 100644 --- a/proto/abbenay/v1/service.proto +++ b/proto/abbenay/v1/service.proto @@ -900,6 +900,11 @@ message ConsumerConfigMsg { message ConsumerCapabilitiesMsg { optional bool inline_policy = 1; // Allow inline PolicyConfig on ChatRequest optional bool mcp_register = 2; // Allow dynamic MCP server registration + optional bool secrets = 3; // Allow secret read/write/list RPCs + optional bool config = 4; // Allow GetConfig / UpdateConfig / policy / web control + optional bool providers = 5; // Allow ConfigureProvider / RemoveProvider / DiscoverModels + optional bool shutdown = 6; // Allow Shutdown RPC + optional bool chat = 7; // Allow Chat / SessionChat } enum LogLevel {