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 {