@pyxmate/memory 0.20.5 → 0.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +63 -34
- package/dist/chunk-7P6ASYW6.mjs +9 -0
- package/dist/cli/pyx-mem.mjs +15705 -0
- package/dist/dashboard.mjs +1 -0
- package/dist/index.mjs +1 -0
- package/dist/react.mjs +1 -0
- package/package.json +7 -4
- package/bin/init.mjs +0 -672
- package/skills/pyx-memory/SKILL.md +0 -128
- package/skills/pyx-memory/examples/disabled-memory.ts +0 -53
- package/skills/pyx-memory/examples/minimal-embedded.ts +0 -37
- package/skills/pyx-memory/examples/minimal-sidecar.ts +0 -14
- package/skills/pyx-memory/patterns/access-control.md +0 -586
- package/skills/pyx-memory/patterns/consumer.md +0 -129
- package/skills/pyx-memory/patterns/embedded.md +0 -249
- package/skills/pyx-memory/patterns/file-uploads.md +0 -78
- package/skills/pyx-memory/reference/advanced.md +0 -274
- package/skills/pyx-memory/reference/http-api.md +0 -526
- package/skills/pyx-memory/reference/parity.md +0 -74
- package/skills/pyx-memory/reference/sdk-guide.md +0 -233
- package/skills/pyx-memory/reference/types.md +0 -344
|
@@ -1,586 +0,0 @@
|
|
|
1
|
-
# Access Control Patterns
|
|
2
|
-
|
|
3
|
-
Production patterns for restricting who can read and write which memories.
|
|
4
|
-
|
|
5
|
-
## Contents
|
|
6
|
-
- [Quick Decision Tree](#quick-decision-tree)
|
|
7
|
-
- [Pattern 10: Per-Agent Isolation](#pattern-10-per-agent-isolation)
|
|
8
|
-
- [Pattern 11: Multi-Tenant with Team Scoping](#pattern-11-multi-tenant-with-team-scoping)
|
|
9
|
-
- [Pattern 12: Sensitivity-Based Read Restriction](#pattern-12-sensitivity-based-read-restriction)
|
|
10
|
-
- [Pattern 13: Read-Only vs Read-Write Access](#pattern-13-read-only-vs-read-write-access)
|
|
11
|
-
- [Pattern 14: Full Production Stack](#pattern-14-full-production-stack)
|
|
12
|
-
- [Pattern 15: Built-in ReBAC](#pattern-15-built-in-rebac-namespaces--tuples--authzplan) ← **start here for fine-grained per-user / per-team access**
|
|
13
|
-
|
|
14
|
-
---
|
|
15
|
-
|
|
16
|
-
## Quick Decision Tree
|
|
17
|
-
|
|
18
|
-
```
|
|
19
|
-
Need to isolate agents from each other?
|
|
20
|
-
→ Use agentId scoping (Pattern 10)
|
|
21
|
-
|
|
22
|
-
Need to isolate organizations/customers?
|
|
23
|
-
→ Use TENANT_MODE=multi + X-Tenant-Id (Pattern 11)
|
|
24
|
-
|
|
25
|
-
Need fine-grained per-user / per-team / per-folder access INSIDE a tenant?
|
|
26
|
-
→ Use built-in ReBAC: namespaces + principals + authz_tuples (Pattern 15)
|
|
27
|
-
|
|
28
|
-
Need to hide sensitive data from some users?
|
|
29
|
-
→ Use sensitivity classification + X-Caller-Access-Level (Pattern 12)
|
|
30
|
-
|
|
31
|
-
Need read-only vs read-write roles?
|
|
32
|
-
→ Use API_KEY + ADMIN_API_KEY + an API gateway (Pattern 13)
|
|
33
|
-
|
|
34
|
-
Need all of the above?
|
|
35
|
-
→ Pattern 14 (full production stack) + Pattern 15 (ReBAC)
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
---
|
|
39
|
-
|
|
40
|
-
## Pattern 10: Per-Agent Isolation
|
|
41
|
-
|
|
42
|
-
Each agent sees only its own memories. Other agents' memories are invisible — not redacted, just absent from results.
|
|
43
|
-
|
|
44
|
-
```typescript
|
|
45
|
-
// Agent A — can only see its own memories
|
|
46
|
-
const agentAMemory = new Memory({
|
|
47
|
-
dataDir: './data',
|
|
48
|
-
agentId: 'agent-researcher',
|
|
49
|
-
});
|
|
50
|
-
await agentAMemory.initialize();
|
|
51
|
-
|
|
52
|
-
// Anything agent A stores is tagged with agentId: 'agent-researcher'
|
|
53
|
-
await agentAMemory.store({
|
|
54
|
-
content: 'Found a bug in the auth module',
|
|
55
|
-
type: 'long-term',
|
|
56
|
-
metadata: {},
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
// Agent A's searches only return agent A's memories
|
|
60
|
-
const results = await agentAMemory.search({ query: 'auth bug' });
|
|
61
|
-
// ✓ Returns the entry above
|
|
62
|
-
|
|
63
|
-
// Agent B — completely isolated
|
|
64
|
-
const agentBMemory = new Memory({
|
|
65
|
-
dataDir: './data',
|
|
66
|
-
agentId: 'agent-writer',
|
|
67
|
-
});
|
|
68
|
-
await agentBMemory.initialize();
|
|
69
|
-
|
|
70
|
-
const bResults = await agentBMemory.search({ query: 'auth bug' });
|
|
71
|
-
// ✗ Returns nothing — agent A's memories are invisible to agent B
|
|
72
|
-
```
|
|
73
|
-
|
|
74
|
-
**Sidecar mode**: Pass `agentId` in the request body or query params — the server filters by it.
|
|
75
|
-
|
|
76
|
-
```bash
|
|
77
|
-
# Store as agent-researcher
|
|
78
|
-
curl -X POST http://localhost:7822/api/memory/ingest \
|
|
79
|
-
-H "Content-Type: application/json" \
|
|
80
|
-
-d '{"content":"Agent-scoped fact","type":"long-term","metadata":{},"agentId":"agent-researcher"}'
|
|
81
|
-
|
|
82
|
-
# Search as agent-researcher — only sees agent-researcher's memories
|
|
83
|
-
curl 'http://localhost:7822/api/memory/search?query=fact&agentId=agent-researcher'
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
**When to use**: Multiple AI agents sharing one memory instance, each needing their own knowledge base. Agents that should collaborate can omit `agentId` to share a global pool.
|
|
87
|
-
|
|
88
|
-
---
|
|
89
|
-
|
|
90
|
-
## Pattern 11: Multi-Tenant with Team Scoping
|
|
91
|
-
|
|
92
|
-
For SaaS / multi-organization deployments where each customer's data must be completely isolated.
|
|
93
|
-
|
|
94
|
-
### Server config
|
|
95
|
-
|
|
96
|
-
```bash
|
|
97
|
-
# docker-compose.yaml
|
|
98
|
-
environment:
|
|
99
|
-
- TENANT_MODE=multi # enforce X-Tenant-Id on ALL operations
|
|
100
|
-
- API_KEY=your-key # always set API_KEY with multi-tenant
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
### Per-client setup
|
|
104
|
-
|
|
105
|
-
```typescript
|
|
106
|
-
import { MemoryClient } from '@pyx-memory/client';
|
|
107
|
-
|
|
108
|
-
// Organization A — engineering team
|
|
109
|
-
const orgAEng = new MemoryClient('http://memory:7822', {
|
|
110
|
-
apiKey: process.env.MEMORY_API_KEY,
|
|
111
|
-
defaultHeaders: {
|
|
112
|
-
'X-Tenant-Id': 'org-acme',
|
|
113
|
-
'X-User-Id': 'alice',
|
|
114
|
-
'X-Team-Id': 'team-engineering',
|
|
115
|
-
},
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
// Organization A — marketing team (same tenant, different team)
|
|
119
|
-
const orgAMkt = new MemoryClient('http://memory:7822', {
|
|
120
|
-
apiKey: process.env.MEMORY_API_KEY,
|
|
121
|
-
defaultHeaders: {
|
|
122
|
-
'X-Tenant-Id': 'org-acme',
|
|
123
|
-
'X-User-Id': 'bob',
|
|
124
|
-
'X-Team-Id': 'team-marketing',
|
|
125
|
-
},
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
// Organization B — completely isolated tenant
|
|
129
|
-
const orgB = new MemoryClient('http://memory:7822', {
|
|
130
|
-
apiKey: process.env.MEMORY_API_KEY,
|
|
131
|
-
defaultHeaders: {
|
|
132
|
-
'X-Tenant-Id': 'org-globex',
|
|
133
|
-
'X-User-Id': 'charlie',
|
|
134
|
-
},
|
|
135
|
-
});
|
|
136
|
-
```
|
|
137
|
-
|
|
138
|
-
### What's enforced
|
|
139
|
-
|
|
140
|
-
| Scope level | How it works |
|
|
141
|
-
|-------------|-------------|
|
|
142
|
-
| `tenantId` | **Hard isolation.** Tenant A never sees tenant B's data. Enforced server-side when `TENANT_MODE=multi`. |
|
|
143
|
-
| `teamId` | **Soft scoping.** Stored on entries but not enforced by the server. Use application-layer filtering. |
|
|
144
|
-
| `userId` | **Soft scoping.** Stored on entries but not enforced by the server. Use application-layer filtering. |
|
|
145
|
-
| `agentId` | **Hard filtering.** When set in MemoryOptions or query params, only that agent's entries are returned. |
|
|
146
|
-
|
|
147
|
-
**Key rule**: `TENANT_MODE=multi` enforces tenantId. userId and teamId are for your application to filter — pyx-memory stores them but does not enforce boundaries between users/teams within a tenant.
|
|
148
|
-
|
|
149
|
-
---
|
|
150
|
-
|
|
151
|
-
## Pattern 12: Sensitivity-Based Read Restriction
|
|
152
|
-
|
|
153
|
-
Control which memories are visible based on the caller's access level. Entries classified above the caller's level are automatically redacted in search results.
|
|
154
|
-
|
|
155
|
-
### How sensitivity classification works
|
|
156
|
-
|
|
157
|
-
Every `store()` automatically scans content and classifies it:
|
|
158
|
-
|
|
159
|
-
| Level | Auto-detected when | Example |
|
|
160
|
-
|-------|-------------------|---------|
|
|
161
|
-
| `public` | No credentials or PII detected | "Prefer tabs over spaces" |
|
|
162
|
-
| `internal` | PII found (email, phone, SSN, etc.) | "Alice's email is alice@acme.com" |
|
|
163
|
-
| `secret` | Credentials found (API keys, tokens, passwords) | "The API key is sk-ant-..." |
|
|
164
|
-
|
|
165
|
-
### Restricting search results
|
|
166
|
-
|
|
167
|
-
```typescript
|
|
168
|
-
// Embedded: use maxSensitivity search param
|
|
169
|
-
const results = await memory.search({
|
|
170
|
-
query: 'config',
|
|
171
|
-
maxSensitivity: 'internal', // only public + internal; secret entries are redacted
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
// Sidecar: use X-Caller-Access-Level header
|
|
175
|
-
const restricted = new MemoryClient('http://memory:7822', {
|
|
176
|
-
apiKey: process.env.MEMORY_API_KEY,
|
|
177
|
-
defaultHeaders: {
|
|
178
|
-
'X-Caller-Access-Level': 'internal', // this client never sees secret entries
|
|
179
|
-
},
|
|
180
|
-
});
|
|
181
|
-
```
|
|
182
|
-
|
|
183
|
-
### What "redacted" means
|
|
184
|
-
|
|
185
|
-
When a search returns entries above the caller's access level, the content is replaced with `[REDACTED: sensitive content]`. The entry metadata (id, type, createdAt) is still visible so the caller knows something exists — they just can't read it.
|
|
186
|
-
|
|
187
|
-
### Sensitivity policy options
|
|
188
|
-
|
|
189
|
-
| `SENSITIVITY_POLICY` | Behavior |
|
|
190
|
-
|----------------------|----------|
|
|
191
|
-
| `flag` (default) | Classify and store sensitivity level. No content modification. |
|
|
192
|
-
| `redact` | Replace detected credentials with `[REDACTED]` before storage. |
|
|
193
|
-
| `block` | Reject any ingest containing credentials (HTTP 400). |
|
|
194
|
-
| `encrypt` | Encrypt `secret` entries at rest with AES-256-GCM. Transparent decrypt on read when key is available. |
|
|
195
|
-
|
|
196
|
-
```bash
|
|
197
|
-
# Production config with encryption
|
|
198
|
-
environment:
|
|
199
|
-
- SENSITIVITY_POLICY=encrypt
|
|
200
|
-
- ENCRYPTION_KEY=your-64-hex-char-key # 32 bytes as hex
|
|
201
|
-
```
|
|
202
|
-
|
|
203
|
-
---
|
|
204
|
-
|
|
205
|
-
## Pattern 13: Read-Only vs Read-Write Access
|
|
206
|
-
|
|
207
|
-
pyx-memory has two auth tiers: `API_KEY` (read + write) and `ADMIN_API_KEY` (destructive ops). For finer read-only vs read-write control, use an API gateway or proxy layer in front.
|
|
208
|
-
|
|
209
|
-
### Built-in auth tiers
|
|
210
|
-
|
|
211
|
-
| Key | Allowed operations |
|
|
212
|
-
|-----|-------------------|
|
|
213
|
-
| `API_KEY` | All read operations (search, get, list, stats) + write operations (store, ingestFileEvents) |
|
|
214
|
-
| `ADMIN_API_KEY` | Destructive operations: DELETE, forget, decay, consolidate, reindex, deleteBySource |
|
|
215
|
-
|
|
216
|
-
```bash
|
|
217
|
-
# Give agents API_KEY (read + write), give admin tools ADMIN_API_KEY
|
|
218
|
-
environment:
|
|
219
|
-
- API_KEY=agent-key-for-read-write
|
|
220
|
-
- ADMIN_API_KEY=admin-key-for-destructive-ops
|
|
221
|
-
```
|
|
222
|
-
|
|
223
|
-
### Adding read-only access via a proxy
|
|
224
|
-
|
|
225
|
-
pyx-memory doesn't have a native "read-only API key." To implement read-only roles, put a reverse proxy (nginx, Caddy, API gateway) in front:
|
|
226
|
-
|
|
227
|
-
```nginx
|
|
228
|
-
# nginx example: read-only role blocks POST/PUT/DELETE
|
|
229
|
-
location /api/memory/ {
|
|
230
|
-
# Read-only key can only GET
|
|
231
|
-
if ($http_authorization = "Bearer readonly-key") {
|
|
232
|
-
limit_except GET {
|
|
233
|
-
deny all;
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
proxy_pass http://memory:7822;
|
|
237
|
-
}
|
|
238
|
-
```
|
|
239
|
-
|
|
240
|
-
Or implement it in your application layer:
|
|
241
|
-
|
|
242
|
-
```typescript
|
|
243
|
-
// Application-level access control middleware
|
|
244
|
-
function memoryAccessMiddleware(req: Request, userRole: string): boolean {
|
|
245
|
-
const method = req.method;
|
|
246
|
-
const isWrite = method === 'POST' || method === 'PUT' || method === 'DELETE';
|
|
247
|
-
|
|
248
|
-
if (userRole === 'viewer' && isWrite) {
|
|
249
|
-
throw new ForbiddenError('Read-only access — cannot modify memory');
|
|
250
|
-
}
|
|
251
|
-
if (userRole === 'editor' && req.url.includes('/consolidate')) {
|
|
252
|
-
throw new ForbiddenError('Editors cannot run consolidation');
|
|
253
|
-
}
|
|
254
|
-
return true;
|
|
255
|
-
}
|
|
256
|
-
```
|
|
257
|
-
|
|
258
|
-
---
|
|
259
|
-
|
|
260
|
-
## Pattern 14: Full Production Stack
|
|
261
|
-
|
|
262
|
-
Combines all patterns for a production multi-tenant deployment with sensitivity, encryption, and the built-in two-tier API key model (`API_KEY` for read+write, `ADMIN_API_KEY` for destructive ops). For fine-grained per-user/per-namespace access inside a tenant, layer Pattern 15 (ReBAC) on top of this base.
|
|
263
|
-
|
|
264
|
-
### Server config
|
|
265
|
-
|
|
266
|
-
```yaml
|
|
267
|
-
# docker-compose.yaml
|
|
268
|
-
services:
|
|
269
|
-
memory:
|
|
270
|
-
image: ghcr.io/pyx-corp/pyx-memory-v1:latest
|
|
271
|
-
environment:
|
|
272
|
-
- DATA_DIR=/data
|
|
273
|
-
- API_KEY=${MEMORY_API_KEY}
|
|
274
|
-
- ADMIN_API_KEY=${MEMORY_ADMIN_KEY}
|
|
275
|
-
- TENANT_MODE=multi
|
|
276
|
-
- SENSITIVITY_POLICY=encrypt
|
|
277
|
-
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
|
278
|
-
- PII_POLICY=flag
|
|
279
|
-
- RATE_LIMIT_RPM=120
|
|
280
|
-
- CORS_ORIGIN=https://app.example.com
|
|
281
|
-
- NODE_ENV=production
|
|
282
|
-
```
|
|
283
|
-
|
|
284
|
-
### Client setup per role
|
|
285
|
-
|
|
286
|
-
```typescript
|
|
287
|
-
import { MemoryClient } from '@pyx-memory/client';
|
|
288
|
-
|
|
289
|
-
const MEMORY_URL = 'http://memory:7822';
|
|
290
|
-
|
|
291
|
-
// Admin client — full access, destructive ops allowed
|
|
292
|
-
function createAdminClient(tenantId: string) {
|
|
293
|
-
return new MemoryClient(MEMORY_URL, {
|
|
294
|
-
apiKey: process.env.MEMORY_ADMIN_KEY,
|
|
295
|
-
defaultHeaders: {
|
|
296
|
-
'X-Tenant-Id': tenantId,
|
|
297
|
-
'X-Caller-Access-Level': 'secret',
|
|
298
|
-
},
|
|
299
|
-
});
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
// Agent client — read + write, scoped to agent + tenant
|
|
303
|
-
function createAgentClient(tenantId: string, agentId: string) {
|
|
304
|
-
return new MemoryClient(MEMORY_URL, {
|
|
305
|
-
apiKey: process.env.MEMORY_API_KEY,
|
|
306
|
-
defaultHeaders: {
|
|
307
|
-
'X-Tenant-Id': tenantId,
|
|
308
|
-
'X-Caller-Access-Level': 'internal', // can't see secret entries
|
|
309
|
-
},
|
|
310
|
-
});
|
|
311
|
-
// Note: agentId is passed per-request, not via headers
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
// Dashboard client — read-only (enforce at application layer)
|
|
315
|
-
function createDashboardClient(tenantId: string, userId: string) {
|
|
316
|
-
return new MemoryClient(MEMORY_URL, {
|
|
317
|
-
apiKey: process.env.MEMORY_API_KEY,
|
|
318
|
-
defaultHeaders: {
|
|
319
|
-
'X-Tenant-Id': tenantId,
|
|
320
|
-
'X-User-Id': userId,
|
|
321
|
-
'X-Caller-Access-Level': 'public', // most restricted view
|
|
322
|
-
},
|
|
323
|
-
});
|
|
324
|
-
}
|
|
325
|
-
```
|
|
326
|
-
|
|
327
|
-
### What each role sees
|
|
328
|
-
|
|
329
|
-
| Role | tenantId | agentId | maxSensitivity | Write | Delete/Admin |
|
|
330
|
-
|------|----------|---------|----------------|-------|-------------|
|
|
331
|
-
| Admin | scoped | all | secret | yes | yes (ADMIN_API_KEY) |
|
|
332
|
-
| Agent | scoped | scoped | internal | yes | no |
|
|
333
|
-
| Dashboard | scoped | all | public | no (app-enforced) | no |
|
|
334
|
-
|
|
335
|
-
---
|
|
336
|
-
|
|
337
|
-
## Pattern 15: Built-in ReBAC (namespaces + tuples + AuthzPlan)
|
|
338
|
-
|
|
339
|
-
For fine-grained access control inside a tenant — "alice can read project-X but not project-Y", "team-eng has editor on engineering/*", "revoke without entry rewrite" — pyx-memory ships first-class ReBAC primitives. No external policy service required for v1; the model is Zanzibar-aligned so you can later export to OpenFGA / SpiceDB without changing application code.
|
|
340
|
-
|
|
341
|
-
### The four primitives
|
|
342
|
-
|
|
343
|
-
| Resource | Role |
|
|
344
|
-
|----------|------|
|
|
345
|
-
| `namespace` | The unit you grant access to (folders, projects, channels). Hierarchical via `parentId`. Granting on a parent transitively covers descendants. |
|
|
346
|
-
| `principal` | A subject — `user`, `team`, `group`, `agent`, `service`, or per-tenant `everyone`. Identified by `(tenantId, kind, externalId)`. |
|
|
347
|
-
| `principal_member` | Membership edge `member ∈ group`. Group-of-groups supported (cycles rejected, max-depth-8 namespaces). |
|
|
348
|
-
| `authz_tuple` | The grant: `(subject, relation, object)` — e.g. `(user:alice, viewer, namespace:proj-acme)`. Built-in rewrite: `owner ⊇ editor ⊇ viewer`. |
|
|
349
|
-
|
|
350
|
-
### Server config
|
|
351
|
-
|
|
352
|
-
```yaml
|
|
353
|
-
# docker-compose.yaml
|
|
354
|
-
environment:
|
|
355
|
-
- TENANT_MODE=multi
|
|
356
|
-
- API_KEY=${MEMORY_API_KEY}
|
|
357
|
-
- ADMIN_API_KEY=${MEMORY_ADMIN_KEY} # required to manage namespaces / tuples
|
|
358
|
-
```
|
|
359
|
-
|
|
360
|
-
### Manage namespaces, principals, and tuples (admin API)
|
|
361
|
-
|
|
362
|
-
All `/api/admin/*` routes require `ADMIN_API_KEY` and `X-Tenant-Id`.
|
|
363
|
-
|
|
364
|
-
```bash
|
|
365
|
-
# 1. Create a namespace (the resource you'll grant access to)
|
|
366
|
-
curl -X POST http://memory:7822/api/admin/namespaces \
|
|
367
|
-
-H "Authorization: Bearer $ADMIN_KEY" \
|
|
368
|
-
-H "X-Tenant-Id: tenant-acme" \
|
|
369
|
-
-H "Content-Type: application/json" \
|
|
370
|
-
-d '{"name":"engineering"}'
|
|
371
|
-
# → {"id":"<NS_ID>", ...}
|
|
372
|
-
|
|
373
|
-
# 2. Register a principal (idempotent on tenant + kind + externalId)
|
|
374
|
-
curl -X POST http://memory:7822/api/admin/principals \
|
|
375
|
-
-H "Authorization: Bearer $ADMIN_KEY" \
|
|
376
|
-
-H "X-Tenant-Id: tenant-acme" \
|
|
377
|
-
-H "Content-Type: application/json" \
|
|
378
|
-
-d '{"kind":"user","externalId":"alice","displayName":"Alice"}'
|
|
379
|
-
# → {"id":"<ALICE_ID>", ...}
|
|
380
|
-
|
|
381
|
-
# 3. Grant alice viewer on engineering
|
|
382
|
-
curl -X POST http://memory:7822/api/admin/authz-tuples \
|
|
383
|
-
-H "Authorization: Bearer $ADMIN_KEY" \
|
|
384
|
-
-H "X-Tenant-Id: tenant-acme" \
|
|
385
|
-
-H "Content-Type: application/json" \
|
|
386
|
-
-d '{
|
|
387
|
-
"subjectKind":"user","subjectId":"<ALICE_ID>",
|
|
388
|
-
"relation":"viewer",
|
|
389
|
-
"objectKind":"namespace","objectId":"<NS_ID>"
|
|
390
|
-
}'
|
|
391
|
-
|
|
392
|
-
# 4. Revoke (one row delete — no entry rewrite, takes effect immediately)
|
|
393
|
-
curl -X DELETE http://memory:7822/api/admin/authz-tuples \
|
|
394
|
-
-H "Authorization: Bearer $ADMIN_KEY" \
|
|
395
|
-
-H "X-Tenant-Id: tenant-acme" \
|
|
396
|
-
-H "Content-Type: application/json" \
|
|
397
|
-
-d '{ ...same body as the grant... }'
|
|
398
|
-
```
|
|
399
|
-
|
|
400
|
-
### Make something public to the whole tenant
|
|
401
|
-
|
|
402
|
-
```bash
|
|
403
|
-
# Resolve the per-tenant `everyone` principal (auto-created)
|
|
404
|
-
curl -X POST http://memory:7822/api/admin/principals \
|
|
405
|
-
-H "Authorization: Bearer $ADMIN_KEY" \
|
|
406
|
-
-H "X-Tenant-Id: tenant-acme" \
|
|
407
|
-
-H "Content-Type: application/json" \
|
|
408
|
-
-d '{"kind":"everyone","externalId":"_everyone"}'
|
|
409
|
-
|
|
410
|
-
# Grant `everyone` viewer on the announcements namespace
|
|
411
|
-
curl -X POST http://memory:7822/api/admin/authz-tuples \
|
|
412
|
-
-H "Authorization: Bearer $ADMIN_KEY" \
|
|
413
|
-
-H "X-Tenant-Id: tenant-acme" \
|
|
414
|
-
-H "Content-Type: application/json" \
|
|
415
|
-
-d '{
|
|
416
|
-
"subjectKind":"everyone","subjectId":"<EVERYONE_ID>",
|
|
417
|
-
"relation":"viewer",
|
|
418
|
-
"objectKind":"namespace","objectId":"<NS_ID>"
|
|
419
|
-
}'
|
|
420
|
-
```
|
|
421
|
-
|
|
422
|
-
Reversing later is the same — delete the tuple, visibility flips back the next request.
|
|
423
|
-
|
|
424
|
-
### Search-time enforcement
|
|
425
|
-
|
|
426
|
-
Pass the calling principal on `Memory.search()`. The server computes an `AuthzPlan` (visible namespaces + cached revision) BEFORE any retrieval source fans out. SQLite/FTS, LanceDB vector search, and the graph engine all apply the plan as a native pre-filter — forbidden entries never enter RRF fusion or reranker scoring (which would skew normalization for everyone).
|
|
427
|
-
|
|
428
|
-
```typescript
|
|
429
|
-
const result = await memory.search({
|
|
430
|
-
query: 'Q4 revenue',
|
|
431
|
-
strategy: 'hybrid',
|
|
432
|
-
principal: {
|
|
433
|
-
tenantId: 'tenant-acme',
|
|
434
|
-
principalId: 'alice',
|
|
435
|
-
kind: 'user',
|
|
436
|
-
},
|
|
437
|
-
});
|
|
438
|
-
// result.entries contains only namespaces alice can `view`,
|
|
439
|
-
// plus legacy NULL-namespace entries (tenant-root bucket).
|
|
440
|
-
```
|
|
441
|
-
|
|
442
|
-
### Legacy compatibility
|
|
443
|
-
|
|
444
|
-
Entries with `namespace_id IS NULL` (the default before ReBAC) remain visible to anyone with tenant access. You opt entries into ReBAC by setting `namespaceId` at ingest:
|
|
445
|
-
|
|
446
|
-
```typescript
|
|
447
|
-
await memory.store({
|
|
448
|
-
content: 'Q4 revenue projections',
|
|
449
|
-
type: 'long-term',
|
|
450
|
-
metadata: {},
|
|
451
|
-
namespaceId: '<NS_ID>',
|
|
452
|
-
});
|
|
453
|
-
```
|
|
454
|
-
|
|
455
|
-
HTTP ingest accepts the same resource coordinate on JSON and file uploads:
|
|
456
|
-
|
|
457
|
-
```bash
|
|
458
|
-
curl -X POST "$MEMORY_URL/api/memory/ingest" \
|
|
459
|
-
-H "Authorization: Bearer $API_KEY" \
|
|
460
|
-
-H "X-Tenant-Id: tenant-acme" \
|
|
461
|
-
-H "X-Namespace-Id: <NS_ID>" \
|
|
462
|
-
-H "Content-Type: application/json" \
|
|
463
|
-
-d '{"content":"Q4 revenue projections","type":"long-term"}'
|
|
464
|
-
|
|
465
|
-
curl -N -X POST "$MEMORY_URL/api/memory/ingest/file" \
|
|
466
|
-
-H "Authorization: Bearer $API_KEY" \
|
|
467
|
-
-H "X-Tenant-Id: tenant-acme" \
|
|
468
|
-
-H "X-Namespace-Id: <NS_ID>" \
|
|
469
|
-
-F "file=@report.pdf"
|
|
470
|
-
```
|
|
471
|
-
|
|
472
|
-
`X-Namespace-Id` is canonical when both header and body/form field are sent; conflicting values return `400 namespace_id_conflict`. A missing or cross-tenant namespace returns `404 namespace_not_found`.
|
|
473
|
-
|
|
474
|
-
No bulk migration is required — legacy data keeps working unchanged.
|
|
475
|
-
|
|
476
|
-
### Migrating legacy entries (v0.16.1)
|
|
477
|
-
|
|
478
|
-
When you _do_ want to move pre-ReBAC entries (or reorganize an existing namespace tree), the admin entry-move API coordinates SQLite + vector + graph for you:
|
|
479
|
-
|
|
480
|
-
```bash
|
|
481
|
-
# Single entry: move one row to a target namespace.
|
|
482
|
-
curl -X POST "$ENDPOINT/api/admin/entries/$ENTRY_ID/move" \
|
|
483
|
-
-H "Authorization: Bearer $ADMIN_API_KEY" \
|
|
484
|
-
-H "X-Tenant-Id: $TENANT" \
|
|
485
|
-
-H "Content-Type: application/json" \
|
|
486
|
-
-d '{"namespaceId": "<NS_ID>"}'
|
|
487
|
-
# null reverts to tenant-root (the legacy / pre-ReBAC bucket).
|
|
488
|
-
```
|
|
489
|
-
|
|
490
|
-
Batch moves accept a filter and return cursor-paginated `MoveResult` rows. Always preview with `dryRun: true` first — the same filter + cursor drives both the dryRun preview and the execute pass, so what you see in dryRun is exactly what executes:
|
|
491
|
-
|
|
492
|
-
```bash
|
|
493
|
-
# Dry-run: see which rows would move, without touching any store.
|
|
494
|
-
curl -X POST "$ENDPOINT/api/admin/entries/move-batch" \
|
|
495
|
-
-H "Authorization: Bearer $ADMIN_API_KEY" \
|
|
496
|
-
-H "X-Tenant-Id: $TENANT" \
|
|
497
|
-
-H "Content-Type: application/json" \
|
|
498
|
-
-d '{
|
|
499
|
-
"filter": { "fromNamespaceId": null, "source": "legacy-import.csv" },
|
|
500
|
-
"target": { "namespaceId": "<NS_ID>" },
|
|
501
|
-
"dryRun": true,
|
|
502
|
-
"limit": 100
|
|
503
|
-
}'
|
|
504
|
-
# Response includes results[] + nextCursor for paging.
|
|
505
|
-
|
|
506
|
-
# Execute: same body without dryRun (or dryRun: false).
|
|
507
|
-
curl -X POST "$ENDPOINT/api/admin/entries/move-batch" \
|
|
508
|
-
-H "Authorization: Bearer $ADMIN_API_KEY" \
|
|
509
|
-
-H "X-Tenant-Id: $TENANT" \
|
|
510
|
-
-H "Content-Type: application/json" \
|
|
511
|
-
-d '{
|
|
512
|
-
"filter": { "fromNamespaceId": null, "source": "legacy-import.csv" },
|
|
513
|
-
"target": { "namespaceId": "<NS_ID>" },
|
|
514
|
-
"limit": 100
|
|
515
|
-
}'
|
|
516
|
-
```
|
|
517
|
-
|
|
518
|
-
What the move guarantees:
|
|
519
|
-
|
|
520
|
-
- **3-store coordination.** SQLite + vector + graph (when registered) all see the new `namespace_id`. On per-store failure the earlier writes are reverted in reverse order so partial states do not survive — the `MoveResult.failureReason` tells you which store rejected the move (`vector_update_failed`, `graph_update_failed`, etc.).
|
|
521
|
-
- **Cross-tenant safety.** A move whose source entry belongs to a different tenant is rejected with `cross_tenant_forbidden`. A move whose target namespace belongs to a different tenant is rejected with `target_namespace_not_found` — same code as a missing namespace, deliberately, to avoid telling the caller "this namespace exists but somewhere else" (existence-disclosure guard).
|
|
522
|
-
- **Authz revision bump.** Every successful move bumps the per-tenant AuthzPlan revision, so any cached plan keyed on `(principal, revision)` invalidates immediately.
|
|
523
|
-
- **Footgun guard on batch.** `filter.fromNamespaceId` is required — there is no "move everything I can see" shorthand by design.
|
|
524
|
-
|
|
525
|
-
`MoveFailureReason.compensation_failed` is the only deterministic-manual-recovery case: the forward write succeeded but the rollback failed. The response includes both error messages so an operator has enough detail to inspect each store directly.
|
|
526
|
-
|
|
527
|
-
### Strict topology isolation (v0.17.0)
|
|
528
|
-
|
|
529
|
-
By default a namespace is `shared` — its edges remain visible across the tenant whenever AuthzPlan grants access via any path. The KG layer dedupes nodes globally so edges from different namespaces co-exist on the same node, and `shared` lets that cross-namespace visibility through.
|
|
530
|
-
|
|
531
|
-
Set a namespace to `strict` and its edges become invisible to any principal who does not hold an explicit grant on it, even when the underlying KG node is reachable through a shared neighbor. KG dedupe at the node stays intact — the strictness is enforced edge-by-edge in the AuthzPlan + graph traversal WHERE, not by splitting nodes.
|
|
532
|
-
|
|
533
|
-
```bash
|
|
534
|
-
# Flip a namespace to strict.
|
|
535
|
-
curl -X POST "$ENDPOINT/api/admin/namespaces/$NS_ID/isolation" \
|
|
536
|
-
-H "Authorization: Bearer $ADMIN_API_KEY" \
|
|
537
|
-
-H "X-Tenant-Id: $TENANT" \
|
|
538
|
-
-H "Content-Type: application/json" \
|
|
539
|
-
-d '{"isolation":"strict"}'
|
|
540
|
-
# {"namespace": {... "isolation": "strict"}, "changed": true}
|
|
541
|
-
|
|
542
|
-
# Flip back to shared.
|
|
543
|
-
curl -X POST "$ENDPOINT/api/admin/namespaces/$NS_ID/isolation" \
|
|
544
|
-
-H "Authorization: Bearer $ADMIN_API_KEY" \
|
|
545
|
-
-H "X-Tenant-Id: $TENANT" \
|
|
546
|
-
-H "Content-Type: application/json" \
|
|
547
|
-
-d '{"isolation":"shared"}'
|
|
548
|
-
```
|
|
549
|
-
|
|
550
|
-
Both flips bump the per-tenant AuthzPlan revision so cached plans invalidate on the next request — no manual cache invalidation step. A no-op flip (already in the requested mode) returns `changed: false` and leaves the revision untouched.
|
|
551
|
-
|
|
552
|
-
What strict guarantees:
|
|
553
|
-
|
|
554
|
-
- **Edge-level scoping.** SQLiteGraph WHERE clause + Neo4j post-filter drop edges whose `namespace_id` is in `AuthzPlan.forbiddenStrictNamespaceIds`. Legacy NULL-namespace edges (pre-v0.16.1 ingest) keep tenant-root visibility regardless.
|
|
555
|
-
- **Bulk endpoint visibility.** `/api/memory/graph/relationships` filters at the HTTP boundary too. `/api/memory/graph/nodes` keeps only nodes that have at least one visible memoryEntryId, intersects the projection's memoryEntryIds with the visible set, and strips provenance properties (`source`, `sourceUrl`, `sourceTitle`, etc.) so a cross-namespace node cannot leak originating-namespace metadata via property bag.
|
|
556
|
-
- **Hybrid RAG fail-loud.** Graph + community retrieval no longer swallow backend errors. A graph-store outage propagates as `MemorySearchError` so the operator sees it at the request boundary instead of as drifting RRF gaps.
|
|
557
|
-
|
|
558
|
-
### Idempotent admin mutations (v0.17.0)
|
|
559
|
-
|
|
560
|
-
The audit-outbox sweeper retries 5xx mutations until each lands. Set `X-Idempotency-Key` on every admin mutation request so a retry on the same key returns the recorded response without re-executing the handler:
|
|
561
|
-
|
|
562
|
-
```bash
|
|
563
|
-
KEY=$(uuidgen)
|
|
564
|
-
curl -X POST "$ENDPOINT/api/admin/namespaces" \
|
|
565
|
-
-H "Authorization: Bearer $ADMIN_API_KEY" \
|
|
566
|
-
-H "X-Tenant-Id: $TENANT" \
|
|
567
|
-
-H "X-Idempotency-Key: $KEY" \
|
|
568
|
-
-H "Content-Type: application/json" \
|
|
569
|
-
-d '{"name":"engineering"}'
|
|
570
|
-
```
|
|
571
|
-
|
|
572
|
-
24h TTL. GET / OPTIONS bypass the wrapper entirely (read-only methods are already safe to retry). Pre-existing entries are dedupe-keyed by `key` (not by request body), so a sweeper retry with the original payload + key replays the original response even if the underlying state has since changed.
|
|
573
|
-
|
|
574
|
-
### Why not metadata + post-filter?
|
|
575
|
-
|
|
576
|
-
The pre-PR-D pattern was "tag entries with `metadata.allowedTeams`, filter results in your gateway". That breaks RAG retrieval:
|
|
577
|
-
|
|
578
|
-
1. **Ranking pollution** — RRF fusion and reranker interpolation normalize against the candidate set. If forbidden entries enter retrieval, they shift the scoring for the entries the caller IS allowed to see.
|
|
579
|
-
2. **Confidence corruption** — abstention scores depend on score variance over the candidate set. Hidden entries change the variance.
|
|
580
|
-
3. **Write amplification** — changing a role means rewriting metadata on every affected chunk.
|
|
581
|
-
|
|
582
|
-
ReBAC tuples solve all three: pre-filter at the SQL/vector layer, single-row mutations, no entry rewrites.
|
|
583
|
-
|
|
584
|
-
### Next-scale: external PDP
|
|
585
|
-
|
|
586
|
-
When you outgrow the in-process tuple store (~10M tuples, or you need cross-tenant sharing, or distributed enforcement), the tuple format is Zanzibar-compatible — export to OpenFGA or SpiceDB and swap the `AuthzPlan` compute path to call their `ListObjects` API. The retrieval-engine code does not change.
|