memgres 0.8.0__tar.gz → 0.10.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.8.0 → memgres-0.10.0}/PKG-INFO +2 -1
- {memgres-0.8.0 → memgres-0.10.0}/README.md +1 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres/_version.py +1 -1
- {memgres-0.8.0 → memgres-0.10.0}/memgres/admin.py +176 -8
- {memgres-0.8.0 → memgres-0.10.0}/memgres/config.py +12 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres/identity.py +390 -13
- {memgres-0.8.0 → memgres-0.10.0}/memgres/info.py +24 -2
- {memgres-0.8.0 → memgres-0.10.0}/memgres/mcp_server.py +265 -15
- memgres-0.10.0/memgres/migrations/0021_enrollment_key.sql +40 -0
- memgres-0.10.0/memgres/migrations/0022_user_disabled.sql +12 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres/schema.py +1 -1
- {memgres-0.8.0 → memgres-0.10.0}/memgres/server.py +85 -2
- memgres-0.10.0/memgres/token_cli.py +206 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres.egg-info/PKG-INFO +2 -1
- {memgres-0.8.0 → memgres-0.10.0}/memgres.egg-info/SOURCES.txt +6 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres.egg-info/entry_points.txt +1 -0
- {memgres-0.8.0 → memgres-0.10.0}/pyproject.toml +1 -0
- memgres-0.10.0/tests/test_admin_two_way.py +402 -0
- memgres-0.10.0/tests/test_enrollment.py +338 -0
- {memgres-0.8.0 → memgres-0.10.0}/tests/test_mcp_admin_tools.py +9 -0
- {memgres-0.8.0 → memgres-0.10.0}/tests/test_mcp_tool_visibility.py +21 -3
- {memgres-0.8.0 → memgres-0.10.0}/tests/test_mcp_tool_visibility_http.py +75 -0
- {memgres-0.8.0 → memgres-0.10.0}/tests/test_server_info.py +45 -1
- {memgres-0.8.0 → memgres-0.10.0}/tests/test_server_integration.py +6 -1
- memgres-0.10.0/tests/test_token_sink.py +204 -0
- {memgres-0.8.0 → memgres-0.10.0}/LICENSE +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres/__init__.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres/admin_cli.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres/blame.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres/bootstrap.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres/delimiters.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres/diffing.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres/embed_worker.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres/embeddings.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres/healthcheck.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres/indexing.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres/lines.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres/links.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres/migrations/0001_core.sql +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres/migrations/0002_identity.sql +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres/migrations/0003_history_author.sql +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres/migrations/0004_title.sql +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres/migrations/0005_chunk_index.sql +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres/migrations/0006_reader_floor.sql +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres/migrations/0007_embed_retry.sql +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres/migrations/0008_service_roles.sql +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres/migrations/0009_create_namespace_right.sql +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres/migrations/0010_namespace_alias.sql +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres/migrations/0011_drop_default_namespace.sql +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres/migrations/0012_user_profile.sql +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres/migrations/0013_hash_version.sql +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres/migrations/0014_access_request_no_fk.sql +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres/migrations/0015_normalize_tags.sql +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres/migrations/0016_valid_at.sql +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres/migrations/0017_memory_link.sql +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres/migrations/0018_links_built.sql +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres/migrations/0019_memory_usage.sql +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres/migrations/0020_memory_usage_no_fk.sql +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres/periodic.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres/reembed.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres/relink.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres/search.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres/segments.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres/store.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres/tags.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres/vector/__init__.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres/vector/base.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres/vector/pgvector.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres/vector/qdrant.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres/worker.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres.egg-info/dependency_links.txt +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres.egg-info/requires.txt +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/memgres.egg-info/top_level.txt +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/setup.cfg +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/tests/test_blame_integration.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/tests/test_chunk_index.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/tests/test_claim_and_reembed.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/tests/test_config.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/tests/test_diffing.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/tests/test_embed_worker.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/tests/test_embeddings.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/tests/test_healthcheck.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/tests/test_identity_integration.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/tests/test_lexical_match.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/tests/test_limits.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/tests/test_links.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/tests/test_list.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/tests/test_mcp_http_transport.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/tests/test_mcp_instructions.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/tests/test_mcp_recall_schema.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/tests/test_migration_upgrade.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/tests/test_multi_space_search.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/tests/test_path_addressing.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/tests/test_qdrant_ca.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/tests/test_qdrant_integration.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/tests/test_replace_build.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/tests/test_require_title.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/tests/test_retention.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/tests/test_roles_bootstrap.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/tests/test_search_integration.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/tests/test_security_integration.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/tests/test_segments.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/tests/test_segments_store.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/tests/test_snippets.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/tests/test_store_integration.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/tests/test_tags.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/tests/test_usage.py +0 -0
- {memgres-0.8.0 → memgres-0.10.0}/tests/test_valid_at.py +0 -0
- {memgres-0.8.0 → memgres-0.10.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.10.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 |
|
|
@@ -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 |
|
|
@@ -353,7 +353,8 @@ def create_namespace(conn, p: Principal, *, owner_user_id: str, name: str,
|
|
|
353
353
|
|
|
354
354
|
def edit_namespace(conn, p: Principal, *, namespace_id: str,
|
|
355
355
|
description: Optional[str] = None,
|
|
356
|
-
instruction: Optional[str] = None
|
|
356
|
+
instruction: Optional[str] = None,
|
|
357
|
+
name: Optional[str] = None) -> dict:
|
|
357
358
|
"""Amend a namespace's description or routing instruction.
|
|
358
359
|
|
|
359
360
|
`create_namespace` is an idempotent upsert that ignores conflicts, so
|
|
@@ -363,19 +364,92 @@ def edit_namespace(conn, p: Principal, *, namespace_id: str,
|
|
|
363
364
|
"""
|
|
364
365
|
require_namespace_admin(conn, p, namespace_id)
|
|
365
366
|
identity.edit_namespace(conn, namespace_id, description=description,
|
|
366
|
-
instruction=instruction)
|
|
367
|
-
|
|
367
|
+
instruction=instruction, name=name)
|
|
368
|
+
out = {"namespace_id": namespace_id}
|
|
369
|
+
if name is not None:
|
|
370
|
+
out["name"] = name
|
|
371
|
+
out["warning"] = ("renamed — anyone addressing it by the old name now "
|
|
372
|
+
"gets 'no such namespace'. Ids and aliases still work")
|
|
373
|
+
return out
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def require_namespace_owner(conn, p: Principal, namespace_id: str) -> None:
|
|
377
|
+
"""Owner-or-superadmin, a tier narrower than `require_namespace_admin`.
|
|
378
|
+
|
|
379
|
+
For the acts that dispose of the namespace itself rather than work inside
|
|
380
|
+
it. An admin MEMBER was given authority over the contents; giving away
|
|
381
|
+
somebody else's namespace is not part of that, and the difference only
|
|
382
|
+
matters here — which is why this is its own check rather than a flag.
|
|
383
|
+
"""
|
|
384
|
+
require_namespace_admin(conn, p, namespace_id) # ceiling + scope + exists
|
|
385
|
+
if p.is_admin:
|
|
386
|
+
return
|
|
387
|
+
if identity.namespace_owner(conn, namespace_id) != p.user_id:
|
|
388
|
+
raise Forbidden("only the owner (or a superadmin) may do this")
|
|
368
389
|
|
|
369
390
|
|
|
370
391
|
def add_member(conn, p: Principal, *, namespace_id: str, user_id: str,
|
|
371
392
|
permission: str = "read") -> dict:
|
|
372
|
-
"""Share a namespace with another user
|
|
373
|
-
|
|
393
|
+
"""Share a namespace with another user.
|
|
394
|
+
|
|
395
|
+
Authorized per-NAMESPACE, not deployment-wide. It used to demand superadmin,
|
|
396
|
+
on the reasoning that sharing reaches across tenants — but the thing being
|
|
397
|
+
shared is the caller's OWN namespace, and requiring the deployment's root
|
|
398
|
+
for that made "let a colleague into my cabinet" an operator ticket. What it
|
|
399
|
+
still demands is an admin-ceiling credential for that namespace, so a
|
|
400
|
+
read-only or differently-scoped token cannot hand out access.
|
|
401
|
+
"""
|
|
402
|
+
require_namespace_admin(conn, p, namespace_id)
|
|
374
403
|
identity.add_member(conn, namespace_id, user_id, permission)
|
|
375
404
|
return {"namespace_id": namespace_id, "user_id": user_id,
|
|
376
405
|
"permission": permission}
|
|
377
406
|
|
|
378
407
|
|
|
408
|
+
def remove_member(conn, p: Principal, *, namespace_id: str, user_id: str) -> dict:
|
|
409
|
+
"""Un-share a namespace. `removed: false` means they were not a member.
|
|
410
|
+
|
|
411
|
+
The other half of `add_member`, and its absence was the sharpest gap in this
|
|
412
|
+
control plane: access could be granted and never taken back except by
|
|
413
|
+
revoking every token the person held, which cuts them off from everything
|
|
414
|
+
rather than from this.
|
|
415
|
+
"""
|
|
416
|
+
require_namespace_admin(conn, p, namespace_id)
|
|
417
|
+
return {"namespace_id": namespace_id, "user_id": user_id,
|
|
418
|
+
"removed": identity.remove_member(conn, namespace_id, user_id)}
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def transfer_namespace(conn, p: Principal, *, namespace_id: str,
|
|
422
|
+
new_owner_user_id: str,
|
|
423
|
+
keep_previous_owner: Optional[str] = "admin") -> dict:
|
|
424
|
+
"""Hand a namespace to another account — owner or superadmin.
|
|
425
|
+
|
|
426
|
+
The outgoing owner stays behind as an `admin` member unless
|
|
427
|
+
`keep_previous_owner` is null. Defaulting to keeping them is the safer
|
|
428
|
+
footing: the alternative is a single call that removes the caller from a
|
|
429
|
+
namespace whose contents they may be the only one who knows.
|
|
430
|
+
"""
|
|
431
|
+
require_namespace_owner(conn, p, namespace_id)
|
|
432
|
+
return identity.transfer_namespace(
|
|
433
|
+
conn, namespace_id, new_owner_user_id,
|
|
434
|
+
keep_previous_owner=keep_previous_owner)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def set_disabled(conn, p: Principal, *, user_id: str, disabled: bool) -> dict:
|
|
438
|
+
"""Switch an account off, or back on. Every token it holds stops at once.
|
|
439
|
+
|
|
440
|
+
Offboarding as ONE act. Doing it by revoking tokens one at a time is a loop
|
|
441
|
+
that has to be complete to be correct, and nothing stops a new token being
|
|
442
|
+
issued afterwards. Reversible and destructive of nothing — authorship,
|
|
443
|
+
namespaces and memberships all survive, which is what makes it usable for
|
|
444
|
+
"gone for now" as well as "gone".
|
|
445
|
+
"""
|
|
446
|
+
require_manage_users(p)
|
|
447
|
+
_require_target_is_plain_user(conn, p, user_id,
|
|
448
|
+
"disabling an account" if disabled
|
|
449
|
+
else "re-enabling an account")
|
|
450
|
+
return identity.set_disabled(conn, user_id, disabled)
|
|
451
|
+
|
|
452
|
+
|
|
379
453
|
def list_namespaces(conn, p: Principal, *, owner_user_id: Optional[str] = None,
|
|
380
454
|
limit: Optional[int] = None, offset: int = 0) -> List[dict]:
|
|
381
455
|
"""Deployment-wide namespace inventory.
|
|
@@ -407,24 +481,118 @@ def list_spaces(conn, p: Principal) -> List[dict]:
|
|
|
407
481
|
|
|
408
482
|
# ─── tokens ──────────────────────────────────────────────────────────────────
|
|
409
483
|
|
|
484
|
+
def _unreachable_warning(conn, user_id: str,
|
|
485
|
+
namespace_id: Optional[str]) -> Optional[str]:
|
|
486
|
+
"""Warn when a credential is about to be scoped to a namespace its owner
|
|
487
|
+
cannot reach.
|
|
488
|
+
|
|
489
|
+
Scoping is not granting: reach comes from ownership or membership, and a
|
|
490
|
+
token pinned to a namespace the account is not in is perfectly valid and
|
|
491
|
+
reaches nothing. The person enrols, everything answers "no namespace", and
|
|
492
|
+
it reads as a broken server rather than an unfinished provisioning. A
|
|
493
|
+
warning rather than a refusal, because issuing the credential first and
|
|
494
|
+
adding the membership after is a legitimate order.
|
|
495
|
+
"""
|
|
496
|
+
if not namespace_id:
|
|
497
|
+
return None
|
|
498
|
+
if identity.reaches(conn, user_id, namespace_id) is not None:
|
|
499
|
+
return None
|
|
500
|
+
return ("that user cannot reach this namespace yet, so the token will be "
|
|
501
|
+
"valid and see NOTHING — add them with memory_admin_add_member "
|
|
502
|
+
"(or hand the namespace over) before they try to use it")
|
|
503
|
+
|
|
504
|
+
|
|
410
505
|
def issue_token(conn, p: Principal, *, user_id: str,
|
|
411
506
|
namespace_id: Optional[str] = None, permission: str = "write",
|
|
412
|
-
label: str = "", expires_days: Optional[int] = None
|
|
507
|
+
label: str = "", expires_days: Optional[int] = None,
|
|
508
|
+
sink_dir: str = "") -> dict:
|
|
413
509
|
"""Mint a token for `user_id`. The secret is returned once and never again.
|
|
414
510
|
|
|
415
511
|
`expires_days` rather than a timestamp: both doors were converting the same
|
|
416
512
|
way, so the conversion belongs here.
|
|
513
|
+
|
|
514
|
+
`sink_dir` (the deployment's `MEMGRES_TOKEN_SINK`) diverts the secret to a
|
|
515
|
+
0600 file on the server and returns its path instead — see
|
|
516
|
+
`identity.stash_secret`. The caller then never holds the secret at all,
|
|
517
|
+
which is the point when the caller is an agent.
|
|
417
518
|
"""
|
|
418
519
|
require_manage_users(p)
|
|
419
520
|
_require_target_is_plain_user(conn, p, user_id, "issuing a token")
|
|
420
521
|
expires_at = None
|
|
421
522
|
if expires_days:
|
|
422
523
|
expires_at = dt.datetime.now(dt.timezone.utc) + dt.timedelta(days=expires_days)
|
|
524
|
+
warning = _unreachable_warning(conn, user_id, namespace_id)
|
|
423
525
|
secret, tid = identity.issue_token(conn, user_id, namespace_id=namespace_id,
|
|
424
526
|
permission=permission, label=label,
|
|
425
527
|
expires_at=expires_at)
|
|
426
|
-
|
|
427
|
-
|
|
528
|
+
out = deliver_secret(secret, tid, sink_dir)
|
|
529
|
+
if warning:
|
|
530
|
+
out["warning"] = warning
|
|
531
|
+
return out
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def deliver_secret(secret: str, token_id: str, sink_dir: str) -> dict:
|
|
535
|
+
"""The reply for a freshly minted token: the secret itself, or — when the
|
|
536
|
+
deployment set a sink — only where it was put. One function so both minting
|
|
537
|
+
doors (this module and the MCP self-service tool) cannot disagree about
|
|
538
|
+
whether a secret is allowed into a response body."""
|
|
539
|
+
if sink_dir:
|
|
540
|
+
path = identity.stash_secret(sink_dir, token_id, secret)
|
|
541
|
+
return {"id": token_id, "delivered": "file", "path": path,
|
|
542
|
+
"exposed": False,
|
|
543
|
+
"note": "the secret was written to that file on the server and "
|
|
544
|
+
"deliberately NOT returned here — read it there"}
|
|
545
|
+
return {"token": secret, "id": token_id, "exposed": True,
|
|
546
|
+
"note": "store this now — it is not recoverable. This secret was "
|
|
547
|
+
"returned in a reply: if that reply reached an LLM, treat "
|
|
548
|
+
"the token as exposed and rotate it once delivered. Set "
|
|
549
|
+
"MEMGRES_TOKEN_SINK to stop returning secrets at all."}
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def create_enrollment(conn, p: Principal, *, user_id: str,
|
|
553
|
+
namespace_id: Optional[str] = None,
|
|
554
|
+
permission: str = "write", label: str = "",
|
|
555
|
+
expires_minutes: Optional[int] = None) -> dict:
|
|
556
|
+
"""Mint a one-time key that lets `user_id` bind a token THEY generate.
|
|
557
|
+
|
|
558
|
+
Provisioning-tier, and gated exactly like `issue_token`, because it grants
|
|
559
|
+
the same thing by another route: whoever redeems it ends up holding a
|
|
560
|
+
credential for that account. What it does not do is put a secret anywhere —
|
|
561
|
+
the key is worthless after one use and after `expires_minutes`, and the
|
|
562
|
+
token itself is created on the far side and never travels.
|
|
563
|
+
"""
|
|
564
|
+
require_manage_users(p)
|
|
565
|
+
_require_target_is_plain_user(conn, p, user_id, "issuing an enrollment key")
|
|
566
|
+
kw = {} if expires_minutes is None else {"expires_minutes": expires_minutes}
|
|
567
|
+
out = identity.create_enrollment(
|
|
568
|
+
conn, user_id, namespace_id=namespace_id, permission=permission,
|
|
569
|
+
label=label, created_by=p.user_id, **kw)
|
|
570
|
+
out["note"] = ("give this key to its owner over any channel you would use "
|
|
571
|
+
"for a meeting link — it is single-use and short-lived. They "
|
|
572
|
+
"generate their own token, put it in their client's config, "
|
|
573
|
+
"and call memory_enroll with this key.")
|
|
574
|
+
warning = _unreachable_warning(conn, user_id, namespace_id)
|
|
575
|
+
if warning:
|
|
576
|
+
out["warning"] = warning
|
|
577
|
+
return out
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def list_enrollments(conn, p: Principal, *,
|
|
581
|
+
user_id: Optional[str] = None) -> List[dict]:
|
|
582
|
+
"""Enrollment keys and what became of them — metadata only, never a key."""
|
|
583
|
+
require_manage_users(p)
|
|
584
|
+
if user_id is not None:
|
|
585
|
+
_require_target_is_plain_user(conn, p, user_id, "listing enrollment keys")
|
|
586
|
+
return identity.list_enrollments(conn, user_id=user_id)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def revoke_enrollment(conn, p: Principal, *, enrollment_id: str) -> bool:
|
|
590
|
+
"""Kill an unredeemed key. False if it was already spent, revoked or absent."""
|
|
591
|
+
require_manage_users(p)
|
|
592
|
+
_require_target_is_plain_user(conn, p,
|
|
593
|
+
identity.enrollment_owner(conn, enrollment_id),
|
|
594
|
+
"revoking an enrollment key")
|
|
595
|
+
return identity.revoke_enrollment(conn, enrollment_id)
|
|
428
596
|
|
|
429
597
|
|
|
430
598
|
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),
|