memgres 0.7.2__tar.gz → 0.9.0__tar.gz
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.
- {memgres-0.7.2 → memgres-0.9.0}/PKG-INFO +8 -6
- {memgres-0.7.2 → memgres-0.9.0}/README.md +7 -5
- {memgres-0.7.2 → memgres-0.9.0}/memgres/_version.py +1 -1
- {memgres-0.7.2 → memgres-0.9.0}/memgres/admin.py +69 -3
- {memgres-0.7.2 → memgres-0.9.0}/memgres/config.py +12 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres/identity.py +199 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres/info.py +24 -2
- {memgres-0.7.2 → memgres-0.9.0}/memgres/mcp_server.py +304 -156
- memgres-0.9.0/memgres/migrations/0009_create_namespace_right.sql +37 -0
- memgres-0.9.0/memgres/migrations/0021_enrollment_key.sql +40 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres/schema.py +1 -1
- {memgres-0.7.2 → memgres-0.9.0}/memgres/server.py +54 -1
- memgres-0.9.0/memgres/token_cli.py +206 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres.egg-info/PKG-INFO +8 -6
- {memgres-0.7.2 → memgres-0.9.0}/memgres.egg-info/SOURCES.txt +4 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres.egg-info/entry_points.txt +1 -0
- {memgres-0.7.2 → memgres-0.9.0}/pyproject.toml +1 -0
- memgres-0.9.0/tests/test_enrollment.py +338 -0
- {memgres-0.7.2 → memgres-0.9.0}/tests/test_mcp_admin_tools.py +112 -83
- {memgres-0.7.2 → memgres-0.9.0}/tests/test_mcp_tool_visibility.py +21 -3
- {memgres-0.7.2 → memgres-0.9.0}/tests/test_mcp_tool_visibility_http.py +95 -3
- {memgres-0.7.2 → memgres-0.9.0}/tests/test_server_info.py +45 -1
- {memgres-0.7.2 → memgres-0.9.0}/tests/test_server_integration.py +6 -1
- memgres-0.9.0/tests/test_token_sink.py +204 -0
- memgres-0.7.2/memgres/migrations/0009_create_namespace_right.sql +0 -23
- {memgres-0.7.2 → memgres-0.9.0}/LICENSE +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres/__init__.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres/admin_cli.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres/blame.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres/bootstrap.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres/delimiters.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres/diffing.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres/embed_worker.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres/embeddings.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres/healthcheck.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres/indexing.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres/lines.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres/links.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres/migrations/0001_core.sql +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres/migrations/0002_identity.sql +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres/migrations/0003_history_author.sql +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres/migrations/0004_title.sql +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres/migrations/0005_chunk_index.sql +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres/migrations/0006_reader_floor.sql +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres/migrations/0007_embed_retry.sql +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres/migrations/0008_service_roles.sql +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres/migrations/0010_namespace_alias.sql +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres/migrations/0011_drop_default_namespace.sql +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres/migrations/0012_user_profile.sql +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres/migrations/0013_hash_version.sql +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres/migrations/0014_access_request_no_fk.sql +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres/migrations/0015_normalize_tags.sql +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres/migrations/0016_valid_at.sql +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres/migrations/0017_memory_link.sql +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres/migrations/0018_links_built.sql +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres/migrations/0019_memory_usage.sql +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres/migrations/0020_memory_usage_no_fk.sql +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres/periodic.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres/reembed.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres/relink.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres/search.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres/segments.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres/store.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres/tags.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres/vector/__init__.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres/vector/base.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres/vector/pgvector.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres/vector/qdrant.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres/worker.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres.egg-info/dependency_links.txt +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres.egg-info/requires.txt +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/memgres.egg-info/top_level.txt +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/setup.cfg +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/tests/test_blame_integration.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/tests/test_chunk_index.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/tests/test_claim_and_reembed.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/tests/test_config.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/tests/test_diffing.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/tests/test_embed_worker.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/tests/test_embeddings.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/tests/test_healthcheck.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/tests/test_identity_integration.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/tests/test_lexical_match.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/tests/test_limits.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/tests/test_links.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/tests/test_list.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/tests/test_mcp_http_transport.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/tests/test_mcp_instructions.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/tests/test_mcp_recall_schema.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/tests/test_migration_upgrade.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/tests/test_multi_space_search.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/tests/test_path_addressing.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/tests/test_qdrant_ca.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/tests/test_qdrant_integration.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/tests/test_replace_build.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/tests/test_require_title.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/tests/test_retention.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/tests/test_roles_bootstrap.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/tests/test_search_integration.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/tests/test_security_integration.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/tests/test_segments.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/tests/test_segments_store.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/tests/test_snippets.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/tests/test_store_integration.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/tests/test_tags.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/tests/test_usage.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/tests/test_valid_at.py +0 -0
- {memgres-0.7.2 → memgres-0.9.0}/tests/test_write_ergonomics.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memgres
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: Drop-in memory for AI agents: one Postgres, lexical + semantic recall, diff-versioned history, GDPR-erasable.
|
|
5
5
|
Author: mozgsml
|
|
6
6
|
License-Expression: MIT
|
|
@@ -214,6 +214,7 @@ Everything is env, all optional (defaults suit a single-user embed). Full list i
|
|
|
214
214
|
| `MEMGRES_KEY_MODE` | `single` | `single` (no auth, one space) · `open` (bring-your-own token, self-registers) · `managed` (admin-provisioned). See [docs/TENANCY.md](docs/TENANCY.md) |
|
|
215
215
|
| `MEMGRES_ADMIN_TOKEN` | — | global admin bearer for provisioning (managed mode) |
|
|
216
216
|
| `MEMGRES_TOKEN` | — | default token used when a call passes none (single-tenant endpoints) |
|
|
217
|
+
| `MEMGRES_TOKEN_SINK` | — | absolute directory a minted secret is **written to** (`<token-id>.token`, `0600`) instead of being returned. Set it when provisioning is done by an agent — a secret in a tool result is a secret in a transcript. See [docs/TENANCY.md](docs/TENANCY.md) |
|
|
217
218
|
| `MEMGRES_TREE` | `true` | `ltree` path column + GiST index (fast subtree select) |
|
|
218
219
|
| `MEMGRES_REQUIRE_TITLE` | `true` | `true` = a write that stores content must supply `title`. Captions are what name a memory in results and what title-weighted ranking weighs; `move`/`retag` are exempt (they store no content) |
|
|
219
220
|
| `MEMGRES_REQUIRE_PARENT` | `false` | `true` = a node's parent path must already exist |
|
|
@@ -351,11 +352,12 @@ secret and can't switch user):
|
|
|
351
352
|
"headers": { "Authorization": "Bearer mgk_…" } } } }
|
|
352
353
|
```
|
|
353
354
|
|
|
354
|
-
A *namespace-scoped* token also locks the agent to one space.
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
355
|
+
A *namespace-scoped* token also locks the agent to one space. **No tool takes a
|
|
356
|
+
`token` argument**: a deployment names its caller in configuration — an
|
|
357
|
+
`Authorization` header on http, `MEMGRES_TOKEN` in the env of a stdio server's
|
|
358
|
+
client entry — and both transports can carry one, so an argument could only ever
|
|
359
|
+
name an identity the deployment did not choose. Single mode needs no token at
|
|
360
|
+
all. Full model in [docs/TENANCY.md](docs/TENANCY.md).
|
|
359
361
|
|
|
360
362
|
**B. From your own agent code** — your loop calls the HTTP API or the `Store`
|
|
361
363
|
library after the model produces text (see the examples above). Use this when you
|
|
@@ -178,6 +178,7 @@ Everything is env, all optional (defaults suit a single-user embed). Full list i
|
|
|
178
178
|
| `MEMGRES_KEY_MODE` | `single` | `single` (no auth, one space) · `open` (bring-your-own token, self-registers) · `managed` (admin-provisioned). See [docs/TENANCY.md](docs/TENANCY.md) |
|
|
179
179
|
| `MEMGRES_ADMIN_TOKEN` | — | global admin bearer for provisioning (managed mode) |
|
|
180
180
|
| `MEMGRES_TOKEN` | — | default token used when a call passes none (single-tenant endpoints) |
|
|
181
|
+
| `MEMGRES_TOKEN_SINK` | — | absolute directory a minted secret is **written to** (`<token-id>.token`, `0600`) instead of being returned. Set it when provisioning is done by an agent — a secret in a tool result is a secret in a transcript. See [docs/TENANCY.md](docs/TENANCY.md) |
|
|
181
182
|
| `MEMGRES_TREE` | `true` | `ltree` path column + GiST index (fast subtree select) |
|
|
182
183
|
| `MEMGRES_REQUIRE_TITLE` | `true` | `true` = a write that stores content must supply `title`. Captions are what name a memory in results and what title-weighted ranking weighs; `move`/`retag` are exempt (they store no content) |
|
|
183
184
|
| `MEMGRES_REQUIRE_PARENT` | `false` | `true` = a node's parent path must already exist |
|
|
@@ -315,11 +316,12 @@ secret and can't switch user):
|
|
|
315
316
|
"headers": { "Authorization": "Bearer mgk_…" } } } }
|
|
316
317
|
```
|
|
317
318
|
|
|
318
|
-
A *namespace-scoped* token also locks the agent to one space.
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
319
|
+
A *namespace-scoped* token also locks the agent to one space. **No tool takes a
|
|
320
|
+
`token` argument**: a deployment names its caller in configuration — an
|
|
321
|
+
`Authorization` header on http, `MEMGRES_TOKEN` in the env of a stdio server's
|
|
322
|
+
client entry — and both transports can carry one, so an argument could only ever
|
|
323
|
+
name an identity the deployment did not choose. Single mode needs no token at
|
|
324
|
+
all. Full model in [docs/TENANCY.md](docs/TENANCY.md).
|
|
323
325
|
|
|
324
326
|
**B. From your own agent code** — your loop calls the HTTP API or the `Store`
|
|
325
327
|
library after the model produces text (see the examples above). Use this when you
|
|
@@ -409,11 +409,17 @@ def list_spaces(conn, p: Principal) -> List[dict]:
|
|
|
409
409
|
|
|
410
410
|
def issue_token(conn, p: Principal, *, user_id: str,
|
|
411
411
|
namespace_id: Optional[str] = None, permission: str = "write",
|
|
412
|
-
label: str = "", expires_days: Optional[int] = None
|
|
412
|
+
label: str = "", expires_days: Optional[int] = None,
|
|
413
|
+
sink_dir: str = "") -> dict:
|
|
413
414
|
"""Mint a token for `user_id`. The secret is returned once and never again.
|
|
414
415
|
|
|
415
416
|
`expires_days` rather than a timestamp: both doors were converting the same
|
|
416
417
|
way, so the conversion belongs here.
|
|
418
|
+
|
|
419
|
+
`sink_dir` (the deployment's `MEMGRES_TOKEN_SINK`) diverts the secret to a
|
|
420
|
+
0600 file on the server and returns its path instead — see
|
|
421
|
+
`identity.stash_secret`. The caller then never holds the secret at all,
|
|
422
|
+
which is the point when the caller is an agent.
|
|
417
423
|
"""
|
|
418
424
|
require_manage_users(p)
|
|
419
425
|
_require_target_is_plain_user(conn, p, user_id, "issuing a token")
|
|
@@ -423,8 +429,68 @@ def issue_token(conn, p: Principal, *, user_id: str,
|
|
|
423
429
|
secret, tid = identity.issue_token(conn, user_id, namespace_id=namespace_id,
|
|
424
430
|
permission=permission, label=label,
|
|
425
431
|
expires_at=expires_at)
|
|
426
|
-
return
|
|
427
|
-
|
|
432
|
+
return deliver_secret(secret, tid, sink_dir)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def deliver_secret(secret: str, token_id: str, sink_dir: str) -> dict:
|
|
436
|
+
"""The reply for a freshly minted token: the secret itself, or — when the
|
|
437
|
+
deployment set a sink — only where it was put. One function so both minting
|
|
438
|
+
doors (this module and the MCP self-service tool) cannot disagree about
|
|
439
|
+
whether a secret is allowed into a response body."""
|
|
440
|
+
if sink_dir:
|
|
441
|
+
path = identity.stash_secret(sink_dir, token_id, secret)
|
|
442
|
+
return {"id": token_id, "delivered": "file", "path": path,
|
|
443
|
+
"exposed": False,
|
|
444
|
+
"note": "the secret was written to that file on the server and "
|
|
445
|
+
"deliberately NOT returned here — read it there"}
|
|
446
|
+
return {"token": secret, "id": token_id, "exposed": True,
|
|
447
|
+
"note": "store this now — it is not recoverable. This secret was "
|
|
448
|
+
"returned in a reply: if that reply reached an LLM, treat "
|
|
449
|
+
"the token as exposed and rotate it once delivered. Set "
|
|
450
|
+
"MEMGRES_TOKEN_SINK to stop returning secrets at all."}
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def create_enrollment(conn, p: Principal, *, user_id: str,
|
|
454
|
+
namespace_id: Optional[str] = None,
|
|
455
|
+
permission: str = "write", label: str = "",
|
|
456
|
+
expires_minutes: Optional[int] = None) -> dict:
|
|
457
|
+
"""Mint a one-time key that lets `user_id` bind a token THEY generate.
|
|
458
|
+
|
|
459
|
+
Provisioning-tier, and gated exactly like `issue_token`, because it grants
|
|
460
|
+
the same thing by another route: whoever redeems it ends up holding a
|
|
461
|
+
credential for that account. What it does not do is put a secret anywhere —
|
|
462
|
+
the key is worthless after one use and after `expires_minutes`, and the
|
|
463
|
+
token itself is created on the far side and never travels.
|
|
464
|
+
"""
|
|
465
|
+
require_manage_users(p)
|
|
466
|
+
_require_target_is_plain_user(conn, p, user_id, "issuing an enrollment key")
|
|
467
|
+
kw = {} if expires_minutes is None else {"expires_minutes": expires_minutes}
|
|
468
|
+
out = identity.create_enrollment(
|
|
469
|
+
conn, user_id, namespace_id=namespace_id, permission=permission,
|
|
470
|
+
label=label, created_by=p.user_id, **kw)
|
|
471
|
+
out["note"] = ("give this key to its owner over any channel you would use "
|
|
472
|
+
"for a meeting link — it is single-use and short-lived. They "
|
|
473
|
+
"generate their own token, put it in their client's config, "
|
|
474
|
+
"and call memory_enroll with this key.")
|
|
475
|
+
return out
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def list_enrollments(conn, p: Principal, *,
|
|
479
|
+
user_id: Optional[str] = None) -> List[dict]:
|
|
480
|
+
"""Enrollment keys and what became of them — metadata only, never a key."""
|
|
481
|
+
require_manage_users(p)
|
|
482
|
+
if user_id is not None:
|
|
483
|
+
_require_target_is_plain_user(conn, p, user_id, "listing enrollment keys")
|
|
484
|
+
return identity.list_enrollments(conn, user_id=user_id)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def revoke_enrollment(conn, p: Principal, *, enrollment_id: str) -> bool:
|
|
488
|
+
"""Kill an unredeemed key. False if it was already spent, revoked or absent."""
|
|
489
|
+
require_manage_users(p)
|
|
490
|
+
_require_target_is_plain_user(conn, p,
|
|
491
|
+
identity.enrollment_owner(conn, enrollment_id),
|
|
492
|
+
"revoking an enrollment key")
|
|
493
|
+
return identity.revoke_enrollment(conn, enrollment_id)
|
|
428
494
|
|
|
429
495
|
|
|
430
496
|
def revoke_token(conn, p: Principal, *, token_id: str) -> bool:
|
|
@@ -70,6 +70,12 @@ class Config:
|
|
|
70
70
|
# path only. Mutually exclusive with admin_token.
|
|
71
71
|
admin_role: str # role the bootstrap admin is seeded with:
|
|
72
72
|
# user_manager (default) | superadmin
|
|
73
|
+
token_sink: str # directory a freshly minted token secret is
|
|
74
|
+
# WRITTEN to (0600) instead of being returned in
|
|
75
|
+
# the reply. Set it when the caller is an agent:
|
|
76
|
+
# a secret in a tool result is a secret in a chat
|
|
77
|
+
# transcript, and every transcript is copied,
|
|
78
|
+
# summarized and stored. Empty = return it.
|
|
73
79
|
# organization
|
|
74
80
|
tree_enabled: bool # ltree path column + GiST index for fast subtree selection
|
|
75
81
|
require_title: bool # True = a write that stores CONTENT must caption it
|
|
@@ -182,6 +188,11 @@ class Config:
|
|
|
182
188
|
raise ValueError(
|
|
183
189
|
"MEMGRES_ADMIN_ROLE must be user_manager or superadmin "
|
|
184
190
|
f"(got {self.admin_role!r})")
|
|
191
|
+
if self.token_sink and not os.path.isabs(self.token_sink):
|
|
192
|
+
# A relative sink resolves against each process's CWD, so the server
|
|
193
|
+
# and the CLI would write the same operator's secrets to different
|
|
194
|
+
# directories — and neither would say so.
|
|
195
|
+
raise ValueError("MEMGRES_TOKEN_SINK must be an absolute path")
|
|
185
196
|
if self.admin_token and self.admin_token_file:
|
|
186
197
|
raise ValueError(
|
|
187
198
|
"set only one of MEMGRES_ADMIN_TOKEN / MEMGRES_ADMIN_TOKEN_FILE")
|
|
@@ -208,6 +219,7 @@ def load() -> Config:
|
|
|
208
219
|
admin_token=_str("MEMGRES_ADMIN_TOKEN", ""),
|
|
209
220
|
admin_token_file=_str("MEMGRES_ADMIN_TOKEN_FILE", ""),
|
|
210
221
|
admin_role=_str("MEMGRES_ADMIN_ROLE", "user_manager"),
|
|
222
|
+
token_sink=_str("MEMGRES_TOKEN_SINK", ""),
|
|
211
223
|
tree_enabled=_bool("MEMGRES_TREE", True),
|
|
212
224
|
require_title=_bool("MEMGRES_REQUIRE_TITLE", True),
|
|
213
225
|
require_parent=_bool("MEMGRES_REQUIRE_PARENT", False),
|
|
@@ -26,6 +26,7 @@ from __future__ import annotations
|
|
|
26
26
|
|
|
27
27
|
import hashlib
|
|
28
28
|
import hmac
|
|
29
|
+
import os
|
|
29
30
|
import re
|
|
30
31
|
import secrets
|
|
31
32
|
from dataclasses import dataclass
|
|
@@ -34,6 +35,21 @@ from typing import List, Optional, Sequence, Tuple
|
|
|
34
35
|
# ─── token format: mgk_ + 43 url-safe chars (256-bit) ────────────────────────
|
|
35
36
|
TOKEN_RE = re.compile(r"^mgk_[A-Za-z0-9_-]{43}$")
|
|
36
37
|
|
|
38
|
+
# ─── enrollment key format: mge_ + the same 256 bits ─────────────────────────
|
|
39
|
+
# A DIFFERENT prefix on purpose. The two are handled by different code paths and
|
|
40
|
+
# have opposite lifetimes — one is durable, one dies in half an hour — so a
|
|
41
|
+
# credential that leaks should be identifiable on sight, by a human reading a
|
|
42
|
+
# log and by the server refusing it in the wrong slot.
|
|
43
|
+
ENROLL_RE = re.compile(r"^mge_[A-Za-z0-9_-]{43}$")
|
|
44
|
+
|
|
45
|
+
# An enrollment key is a credential for as long as it lives, so the default is
|
|
46
|
+
# short. There is deliberately NO upper limit: an operator provisioning someone
|
|
47
|
+
# who is on holiday for two weeks needs a key that survives until they are back,
|
|
48
|
+
# and a server-side cap would only push them into re-issuing keys by hand. The
|
|
49
|
+
# risk of a long window is real and belongs to whoever chooses it, which is why
|
|
50
|
+
# the listing reports every key's expiry.
|
|
51
|
+
DEFAULT_ENROLL_MINUTES = 30
|
|
52
|
+
|
|
37
53
|
|
|
38
54
|
def bearer_token(authorization: Optional[str],
|
|
39
55
|
x_memgres_token: Optional[str]) -> Optional[str]:
|
|
@@ -1064,6 +1080,189 @@ def issue_token(conn, user_id: str, *, namespace_id: Optional[str] = None,
|
|
|
1064
1080
|
return secret, str(cur.fetchone()[0])
|
|
1065
1081
|
|
|
1066
1082
|
|
|
1083
|
+
def stash_secret(sink_dir: str, token_id: str, secret: str) -> str:
|
|
1084
|
+
"""Write a freshly minted secret to ``<sink_dir>/<token_id>.token`` (0600)
|
|
1085
|
+
and return the path.
|
|
1086
|
+
|
|
1087
|
+
The out-of-band delivery channel for a deployment whose callers are agents.
|
|
1088
|
+
Returning the secret in the reply is fine for a program; for an LLM it means
|
|
1089
|
+
the secret is now in a transcript that gets logged, summarized and shipped
|
|
1090
|
+
to a model provider. Handing back a path instead keeps the operator's own
|
|
1091
|
+
shell as the only place the secret is ever rendered.
|
|
1092
|
+
|
|
1093
|
+
The directory is created 0700 if missing, and the file is opened with
|
|
1094
|
+
``O_CREAT`` at 0600 so the secret is never briefly world-readable.
|
|
1095
|
+
"""
|
|
1096
|
+
os.makedirs(sink_dir, mode=0o700, exist_ok=True)
|
|
1097
|
+
path = os.path.join(sink_dir, f"{token_id}.token")
|
|
1098
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
1099
|
+
with os.fdopen(fd, "w") as fh:
|
|
1100
|
+
fh.write(secret + "\n")
|
|
1101
|
+
os.chmod(path, 0o600) # tighten even if the file pre-existed
|
|
1102
|
+
return path
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
# ─── enrollment: bind a token the client generated itself ────────────────────
|
|
1106
|
+
def new_enrollment_key() -> str:
|
|
1107
|
+
"""A fresh one-time key: ``mge_`` + 43 url-safe chars."""
|
|
1108
|
+
return "mge_" + secrets.token_urlsafe(32)
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
def valid_enrollment_format(key: str) -> bool:
|
|
1112
|
+
return bool(key) and bool(ENROLL_RE.match(key))
|
|
1113
|
+
|
|
1114
|
+
|
|
1115
|
+
def is_unbound(conn, cfg, secret: Optional[str]) -> bool:
|
|
1116
|
+
"""Is this a well-formed credential that simply doesn't belong to anyone yet?
|
|
1117
|
+
|
|
1118
|
+
The question `resolve` cannot answer: it raises ``AuthError`` for an unknown
|
|
1119
|
+
token, a revoked one and an expired one alike, and only the first of those
|
|
1120
|
+
may enroll. Asked separately rather than folded into `resolve` so that
|
|
1121
|
+
authentication keeps exactly one outcome — a Principal or a refusal — and no
|
|
1122
|
+
caller can accidentally treat "may enroll" as "is authenticated".
|
|
1123
|
+
"""
|
|
1124
|
+
if cfg.key_mode != "managed" or not valid_format(secret or ""):
|
|
1125
|
+
return False
|
|
1126
|
+
with conn.cursor() as cur:
|
|
1127
|
+
cur.execute("SELECT 1 FROM token WHERE token_hash=%s",
|
|
1128
|
+
(token_hash(secret),))
|
|
1129
|
+
return cur.fetchone() is None
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
def create_enrollment(conn, user_id: str, *, namespace_id: Optional[str] = None,
|
|
1133
|
+
permission: str = "write", label: str = "",
|
|
1134
|
+
created_by: Optional[str] = None,
|
|
1135
|
+
expires_minutes: int = DEFAULT_ENROLL_MINUTES) -> dict:
|
|
1136
|
+
"""Mint a one-time key that lets its holder bind ONE self-generated token to
|
|
1137
|
+
`user_id`. Returns the key (once) plus its id and expiry.
|
|
1138
|
+
|
|
1139
|
+
The ceiling and the scope are decided HERE, by whoever provisions — not by
|
|
1140
|
+
the person redeeming it. Otherwise enrolling would be a way to ask for more
|
|
1141
|
+
authority than you were given.
|
|
1142
|
+
|
|
1143
|
+
`expires_minutes` defaults to 30 and accepts anything above zero — a key for
|
|
1144
|
+
someone who will not be at their desk today is a legitimate need, and the
|
|
1145
|
+
window is the issuer's judgement, not the server's.
|
|
1146
|
+
"""
|
|
1147
|
+
if permission not in _RANK:
|
|
1148
|
+
raise ValueError(f"bad permission: {permission}")
|
|
1149
|
+
minutes = int(expires_minutes or DEFAULT_ENROLL_MINUTES)
|
|
1150
|
+
if minutes < 1:
|
|
1151
|
+
raise ValueError("expires_minutes must be at least 1")
|
|
1152
|
+
key = new_enrollment_key()
|
|
1153
|
+
with conn.cursor() as cur:
|
|
1154
|
+
cur.execute(
|
|
1155
|
+
"INSERT INTO enrollment_key (key_hash, user_id, namespace_id, "
|
|
1156
|
+
"permission, label, created_by, expires_at) VALUES "
|
|
1157
|
+
"(%s, %s, %s, %s, %s, %s, now() + make_interval(mins => %s)) "
|
|
1158
|
+
"RETURNING id, expires_at",
|
|
1159
|
+
(token_hash(key), user_id, namespace_id, permission, label,
|
|
1160
|
+
created_by, minutes))
|
|
1161
|
+
eid, exp = cur.fetchone()
|
|
1162
|
+
return {"key": key, "id": str(eid), "expires_at": exp,
|
|
1163
|
+
"user_id": user_id, "permission": permission,
|
|
1164
|
+
"namespace_id": namespace_id}
|
|
1165
|
+
|
|
1166
|
+
|
|
1167
|
+
def redeem_enrollment(conn, key: str, secret: str) -> dict:
|
|
1168
|
+
"""Bind `secret` — a token the caller generated — to the account `key` names.
|
|
1169
|
+
|
|
1170
|
+
Every refusal here is deliberate and distinct:
|
|
1171
|
+
|
|
1172
|
+
* a spent key says so, because that is the ONLY signal a stolen key gives.
|
|
1173
|
+
Its rightful holder finding it already redeemed is how the theft is
|
|
1174
|
+
noticed at all; collapsing it into "invalid key" would erase the alarm.
|
|
1175
|
+
* a secret the server already knows is refused rather than re-bound. A
|
|
1176
|
+
revoked token could otherwise be revived by enrolling it somewhere else,
|
|
1177
|
+
which would make revocation a suggestion.
|
|
1178
|
+
"""
|
|
1179
|
+
if not valid_enrollment_format(key):
|
|
1180
|
+
raise AuthError("malformed enrollment key (expected mge_ + 43 url-safe chars)")
|
|
1181
|
+
if not valid_format(secret or ""):
|
|
1182
|
+
raise AuthError("the credential to bind must be mgk_ + 43 url-safe chars")
|
|
1183
|
+
with conn.cursor() as cur:
|
|
1184
|
+
cur.execute(
|
|
1185
|
+
"SELECT id, user_id, namespace_id, permission, label, "
|
|
1186
|
+
" used_at IS NOT NULL, revoked_at IS NOT NULL, "
|
|
1187
|
+
" expires_at <= now() "
|
|
1188
|
+
"FROM enrollment_key WHERE key_hash=%s FOR UPDATE",
|
|
1189
|
+
(token_hash(key),))
|
|
1190
|
+
row = cur.fetchone()
|
|
1191
|
+
if row is None:
|
|
1192
|
+
raise AuthError("unknown enrollment key")
|
|
1193
|
+
eid, uid, nsid, perm, label, used, revoked, expired = row
|
|
1194
|
+
if used:
|
|
1195
|
+
raise AuthError("this enrollment key was already redeemed — if that "
|
|
1196
|
+
"was not you, tell whoever issued it: the token it "
|
|
1197
|
+
"created belongs to someone else")
|
|
1198
|
+
if revoked:
|
|
1199
|
+
raise AuthError("enrollment key revoked")
|
|
1200
|
+
if expired:
|
|
1201
|
+
raise AuthError("enrollment key expired")
|
|
1202
|
+
cur.execute("SELECT 1 FROM token WHERE token_hash=%s", (token_hash(secret),))
|
|
1203
|
+
if cur.fetchone() is not None:
|
|
1204
|
+
raise AuthError("that credential is already known to this server — "
|
|
1205
|
+
"generate a fresh one")
|
|
1206
|
+
tid = register_token(conn, str(uid), secret, namespace_id=
|
|
1207
|
+
str(nsid) if nsid else None,
|
|
1208
|
+
permission=perm, label=label or "enrolled")
|
|
1209
|
+
cur.execute("UPDATE enrollment_key SET used_at=now(), used_token_id=%s "
|
|
1210
|
+
"WHERE id=%s", (tid, eid))
|
|
1211
|
+
return {"user_id": str(uid), "token_id": tid, "permission": perm,
|
|
1212
|
+
"namespace_id": str(nsid) if nsid else None,
|
|
1213
|
+
"enrollment_id": str(eid)}
|
|
1214
|
+
|
|
1215
|
+
|
|
1216
|
+
def list_enrollments(conn, *, user_id: Optional[str] = None) -> List[dict]:
|
|
1217
|
+
"""Pending and spent keys — metadata only, never the key itself."""
|
|
1218
|
+
# `expired` is computed by the DATABASE: the key's clock and the reader's
|
|
1219
|
+
# are not the same clock, and a listing that disagreed with what redeeming
|
|
1220
|
+
# does would be worse than no listing.
|
|
1221
|
+
sql = ("SELECT id, user_id, namespace_id, permission, label, created_by, "
|
|
1222
|
+
"created_at, expires_at, used_at, used_token_id, revoked_at, "
|
|
1223
|
+
"expires_at <= now() AS expired FROM enrollment_key")
|
|
1224
|
+
args: list = []
|
|
1225
|
+
if user_id is not None:
|
|
1226
|
+
sql += " WHERE user_id=%s"
|
|
1227
|
+
args.append(user_id)
|
|
1228
|
+
sql += " ORDER BY created_at DESC, id"
|
|
1229
|
+
with conn.cursor() as cur:
|
|
1230
|
+
cur.execute(sql, args)
|
|
1231
|
+
cols = [c.name for c in cur.description]
|
|
1232
|
+
out = []
|
|
1233
|
+
for r in cur.fetchall():
|
|
1234
|
+
d = dict(zip(cols, r))
|
|
1235
|
+
for k in ("id", "user_id", "namespace_id", "created_by",
|
|
1236
|
+
"used_token_id"):
|
|
1237
|
+
d[k] = str(d[k]) if d[k] else None
|
|
1238
|
+
d["state"] = ("revoked" if d["revoked_at"] else
|
|
1239
|
+
"redeemed" if d["used_at"] else
|
|
1240
|
+
"expired" if d.pop("expired") else "pending")
|
|
1241
|
+
d.pop("expired", None)
|
|
1242
|
+
out.append(d)
|
|
1243
|
+
return out
|
|
1244
|
+
|
|
1245
|
+
|
|
1246
|
+
def revoke_enrollment(conn, enrollment_id: str) -> bool:
|
|
1247
|
+
"""Kill an unredeemed key. False if it was already spent, revoked or absent —
|
|
1248
|
+
a redeemed key is NOT revocable, because what needs killing then is the token
|
|
1249
|
+
it produced, and pretending otherwise would leave that token alive."""
|
|
1250
|
+
with conn.cursor() as cur:
|
|
1251
|
+
cur.execute("UPDATE enrollment_key SET revoked_at=now() WHERE id=%s "
|
|
1252
|
+
"AND revoked_at IS NULL AND used_at IS NULL RETURNING id",
|
|
1253
|
+
(enrollment_id,))
|
|
1254
|
+
return cur.fetchone() is not None
|
|
1255
|
+
|
|
1256
|
+
|
|
1257
|
+
def enrollment_owner(conn, enrollment_id: str) -> Optional[str]:
|
|
1258
|
+
"""Whose account a key would bind to — the target for an authority check."""
|
|
1259
|
+
with conn.cursor() as cur:
|
|
1260
|
+
cur.execute("SELECT user_id FROM enrollment_key WHERE id=%s",
|
|
1261
|
+
(enrollment_id,))
|
|
1262
|
+
r = cur.fetchone()
|
|
1263
|
+
return str(r[0]) if r else None
|
|
1264
|
+
|
|
1265
|
+
|
|
1067
1266
|
def register_token(conn, user_id: str, secret: str, *,
|
|
1068
1267
|
namespace_id: Optional[str] = None, permission: str = "write",
|
|
1069
1268
|
label: str = "", expires_at=None) -> str:
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Effective server configuration, exposed read-only for introspection.
|
|
2
2
|
|
|
3
|
-
An agent shouldn't have to guess the write ceilings,
|
|
4
|
-
available, or how memories are embedded. ``server_info`` distills the loaded
|
|
3
|
+
An agent shouldn't have to guess the write ceilings, how long a memory is kept,
|
|
4
|
+
which recall modes are available, or how memories are embedded. ``server_info`` distills the loaded
|
|
5
5
|
``Config`` (plus the live embedder's dimension, if one is built) into a small,
|
|
6
6
|
non-sensitive dict. It deliberately carries **no** secrets — no token, no api
|
|
7
7
|
key, no database url — so it is safe to return unauthenticated.
|
|
@@ -16,6 +16,15 @@ from .config import Config
|
|
|
16
16
|
from .schema import SCHEMA_VERSION
|
|
17
17
|
|
|
18
18
|
|
|
19
|
+
def _retention_policy(days: int, renew_on_read: bool) -> str:
|
|
20
|
+
"""One plain sentence a human (or an agent explaining itself) can quote."""
|
|
21
|
+
if days <= 0:
|
|
22
|
+
return "kept indefinitely — memories do not expire"
|
|
23
|
+
touch = ("any touch, a read included, starts the window again"
|
|
24
|
+
if renew_on_read else "only a write restarts the window")
|
|
25
|
+
return f"deleted {days} days after the last touch ({touch})"
|
|
26
|
+
|
|
27
|
+
|
|
19
28
|
def server_info(cfg: Config, embed_dim: Optional[int] = None) -> dict:
|
|
20
29
|
"""Effective limits + capabilities, from the loaded config (and the live
|
|
21
30
|
embedder's dimension when available). Never includes secrets.
|
|
@@ -25,6 +34,7 @@ def server_info(cfg: Config, embed_dim: Optional[int] = None) -> dict:
|
|
|
25
34
|
layout this build migrates to."""
|
|
26
35
|
lexical_only = cfg.embed_provider == "none"
|
|
27
36
|
dim = embed_dim if embed_dim is not None else (cfg.embed_dim or None)
|
|
37
|
+
days = cfg.retention_days
|
|
28
38
|
return {
|
|
29
39
|
"version": __version__,
|
|
30
40
|
"schema_version": SCHEMA_VERSION,
|
|
@@ -43,6 +53,18 @@ def server_info(cfg: Config, embed_dim: Optional[int] = None) -> dict:
|
|
|
43
53
|
"model": cfg.embed_model or None,
|
|
44
54
|
"dim": dim,
|
|
45
55
|
},
|
|
56
|
+
# How long a memory survives, said out loud. A client that cannot see
|
|
57
|
+
# this has to guess whether what it stores today will still be there in a
|
|
58
|
+
# month — and "kept indefinitely" is the answer it guesses WRONG about
|
|
59
|
+
# most often, because nothing in a reply hints that an expiry exists at
|
|
60
|
+
# all. `days: null` is the unlimited case, spelled again in `policy`.
|
|
61
|
+
"retention": {
|
|
62
|
+
"days": days if days > 0 else None,
|
|
63
|
+
"expires": days > 0,
|
|
64
|
+
# A read renewing the window only means anything if there is one.
|
|
65
|
+
"renew_on_read": bool(days > 0 and cfg.renew_on_read),
|
|
66
|
+
"policy": _retention_policy(days, cfg.renew_on_read),
|
|
67
|
+
},
|
|
46
68
|
"recall_modes": ["lexical"] if lexical_only
|
|
47
69
|
else ["lexical", "semantic", "hybrid", "auto"],
|
|
48
70
|
"vector_backend": cfg.vector_backend,
|