Skip to content
Open
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
2 changes: 1 addition & 1 deletion DESIGN-inline-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ consumers:
**Behavior:**

- **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`.
- **`consumers` section present:** Callers need `chat` plus `inline_policy`, with a valid `x-abbenay-token`. Missing or unrecognized tokens receive `UNAUTHENTICATED`; a recognized consumer lacking `inline_policy` receives `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 / DR-037).

Expand Down
17 changes: 14 additions & 3 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -346,9 +346,19 @@ consumers:
| 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.
Token comparison uses `crypto.timingSafeEqual` (equal-length buffers). Missing
or unrecognized tokens receive `UNAUTHENTICATED`. A recognized consumer that
lacks the required capability receives `PERMISSION_DENIED` (same denial
message as an unrecognized token so the string does not leak validity).
`HealthCheck`, `GetStatus`, and `ListModels` stay ungated so probes and local
tooling keep working (`DiscoverModels` still needs the `providers`
capability). Session CRUD RPCs (`CreateSession` / `GetSession` /
`ListSessions` / `DeleteSession`) are not capability-gated: no token still
maps to the `local` owner, but a presented-but-unrecognized token is
`UNAUTHENTICATED` (DR-049). `SessionChat` and `SummarizeSession` still
require the `chat` capability. The Python client only attaches
`x-abbenay-token` on a Unix socket or TLS channel; plaintext TCP +
`token=` raises before the RPC is sent.

> **WARNING — open auth:** `--allow-open-auth` (or `--insecure`, which implies
> it) on a non-loopback bind restores allow-all when `consumers` is empty.
Expand All @@ -366,6 +376,7 @@ Every session is stamped with an `owner` principal:
| HTTP + `X-Abbenay-Session-Owner: <name>` | `http:<fingerprint>:<name>` |
| gRPC with matching consumer token | `consumer:<name>` |
| gRPC without consumer token | `local` |
| gRPC with unrecognized consumer token (`consumers` configured) | RPC rejected (`UNAUTHENTICATED`) |

List/get/delete/chat only return sessions for the caller's owner. Cross-owner
access returns 404 (not 403) so session IDs are not leaked across principals.
Expand Down
6 changes: 4 additions & 2 deletions docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,11 +160,12 @@ unless `--allow-open-auth` or `--insecure` is set. Configure consumers per

```bash
# After consumers + --grpc-tls on 0.0.0.0: sensitive RPCs need x-abbenay-token
# Wrong/missing token → PERMISSION_DENIED (see consumer-auth tests / client docs)
# Missing/wrong token → UNAUTHENTICATED; valid token without capability → PERMISSION_DENIED
```

**Pass:** start fails with empty consumers on `0.0.0.0`; with consumers, wrong
token is denied on gated RPCs.
token is denied on gated RPCs. Python clients must send `x-abbenay-token` only
over Unix socket or TLS (plaintext TCP + `token=` is rejected client-side).
**Fail:** non-loopback gRPC allows all callers with no consumers and no opt-in.

### 6. MCP HTTP endpoint
Expand Down Expand Up @@ -201,3 +202,4 @@ need air-gap / offline posture:
| DR-029 | Fail-closed TLS for non-loopback gRPC TCP |
| DR-030 | Secure-by-default HTTP (auth, CORS, bind) |
| DR-038 | Air-gap docs must not claim isolation equals security |
| DR-049 | Unrecognized gRPC consumer tokens fail closed for session ownership |
23 changes: 23 additions & 0 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -883,3 +883,26 @@ Abbenay remains source of truth for provider credentials. Placing secrets
next to config reuses the existing config volume contract without introducing
a separate Gateway SoT or a cloud KMS dependency. Encryption can layer later
without changing the `(secret_store, secret_name)` address model.

---

## DR-049: Unrecognized gRPC consumer tokens fail closed for session ownership

**Date:** 2026-08-15
**Decision:** When `consumers` is configured and a caller presents
`x-abbenay-token` that matches no consumer, session RPCs
(`CreateSession`, `GetSession`, `ListSessions`, `DeleteSession`,
`SessionChat`, `SummarizeSession`) return `UNAUTHENTICATED` instead of
mapping the caller to `local`. Omitting the header still maps to `local`
(unix-socket / local CLI DX). Privileged RPCs (`authorizeConsumer`) use the
same split: missing or unrecognized token → `UNAUTHENTICATED`; recognized
consumer lacking the capability → `PERMISSION_DENIED`. Denial *messages* for
unrecognized token vs missing capability stay identical so the string does
not leak token validity; status codes follow gRPC semantics.
**Rationale:** PR #62 / DR-031 stamped sessions with an owner, but
`resolveGrpcSessionOwner` treated a present-but-wrong token like no token.
That let a caller with a bad consumer token create and list sessions in the
CLI namespace. Fail-closed on unrecognized tokens closes that hole without
breaking no-token local DX. Tracked as
[issue #71](https://github.com/redhat-developer/abbenay/issues/71).

142 changes: 136 additions & 6 deletions packages/daemon/src/daemon/server/abbenay-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -469,7 +469,19 @@ describe('protoToPolicyConfig', () => {
describe('resolveGrpcSessionOwner', () => {
it('returns local owner without matching token', () => {
const call = { metadata: new grpc.Metadata() };
expect(resolveGrpcSessionOwner(call, { providers: {} })).toBe('local');
expect(resolveGrpcSessionOwner(call, { providers: {} })).toEqual({ ok: true, owner: 'local' });
});

it('returns local owner when consumers are configured but no token is sent', async () => {
await withEnv('TEST_OWNER_TOKEN', 'owner-tok', () => {
const call = { metadata: new grpc.Metadata() };
const config: ConfigFile = {
consumers: {
apme: { token_env: 'TEST_OWNER_TOKEN', capabilities: { chat: true } },
},
};
expect(resolveGrpcSessionOwner(call, config)).toEqual({ ok: true, owner: 'local' });
});
});

it('returns consumer owner when token matches', () => {
Expand All @@ -484,12 +496,54 @@ describe('resolveGrpcSessionOwner', () => {
apme: { token_env: 'TEST_OWNER_TOKEN', capabilities: { chat: true } },
},
};
expect(resolveGrpcSessionOwner(call, config)).toBe('consumer:apme');
expect(resolveGrpcSessionOwner(call, config)).toEqual({
ok: true,
owner: 'consumer:apme',
});
} finally {
if (prev === undefined) delete process.env.TEST_OWNER_TOKEN;
else process.env.TEST_OWNER_TOKEN = prev;
}
});

it('fails closed when a token is presented but matches no consumer', async () => {
await withEnv('TEST_OWNER_TOKEN', 'owner-tok', () => {
const metadata = new grpc.Metadata();
metadata.add('x-abbenay-token', 'wrong-tok');
const call = { metadata };
const config: ConfigFile = {
consumers: {
apme: { token_env: 'TEST_OWNER_TOKEN', capabilities: { chat: true } },
},
};
expect(resolveGrpcSessionOwner(call, config)).toEqual({
ok: false,
reason: 'Consumer token not recognized.',
});
});
});

it('fails closed on empty presented token when consumers are configured', async () => {
await withEnv('TEST_OWNER_TOKEN', 'owner-tok', () => {
const metadata = new grpc.Metadata();
metadata.add('x-abbenay-token', '');
const config: ConfigFile = {
consumers: {
apme: { token_env: 'TEST_OWNER_TOKEN', capabilities: { chat: true } },
},
};
expect(resolveGrpcSessionOwner({ metadata }, config).ok).toBe(false);
});
});

it('ignores unrecognized tokens when no consumers are configured', () => {
const metadata = new grpc.Metadata();
metadata.add('x-abbenay-token', 'stray-tok');
expect(resolveGrpcSessionOwner({ metadata }, { providers: {} })).toEqual({
ok: true,
owner: 'local',
});
});
});

// ── deprecated auth wrappers ─────────────────────────────────────────────────
Expand Down Expand Up @@ -922,6 +976,82 @@ describe('createAbbenayService handlers', () => {
expect(state.mcpClientPool.disconnectByScope).toHaveBeenCalledWith('sess-1');
});

it('session RPCs reject unrecognized consumer tokens as UNAUTHENTICATED', async () => {
await withEnv('SESS_OWNER_TOKEN', 'good-owner', async () => {
mockLoadConfig.mockReturnValue({
providers: {},
consumers: {
apme: { token_env: 'SESS_OWNER_TOKEN', capabilities: { chat: true } },
},
});
const state = createMockState();
const service = createServiceHandlers(state);
const badMeta = new grpc.Metadata();
badMeta.add('x-abbenay-token', 'wrong-owner');

const created = await invokeUnary(service.CreateSession, { model: 'mock/echo' }, badMeta);
expect(created.error?.code).toBe(grpc.status.UNAUTHENTICATED);
expect(state.sessionStore.create).not.toHaveBeenCalled();

const listed = await invokeUnary(service.ListSessions, {}, badMeta);
expect(listed.error?.code).toBe(grpc.status.UNAUTHENTICATED);

const got = await invokeUnary(service.GetSession, { session_id: 'sess-1' }, badMeta);
expect(got.error?.code).toBe(grpc.status.UNAUTHENTICATED);

const deleted = await invokeUnary(service.DeleteSession, { session_id: 'sess-1' }, badMeta);
expect(deleted.error?.code).toBe(grpc.status.UNAUTHENTICATED);
});
});

it('session RPCs keep no-token callers in the local namespace when consumers are configured', async () => {
await withEnv('SESS_OWNER_TOKEN', 'good-owner', async () => {
mockLoadConfig.mockReturnValue({
providers: {},
consumers: {
apme: { token_env: 'SESS_OWNER_TOKEN', capabilities: { chat: true } },
},
});
const state = createMockState();
const service = createServiceHandlers(state);

const created = await invokeUnary(service.CreateSession, { model: 'mock/echo', topic: 't' });
expect(created.error).toBeNull();
expect(state.sessionStore.create).toHaveBeenCalledWith(
'mock/echo',
't',
undefined,
undefined,
'local',
);
});
});

it('CreateSession stamps matching consumer token as consumer owner', async () => {
await withEnv('SESS_OWNER_TOKEN', 'good-owner', async () => {
mockLoadConfig.mockReturnValue({
providers: {},
consumers: {
apme: { token_env: 'SESS_OWNER_TOKEN', capabilities: { chat: true } },
},
});
const state = createMockState();
const service = createServiceHandlers(state);
const meta = new grpc.Metadata();
meta.add('x-abbenay-token', 'good-owner');

const created = await invokeUnary(service.CreateSession, { model: 'mock/echo' }, meta);
expect(created.error).toBeNull();
expect(state.sessionStore.create).toHaveBeenCalledWith(
'mock/echo',
undefined,
undefined,
undefined,
'consumer:apme',
);
});
});

it('SummarizeSession returns cached summary when counts match', async () => {
const state = createMockState({
sessionStore: {
Expand Down Expand Up @@ -1396,7 +1526,7 @@ describe('createAbbenayService handlers', () => {
const state = createMockState();
const service = createServiceHandlers(state, DEFAULT_CONSUMER_AUTH_CONTEXT);
const { error } = await invokeUnary(service.GetSecret, { key: 'K' });
expect(error?.code).toBe(grpc.status.PERMISSION_DENIED);
expect(error?.code).toBe(grpc.status.UNAUTHENTICATED);
});

it('Chat returns INVALID_ARGUMENT when model missing', async () => {
Expand All @@ -1416,7 +1546,7 @@ describe('createAbbenayService handlers', () => {
expect(written[0]).toEqual({ error: { code: 'INVALID_ARGUMENT', message: 'Model is required' } });
});

it('Chat stream emits PERMISSION_DENIED via gRPC error when auth fails', async () => {
it('Chat stream emits UNAUTHENTICATED via gRPC error when token is missing', async () => {
mockLoadConfig.mockReturnValue({
providers: {},
consumers: {
Expand Down Expand Up @@ -1444,7 +1574,7 @@ describe('createAbbenayService handlers', () => {
service.Chat(call as never);
await vi.waitFor(() => expect(call.emit).toHaveBeenCalledWith('error', expect.any(Error)));
const err = (call.emit as ReturnType<typeof vi.fn>).mock.calls[0][1] as Error & { code?: number };
expect(err.code).toBe(grpc.status.PERMISSION_DENIED);
expect(err.code).toBe(grpc.status.UNAUTHENTICATED);
});

it('SessionChat validates session_id and message content', async () => {
Expand Down Expand Up @@ -1524,7 +1654,7 @@ describe('createAbbenayService handlers', () => {
server_id: 'dyn',
transport: { type: 'stdio', command: 'npx', args: ['x'] },
});
expect(error?.code).toBe(grpc.status.PERMISSION_DENIED);
expect(error?.code).toBe(grpc.status.UNAUTHENTICATED);
expect(error?.message).toMatch(/consumer authentication/i);
});

Expand Down
Loading
Loading