From 6d03269f16180d03838e861428f843013811f103 Mon Sep 17 00:00:00 2001 From: akushonkamen Date: Mon, 12 Oct 2026 16:49:18 +0800 Subject: [PATCH] fix(memory): wire scoped api keys into the packaged server entry The packaged service entry built its auth as a strict either-or between localServiceToken and allowAnonymous, so the per-agent scopedApiKeys enforcement that already exists in the HTTP layer was unreachable and installs without a storage token silently served anonymous requests. Expose validated scopedApiKeys under memmyMemory.storage (entries require namespace.source and namespace.profileId, malformed entries are dropped so they fail closed), and assemble entry auth with localServiceToken over scopedApiKeys over allowAnonymous: anonymous access is only offered when neither credential type is configured. --- Memory/readme.md | 27 ++++++ Memory/src/config/index.ts | 63 ++++++++++++- Memory/src/server/index.ts | 29 ++++-- Memory/tests/config.test.ts | 51 +++++++++++ Memory/tests/server-entrypoint.test.ts | 119 +++++++++++++++++++++++++ 5 files changed, 282 insertions(+), 7 deletions(-) diff --git a/Memory/readme.md b/Memory/readme.md index 624f19346..3fe30e4e0 100644 --- a/Memory/readme.md +++ b/Memory/readme.md @@ -88,6 +88,33 @@ When `storage.token`, `MEMMY_MEMORY_TOKEN`, or `MEMORY_SERVICE_TOKEN` is set, all HTTP routes except `GET /api/v1/health` require that token as a bearer token or `x-api-key`. +### Per-agent scoped API keys + +Installations that need per-agent credentials can configure +`storage.scopedApiKeys` without a storage token. Each map key is the API key +itself; `namespace.source` and `namespace.profileId` are required, and +`scopes` defaults to read and write (`memory:read`, `memory:write`) when +omitted: + +```yaml +memmyMemory: + storage: + scopedApiKeys: + "agent-a-readonly-key": + namespace: + source: agent-a + profileId: default + scopes: [memory:read] +``` + +Requests authenticate with the key as a bearer token or `x-api-key`. A key +whose scopes do not cover a route is rejected with `403`, and requests without +a valid key are rejected with `401`: while any scoped key is configured the +service never falls back to anonymous access. A storage token, when also +configured, keeps full `*` access and takes precedence. Scoped API keys +restrict access at the service HTTP boundary only; they are not an OS-level +security boundary for local agents that can read the config file. + ## CLI Run the CLI directly from source inside the `Memory/` directory: diff --git a/Memory/src/config/index.ts b/Memory/src/config/index.ts index 85a96a4f2..cb030b622 100644 --- a/Memory/src/config/index.ts +++ b/Memory/src/config/index.ts @@ -12,6 +12,7 @@ import { type RuntimeModelCatalog } from "../contracts/index.js"; import { resolveTimeZone } from "../utils/time.js"; +import type { RuntimeNamespace } from "../types.js"; export type LlmProviderName = | "" @@ -105,6 +106,13 @@ export interface StorageConfig { sqlitePath?: string; endpoint?: string; token?: string; + /** Per-agent API keys accepted by the packaged service entry. Map key is the API key itself. */ + scopedApiKeys?: Record; +} + +export interface ScopedApiKeyConfig { + namespace: RuntimeNamespace; + scopes?: string[]; } export interface AgentAccessConfig { @@ -323,7 +331,8 @@ export const DEFAULT_MEMMY_CONFIG: MemmyConfig = { backend: "sqlite", sqlitePath: defaultMemoryDatabasePath(), endpoint: "http://127.0.0.1:18960", - token: undefined + token: undefined, + scopedApiKeys: undefined }, summary: { provider: "", @@ -752,10 +761,60 @@ function normalizeStorage(input: Record): StorageConfig { backend: storageBackend(input.backend, DEFAULT_MEMMY_CONFIG.storage.backend), sqlitePath: optionalPathString(input.sqlitePath), endpoint: optionalString(input.endpoint) ?? DEFAULT_MEMMY_CONFIG.storage.endpoint, - token: optionalString(input.token) + token: optionalString(input.token), + scopedApiKeys: normalizeScopedApiKeys(input.scopedApiKeys) + }; +} + +/** + * Parses `memmyMemory.storage.scopedApiKeys`. An entry is kept only when its + * namespace carries a non-empty `source` and `profileId`; malformed entries are + * dropped so an unusable key fails closed with 401 instead of granting access. + * A record that is present but entirely malformed still normalizes to an empty + * record so the entry point never falls back to anonymous access. + */ +function normalizeScopedApiKeys(input: unknown): StorageConfig["scopedApiKeys"] { + if (!isRecord(input)) return undefined; + const scopedApiKeys: Record = {}; + for (const [key, value] of Object.entries(input)) { + if (!key.trim() || !isRecord(value)) continue; + const namespace = scopedApiKeyNamespace(value.namespace); + if (!namespace) continue; + const scopes = scopedApiKeyScopes(value.scopes); + scopedApiKeys[key] = { + namespace, + ...(scopes ? { scopes } : {}) + }; + } + return scopedApiKeys; +} + +function scopedApiKeyNamespace(input: unknown): RuntimeNamespace | undefined { + if (!isRecord(input)) return undefined; + const source = optionalString(input.source); + const profileId = optionalString(input.profileId); + if (!source || !profileId) return undefined; + return { + source, + profileId, + profileLabel: optionalString(input.profileLabel), + projectId: optionalString(input.projectId), + workspaceId: optionalString(input.workspaceId), + workspacePath: optionalString(input.workspacePath), + sessionKey: optionalString(input.sessionKey), + userId: optionalString(input.userId), + tenantId: optionalString(input.tenantId) }; } +function scopedApiKeyScopes(input: unknown): string[] | undefined { + if (!Array.isArray(input)) return undefined; + const scopes = input.filter((scope): scope is string => + typeof scope === "string" && scope.trim().length > 0 + ); + return scopes.length > 0 ? scopes : undefined; +} + function normalizeLlm(input: Record, defaults: LlmConfig): LlmConfig { return { provider: llmProvider(input.provider, defaults.provider), diff --git a/Memory/src/server/index.ts b/Memory/src/server/index.ts index 6ce143515..089739ed5 100644 --- a/Memory/src/server/index.ts +++ b/Memory/src/server/index.ts @@ -5,10 +5,10 @@ import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import type { Server } from "node:http"; import { createStorageBackend, type StorageBackend } from "../storage/backend.js"; -import { loadMemmyConfig } from "../config/index.js"; +import { loadMemmyConfig, type MemmyConfig } from "../config/index.js"; import { createMemoryLogger, memoryErrorFields } from "../logging/logger.js"; import { MemoryService } from "../service/memory-service.js"; -import { closeMemoryHttpServer, listenMemoryHttpServer } from "./http.js"; +import { closeMemoryHttpServer, listenMemoryHttpServer, type MemoryHttpAuthOptions } from "./http.js"; import { loadCloudServiceEnv } from "../cli/load-env.js"; import { requestMemoryServiceRestart } from "./service-restart.js"; import { MEMORY_PROTOCOL_VERSION, MEMORY_SERVICE_VERSION } from "../version.js"; @@ -50,6 +50,27 @@ export async function main(argv = process.argv.slice(2)): Promise { } } +/** + * Builds the HTTP auth options for the packaged service entry. Precedence is + * `localServiceToken` (storage token, scope `*`) over `scopedApiKeys` over + * `allowAnonymous`: anonymous access is only offered when the operator + * configured neither credential type, so a configured scoped key can never + * silently fall back to an open service. + */ +export function memoryServiceAuth(config: MemmyConfig): MemoryHttpAuthOptions { + const auth: MemoryHttpAuthOptions = {}; + if (config.storage.token) { + auth.localServiceToken = config.storage.token; + } + if (config.storage.scopedApiKeys) { + auth.scopedApiKeys = config.storage.scopedApiKeys; + } + if (!auth.localServiceToken && !auth.scopedApiKeys) { + auth.allowAnonymous = true; + } + return auth; +} + async function runMemoryService(argv: string[], lifecycle: { shutdown(): void; setStopService(stop: () => void): void; @@ -113,9 +134,7 @@ async function runMemoryService(argv: string[], lifecycle: { requestShutdown(); } }), - auth: config.storage.token - ? { localServiceToken: config.storage.token } - : { allowAnonymous: true }, + auth: memoryServiceAuth(config), configPath, startAgentSourceAutomation: true }); diff --git a/Memory/tests/config.test.ts b/Memory/tests/config.test.ts index 0afb417ea..be21c1962 100644 --- a/Memory/tests/config.test.ts +++ b/Memory/tests/config.test.ts @@ -178,6 +178,57 @@ describe("memmy memory config", () => { expect(config.storage.sqlitePath).toBe(join(homedir(), ".memmy", "memory-service", "memory.sqlite")); }); + it("normalizes storage scoped api keys and drops malformed entries", () => { + const root = tempRoot(); + const configPath = join(root, "config.yaml"); + writeFileSync(configPath, YAML.stringify({ + memmyMemory: { + storage: { + endpoint: "http://127.0.0.1:18960", + scopedApiKeys: { + "valid-key": { + namespace: { source: "agent-a", profileId: "default" }, + scopes: ["memory:read", 42, " "] + }, + "missing-profile": { namespace: { source: "agent-b" } }, + "missing-namespace": { scopes: ["memory:read"] }, + "empty-namespace": { namespace: {}, scopes: ["memory:read"] }, + "scopeless-key": { namespace: { source: "agent-c", profileId: "default" } } + } + } + } + })); + + const { config } = loadMemmyConfig(configPath); + + expect(config.storage.scopedApiKeys).toEqual({ + "valid-key": { + namespace: { source: "agent-a", profileId: "default" }, + scopes: ["memory:read"] + }, + "scopeless-key": { + namespace: { source: "agent-c", profileId: "default" } + } + }); + + writeFileSync(configPath, YAML.stringify({ + memmyMemory: { + storage: { + endpoint: "http://127.0.0.1:18960", + scopedApiKeys: { "broken-key": { namespace: { source: "agent-d" } } } + } + } + })); + expect(loadMemmyConfig(configPath).config.storage.scopedApiKeys).toEqual({}); + + writeFileSync(configPath, YAML.stringify({ + memmyMemory: { + storage: { endpoint: "http://127.0.0.1:18960" } + } + })); + expect(loadMemmyConfig(configPath).config.storage.scopedApiKeys).toBeUndefined(); + }); + it("reads user id from memmyMemory config and environment aliases", () => { const root = tempRoot(); const configPath = join(root, "config.yaml"); diff --git a/Memory/tests/server-entrypoint.test.ts b/Memory/tests/server-entrypoint.test.ts index 98eb8b3d8..f9c727f61 100644 --- a/Memory/tests/server-entrypoint.test.ts +++ b/Memory/tests/server-entrypoint.test.ts @@ -142,6 +142,125 @@ describe("memmy memory server entrypoint", () => { rmSync(root, { recursive: true, force: true }); } }); + + it("authorizes configured scoped api keys and enforces their scopes", async () => { + const root = mkdtempSync(join(tmpdir(), "memmy-memory-scoped-auth-")); + const configPath = join(root, "config.yaml"); + writeFileSync(configPath, YAML.stringify({ memmyMemory: { + storage: { + mode: "local", + backend: "sqlite", + sqlitePath: join(root, "memory.sqlite"), + scopedApiKeys: { + "scoped-agent-a-key": { + namespace: { source: "agent-a", profileId: "default" }, + scopes: ["memory:read"] + } + } + } + } })); + const listen = vi.spyOn(memoryHttp, "listenMemoryHttpServer"); + const running = main(["--config", configPath, "--host", "127.0.0.1", "--port", "0"]); + try { + const endpoint = await waitForWrittenEndpoint(configPath); + const scopedHeaders = { "x-api-key": "scoped-agent-a-key" }; + + const readResponse = await fetch(`${endpoint}/api/v1/admin/memory-token-budget`, { + headers: scopedHeaders + }); + expect(readResponse.status).toBe(200); + + const writeResponse = await fetch(`${endpoint}/api/v1/memory/add`, { + method: "POST", + headers: { ...scopedHeaders, "content-type": "application/json" }, + body: "{}" + }); + expect(writeResponse.status).toBe(403); + } finally { + listen.mock.calls[0]?.[0].onShutdownRequested?.(); + await running; + vi.restoreAllMocks(); + rmSync(root, { recursive: true, force: true }); + } + }); + + it("refuses anonymous and unknown-token access when scoped api keys are configured", async () => { + const root = mkdtempSync(join(tmpdir(), "memmy-memory-scoped-anonymous-")); + const configPath = join(root, "config.yaml"); + writeFileSync(configPath, YAML.stringify({ memmyMemory: { + storage: { + mode: "local", + backend: "sqlite", + sqlitePath: join(root, "memory.sqlite"), + scopedApiKeys: { + "scoped-agent-a-key": { + namespace: { source: "agent-a", profileId: "default" }, + scopes: ["memory:read"] + } + } + } + } })); + const listen = vi.spyOn(memoryHttp, "listenMemoryHttpServer"); + const running = main(["--config", configPath, "--host", "127.0.0.1", "--port", "0"]); + try { + const endpoint = await waitForWrittenEndpoint(configPath); + const readUrl = `${endpoint}/api/v1/admin/memory-token-budget`; + + const anonymous = await fetch(readUrl); + expect(anonymous.status).toBe(401); + + const unknownToken = await fetch(readUrl, { headers: { "x-api-key": "not-a-configured-key" } }); + expect(unknownToken.status).toBe(401); + } finally { + listen.mock.calls[0]?.[0].onShutdownRequested?.(); + await running; + vi.restoreAllMocks(); + rmSync(root, { recursive: true, force: true }); + } + }); + + it("keeps the storage token authoritative when scoped api keys are configured too", async () => { + const root = mkdtempSync(join(tmpdir(), "memmy-memory-scoped-token-")); + const configPath = join(root, "config.yaml"); + writeFileSync(configPath, YAML.stringify({ memmyMemory: { + storage: { + mode: "local", + backend: "sqlite", + sqlitePath: join(root, "memory.sqlite"), + token: "local-admin-token", + scopedApiKeys: { + "scoped-agent-a-key": { + namespace: { source: "agent-a", profileId: "default" }, + scopes: ["memory:read"] + } + } + } + } })); + const listen = vi.spyOn(memoryHttp, "listenMemoryHttpServer"); + const running = main(["--config", configPath, "--host", "127.0.0.1", "--port", "0"]); + try { + const endpoint = await waitForWrittenEndpoint(configPath); + const readUrl = `${endpoint}/api/v1/admin/memory-token-budget`; + + const withToken = await fetch(readUrl, { + headers: { authorization: "Bearer local-admin-token" } + }); + expect(withToken.status).toBe(200); + + const withScopedKey = await fetch(readUrl, { + headers: { "x-api-key": "scoped-agent-a-key" } + }); + expect(withScopedKey.status).toBe(200); + + const anonymous = await fetch(readUrl); + expect(anonymous.status).toBe(401); + } finally { + listen.mock.calls[0]?.[0].onShutdownRequested?.(); + await running; + vi.restoreAllMocks(); + rmSync(root, { recursive: true, force: true }); + } + }); }); async function waitForWrittenEndpoint(configPath: string): Promise {