Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions DESIGN-inline-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
23 changes: 15 additions & 8 deletions config.container.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
47 changes: 47 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
19 changes: 15 additions & 4 deletions docs/CONTAINER.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand All @@ -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
Expand Down Expand Up @@ -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).
15 changes: 11 additions & 4 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -155,9 +157,14 @@ abbenay daemon --grpc-port 50051 --grpc-host 0.0.0.0 --insecure # not recommen
| `--grpc-port <port>` | (off) | Also listen for gRPC on TCP |
| `--grpc-host <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
Expand Down
4 changes: 3 additions & 1 deletion docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
20 changes: 20 additions & 0 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
13 changes: 12 additions & 1 deletion packages/daemon/src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
44 changes: 40 additions & 4 deletions packages/daemon/src/daemon/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -122,9 +133,19 @@ export async function startDaemon(opts?: DaemonOptions): Promise<DaemonState> {
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();
Expand Down Expand Up @@ -154,15 +175,30 @@ export async function startDaemon(opts?: DaemonOptions): Promise<DaemonState> {
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.',
);
}

Expand Down
Loading
Loading