memgres 0.8.0__tar.gz → 0.11.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.
Files changed (110) hide show
  1. {memgres-0.8.0 → memgres-0.11.0}/PKG-INFO +2 -1
  2. {memgres-0.8.0 → memgres-0.11.0}/README.md +1 -0
  3. {memgres-0.8.0 → memgres-0.11.0}/memgres/_version.py +1 -1
  4. {memgres-0.8.0 → memgres-0.11.0}/memgres/admin.py +256 -14
  5. {memgres-0.8.0 → memgres-0.11.0}/memgres/bootstrap.py +10 -6
  6. {memgres-0.8.0 → memgres-0.11.0}/memgres/config.py +12 -0
  7. {memgres-0.8.0 → memgres-0.11.0}/memgres/identity.py +569 -25
  8. {memgres-0.8.0 → memgres-0.11.0}/memgres/info.py +24 -2
  9. {memgres-0.8.0 → memgres-0.11.0}/memgres/mcp_server.py +288 -18
  10. memgres-0.11.0/memgres/migrations/0021_enrollment_key.sql +40 -0
  11. memgres-0.11.0/memgres/migrations/0022_user_disabled.sql +12 -0
  12. {memgres-0.8.0 → memgres-0.11.0}/memgres/schema.py +1 -1
  13. {memgres-0.8.0 → memgres-0.11.0}/memgres/server.py +101 -6
  14. memgres-0.11.0/memgres/token_cli.py +216 -0
  15. {memgres-0.8.0 → memgres-0.11.0}/memgres.egg-info/PKG-INFO +2 -1
  16. {memgres-0.8.0 → memgres-0.11.0}/memgres.egg-info/SOURCES.txt +7 -0
  17. {memgres-0.8.0 → memgres-0.11.0}/memgres.egg-info/entry_points.txt +1 -0
  18. {memgres-0.8.0 → memgres-0.11.0}/pyproject.toml +1 -0
  19. memgres-0.11.0/tests/test_admin_two_way.py +415 -0
  20. memgres-0.11.0/tests/test_enrollment.py +338 -0
  21. {memgres-0.8.0 → memgres-0.11.0}/tests/test_mcp_admin_tools.py +9 -0
  22. {memgres-0.8.0 → memgres-0.11.0}/tests/test_mcp_tool_visibility.py +21 -3
  23. {memgres-0.8.0 → memgres-0.11.0}/tests/test_mcp_tool_visibility_http.py +75 -0
  24. memgres-0.11.0/tests/test_security_followups.py +353 -0
  25. {memgres-0.8.0 → memgres-0.11.0}/tests/test_security_integration.py +7 -3
  26. {memgres-0.8.0 → memgres-0.11.0}/tests/test_server_info.py +45 -1
  27. {memgres-0.8.0 → memgres-0.11.0}/tests/test_server_integration.py +10 -2
  28. memgres-0.11.0/tests/test_token_sink.py +204 -0
  29. {memgres-0.8.0 → memgres-0.11.0}/LICENSE +0 -0
  30. {memgres-0.8.0 → memgres-0.11.0}/memgres/__init__.py +0 -0
  31. {memgres-0.8.0 → memgres-0.11.0}/memgres/admin_cli.py +0 -0
  32. {memgres-0.8.0 → memgres-0.11.0}/memgres/blame.py +0 -0
  33. {memgres-0.8.0 → memgres-0.11.0}/memgres/delimiters.py +0 -0
  34. {memgres-0.8.0 → memgres-0.11.0}/memgres/diffing.py +0 -0
  35. {memgres-0.8.0 → memgres-0.11.0}/memgres/embed_worker.py +0 -0
  36. {memgres-0.8.0 → memgres-0.11.0}/memgres/embeddings.py +0 -0
  37. {memgres-0.8.0 → memgres-0.11.0}/memgres/healthcheck.py +0 -0
  38. {memgres-0.8.0 → memgres-0.11.0}/memgres/indexing.py +0 -0
  39. {memgres-0.8.0 → memgres-0.11.0}/memgres/lines.py +0 -0
  40. {memgres-0.8.0 → memgres-0.11.0}/memgres/links.py +0 -0
  41. {memgres-0.8.0 → memgres-0.11.0}/memgres/migrations/0001_core.sql +0 -0
  42. {memgres-0.8.0 → memgres-0.11.0}/memgres/migrations/0002_identity.sql +0 -0
  43. {memgres-0.8.0 → memgres-0.11.0}/memgres/migrations/0003_history_author.sql +0 -0
  44. {memgres-0.8.0 → memgres-0.11.0}/memgres/migrations/0004_title.sql +0 -0
  45. {memgres-0.8.0 → memgres-0.11.0}/memgres/migrations/0005_chunk_index.sql +0 -0
  46. {memgres-0.8.0 → memgres-0.11.0}/memgres/migrations/0006_reader_floor.sql +0 -0
  47. {memgres-0.8.0 → memgres-0.11.0}/memgres/migrations/0007_embed_retry.sql +0 -0
  48. {memgres-0.8.0 → memgres-0.11.0}/memgres/migrations/0008_service_roles.sql +0 -0
  49. {memgres-0.8.0 → memgres-0.11.0}/memgres/migrations/0009_create_namespace_right.sql +0 -0
  50. {memgres-0.8.0 → memgres-0.11.0}/memgres/migrations/0010_namespace_alias.sql +0 -0
  51. {memgres-0.8.0 → memgres-0.11.0}/memgres/migrations/0011_drop_default_namespace.sql +0 -0
  52. {memgres-0.8.0 → memgres-0.11.0}/memgres/migrations/0012_user_profile.sql +0 -0
  53. {memgres-0.8.0 → memgres-0.11.0}/memgres/migrations/0013_hash_version.sql +0 -0
  54. {memgres-0.8.0 → memgres-0.11.0}/memgres/migrations/0014_access_request_no_fk.sql +0 -0
  55. {memgres-0.8.0 → memgres-0.11.0}/memgres/migrations/0015_normalize_tags.sql +0 -0
  56. {memgres-0.8.0 → memgres-0.11.0}/memgres/migrations/0016_valid_at.sql +0 -0
  57. {memgres-0.8.0 → memgres-0.11.0}/memgres/migrations/0017_memory_link.sql +0 -0
  58. {memgres-0.8.0 → memgres-0.11.0}/memgres/migrations/0018_links_built.sql +0 -0
  59. {memgres-0.8.0 → memgres-0.11.0}/memgres/migrations/0019_memory_usage.sql +0 -0
  60. {memgres-0.8.0 → memgres-0.11.0}/memgres/migrations/0020_memory_usage_no_fk.sql +0 -0
  61. {memgres-0.8.0 → memgres-0.11.0}/memgres/periodic.py +0 -0
  62. {memgres-0.8.0 → memgres-0.11.0}/memgres/reembed.py +0 -0
  63. {memgres-0.8.0 → memgres-0.11.0}/memgres/relink.py +0 -0
  64. {memgres-0.8.0 → memgres-0.11.0}/memgres/search.py +0 -0
  65. {memgres-0.8.0 → memgres-0.11.0}/memgres/segments.py +0 -0
  66. {memgres-0.8.0 → memgres-0.11.0}/memgres/store.py +0 -0
  67. {memgres-0.8.0 → memgres-0.11.0}/memgres/tags.py +0 -0
  68. {memgres-0.8.0 → memgres-0.11.0}/memgres/vector/__init__.py +0 -0
  69. {memgres-0.8.0 → memgres-0.11.0}/memgres/vector/base.py +0 -0
  70. {memgres-0.8.0 → memgres-0.11.0}/memgres/vector/pgvector.py +0 -0
  71. {memgres-0.8.0 → memgres-0.11.0}/memgres/vector/qdrant.py +0 -0
  72. {memgres-0.8.0 → memgres-0.11.0}/memgres/worker.py +0 -0
  73. {memgres-0.8.0 → memgres-0.11.0}/memgres.egg-info/dependency_links.txt +0 -0
  74. {memgres-0.8.0 → memgres-0.11.0}/memgres.egg-info/requires.txt +0 -0
  75. {memgres-0.8.0 → memgres-0.11.0}/memgres.egg-info/top_level.txt +0 -0
  76. {memgres-0.8.0 → memgres-0.11.0}/setup.cfg +0 -0
  77. {memgres-0.8.0 → memgres-0.11.0}/tests/test_blame_integration.py +0 -0
  78. {memgres-0.8.0 → memgres-0.11.0}/tests/test_chunk_index.py +0 -0
  79. {memgres-0.8.0 → memgres-0.11.0}/tests/test_claim_and_reembed.py +0 -0
  80. {memgres-0.8.0 → memgres-0.11.0}/tests/test_config.py +0 -0
  81. {memgres-0.8.0 → memgres-0.11.0}/tests/test_diffing.py +0 -0
  82. {memgres-0.8.0 → memgres-0.11.0}/tests/test_embed_worker.py +0 -0
  83. {memgres-0.8.0 → memgres-0.11.0}/tests/test_embeddings.py +0 -0
  84. {memgres-0.8.0 → memgres-0.11.0}/tests/test_healthcheck.py +0 -0
  85. {memgres-0.8.0 → memgres-0.11.0}/tests/test_identity_integration.py +0 -0
  86. {memgres-0.8.0 → memgres-0.11.0}/tests/test_lexical_match.py +0 -0
  87. {memgres-0.8.0 → memgres-0.11.0}/tests/test_limits.py +0 -0
  88. {memgres-0.8.0 → memgres-0.11.0}/tests/test_links.py +0 -0
  89. {memgres-0.8.0 → memgres-0.11.0}/tests/test_list.py +0 -0
  90. {memgres-0.8.0 → memgres-0.11.0}/tests/test_mcp_http_transport.py +0 -0
  91. {memgres-0.8.0 → memgres-0.11.0}/tests/test_mcp_instructions.py +0 -0
  92. {memgres-0.8.0 → memgres-0.11.0}/tests/test_mcp_recall_schema.py +0 -0
  93. {memgres-0.8.0 → memgres-0.11.0}/tests/test_migration_upgrade.py +0 -0
  94. {memgres-0.8.0 → memgres-0.11.0}/tests/test_multi_space_search.py +0 -0
  95. {memgres-0.8.0 → memgres-0.11.0}/tests/test_path_addressing.py +0 -0
  96. {memgres-0.8.0 → memgres-0.11.0}/tests/test_qdrant_ca.py +0 -0
  97. {memgres-0.8.0 → memgres-0.11.0}/tests/test_qdrant_integration.py +0 -0
  98. {memgres-0.8.0 → memgres-0.11.0}/tests/test_replace_build.py +0 -0
  99. {memgres-0.8.0 → memgres-0.11.0}/tests/test_require_title.py +0 -0
  100. {memgres-0.8.0 → memgres-0.11.0}/tests/test_retention.py +0 -0
  101. {memgres-0.8.0 → memgres-0.11.0}/tests/test_roles_bootstrap.py +0 -0
  102. {memgres-0.8.0 → memgres-0.11.0}/tests/test_search_integration.py +0 -0
  103. {memgres-0.8.0 → memgres-0.11.0}/tests/test_segments.py +0 -0
  104. {memgres-0.8.0 → memgres-0.11.0}/tests/test_segments_store.py +0 -0
  105. {memgres-0.8.0 → memgres-0.11.0}/tests/test_snippets.py +0 -0
  106. {memgres-0.8.0 → memgres-0.11.0}/tests/test_store_integration.py +0 -0
  107. {memgres-0.8.0 → memgres-0.11.0}/tests/test_tags.py +0 -0
  108. {memgres-0.8.0 → memgres-0.11.0}/tests/test_usage.py +0 -0
  109. {memgres-0.8.0 → memgres-0.11.0}/tests/test_valid_at.py +0 -0
  110. {memgres-0.8.0 → memgres-0.11.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.8.0
3
+ Version: 0.11.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 |
@@ -8,4 +8,4 @@ here at release; nowhere else carries the number.
8
8
  PEP 440: a ``.devN`` suffix marks an unreleased build ahead of the last tag.
9
9
  """
10
10
 
11
- __version__ = "0.8.0"
11
+ __version__ = "0.11.0"
@@ -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) -> dict:
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
@@ -361,21 +362,106 @@ def edit_namespace(conn, p: Principal, *, namespace_id: str,
361
362
  the instruction agents read to choose a namespace could not be fixed through
362
363
  any door at all.
363
364
  """
364
- require_namespace_admin(conn, p, namespace_id)
365
+ # Description and instruction are administration; the NAME is closer to
366
+ # disposal — renaming out from under an owner breaks every by-name call they
367
+ # and every member make, and an admin member was given authority over the
368
+ # contents, not over how the namespace is addressed.
369
+ if name is not None:
370
+ require_namespace_owner(conn, p, namespace_id)
371
+ else:
372
+ require_namespace_admin(conn, p, namespace_id)
365
373
  identity.edit_namespace(conn, namespace_id, description=description,
366
- instruction=instruction)
367
- return {"namespace_id": namespace_id}
374
+ instruction=instruction, name=name)
375
+ out = {"namespace_id": namespace_id}
376
+ if name is not None:
377
+ out["name"] = name
378
+ out["warning"] = ("renamed — anyone addressing it by the old name now "
379
+ "gets 'no such namespace'. Ids and aliases still work")
380
+ return out
381
+
382
+
383
+ def require_namespace_owner(conn, p: Principal, namespace_id: str) -> None:
384
+ """Owner-or-superadmin, a tier narrower than `require_namespace_admin`.
385
+
386
+ For the acts that dispose of the namespace itself rather than work inside
387
+ it. An admin MEMBER was given authority over the contents; giving away
388
+ somebody else's namespace is not part of that, and the difference only
389
+ matters here — which is why this is its own check rather than a flag.
390
+ """
391
+ require_namespace_admin(conn, p, namespace_id) # ceiling + scope + exists
392
+ # A namespace-pinned token is issued to WORK in that namespace; disposing of
393
+ # it is a step beyond what the pin implies, and the deployment-wide acts all
394
+ # demand an unscoped credential for the same reason.
395
+ if p.scope_namespace_id is not None:
396
+ raise Forbidden("this act needs an unscoped admin credential")
397
+ if p.is_admin:
398
+ return
399
+ if identity.namespace_owner(conn, namespace_id) != p.user_id:
400
+ raise Forbidden("only the owner (or a superadmin) may do this")
368
401
 
369
402
 
370
403
  def add_member(conn, p: Principal, *, namespace_id: str, user_id: str,
371
404
  permission: str = "read") -> dict:
372
- """Share a namespace with another user — cross-tenant, so superadmin only."""
373
- require_superadmin(p)
405
+ """Share a namespace with another user.
406
+
407
+ Authorized per-NAMESPACE, not deployment-wide. It used to demand superadmin,
408
+ on the reasoning that sharing reaches across tenants — but the thing being
409
+ shared is the caller's OWN namespace, and requiring the deployment's root
410
+ for that made "let a colleague into my cabinet" an operator ticket. What it
411
+ still demands is an admin-ceiling credential for that namespace, so a
412
+ read-only or differently-scoped token cannot hand out access.
413
+ """
414
+ require_namespace_admin(conn, p, namespace_id)
374
415
  identity.add_member(conn, namespace_id, user_id, permission)
375
416
  return {"namespace_id": namespace_id, "user_id": user_id,
376
417
  "permission": permission}
377
418
 
378
419
 
420
+ def remove_member(conn, p: Principal, *, namespace_id: str, user_id: str) -> dict:
421
+ """Un-share a namespace. `removed: false` means they were not a member.
422
+
423
+ The other half of `add_member`, and its absence was the sharpest gap in this
424
+ control plane: access could be granted and never taken back except by
425
+ revoking every token the person held, which cuts them off from everything
426
+ rather than from this.
427
+ """
428
+ require_namespace_admin(conn, p, namespace_id)
429
+ return {"namespace_id": namespace_id, "user_id": user_id,
430
+ "removed": identity.remove_member(conn, namespace_id, user_id)}
431
+
432
+
433
+ def transfer_namespace(conn, p: Principal, *, namespace_id: str,
434
+ new_owner_user_id: str,
435
+ keep_previous_owner: Optional[str] = "admin") -> dict:
436
+ """Hand a namespace to another account — owner or superadmin.
437
+
438
+ The outgoing owner stays behind as an `admin` member unless
439
+ `keep_previous_owner` is null. Defaulting to keeping them is the safer
440
+ footing: the alternative is a single call that removes the caller from a
441
+ namespace whose contents they may be the only one who knows.
442
+ """
443
+ require_namespace_owner(conn, p, namespace_id)
444
+ return identity.transfer_namespace(
445
+ conn, namespace_id, new_owner_user_id,
446
+ keep_previous_owner=keep_previous_owner)
447
+
448
+
449
+ def set_disabled(conn, p: Principal, *, user_id: str, disabled: bool) -> dict:
450
+ """Switch an account off, or back on. Every token it holds stops at once.
451
+
452
+ Offboarding as ONE act. Doing it by revoking tokens one at a time is a loop
453
+ that has to be complete to be correct, and nothing stops a new token being
454
+ issued afterwards. Reversible and destructive of nothing — authorship,
455
+ namespaces and memberships all survive, which is what makes it usable for
456
+ "gone for now" as well as "gone".
457
+ """
458
+ require_manage_users(p)
459
+ _require_target_is_plain_user(conn, p, user_id,
460
+ "disabling an account" if disabled
461
+ else "re-enabling an account")
462
+ return identity.set_disabled(conn, user_id, disabled)
463
+
464
+
379
465
  def list_namespaces(conn, p: Principal, *, owner_user_id: Optional[str] = None,
380
466
  limit: Optional[int] = None, offset: int = 0) -> List[dict]:
381
467
  """Deployment-wide namespace inventory.
@@ -407,24 +493,149 @@ def list_spaces(conn, p: Principal) -> List[dict]:
407
493
 
408
494
  # ─── tokens ──────────────────────────────────────────────────────────────────
409
495
 
496
+ def _unreachable_warning(conn, user_id: str,
497
+ namespace_id: Optional[str]) -> Optional[str]:
498
+ """Warn when a credential is about to be scoped to a namespace its owner
499
+ cannot reach.
500
+
501
+ Scoping is not granting: reach comes from ownership or membership, and a
502
+ token pinned to a namespace the account is not in is perfectly valid and
503
+ reaches nothing. The person enrols, everything answers "no namespace", and
504
+ it reads as a broken server rather than an unfinished provisioning. A
505
+ warning rather than a refusal, because issuing the credential first and
506
+ adding the membership after is a legitimate order.
507
+ """
508
+ if not namespace_id:
509
+ return None
510
+ if identity.reaches(conn, user_id, namespace_id) is not None:
511
+ return None
512
+ return ("that user cannot reach this namespace yet, so the token will be "
513
+ "valid and see NOTHING — add them with memory_admin_add_member "
514
+ "(or hand the namespace over) before they try to use it")
515
+
516
+
410
517
  def issue_token(conn, p: Principal, *, user_id: str,
411
518
  namespace_id: Optional[str] = None, permission: str = "write",
412
- label: str = "", expires_days: Optional[int] = None) -> dict:
519
+ label: str = "", expires_days: Optional[int] = None,
520
+ sink_dir: str = "", defer_delivery: bool = False) -> dict:
413
521
  """Mint a token for `user_id`. The secret is returned once and never again.
414
522
 
415
523
  `expires_days` rather than a timestamp: both doors were converting the same
416
524
  way, so the conversion belongs here.
525
+
526
+ `sink_dir` (the deployment's `MEMGRES_TOKEN_SINK`) diverts the secret to a
527
+ 0600 file on the server and returns its path instead — see
528
+ `identity.stash_secret`. The caller then never holds the secret at all,
529
+ which is the point when the caller is an agent.
417
530
  """
418
531
  require_manage_users(p)
419
532
  _require_target_is_plain_user(conn, p, user_id, "issuing a token")
420
533
  expires_at = None
421
534
  if expires_days:
422
535
  expires_at = dt.datetime.now(dt.timezone.utc) + dt.timedelta(days=expires_days)
536
+ warning = _unreachable_warning(conn, user_id, namespace_id)
423
537
  secret, tid = identity.issue_token(conn, user_id, namespace_id=namespace_id,
424
538
  permission=permission, label=label,
425
539
  expires_at=expires_at)
426
- return {"token": secret, "id": tid,
427
- "note": "store this now — it is not recoverable"}
540
+ if defer_delivery:
541
+ # The caller will commit and THEN write the file. Writing it inside the
542
+ # transaction leaves a 0600 file holding a live-looking secret for a
543
+ # token that a rollback removed — the CLI already delivers outside its
544
+ # transaction for exactly this reason, and the doors did not.
545
+ return {"secret": secret, "id": tid, "warning": warning}
546
+ out = deliver_secret(secret, tid, sink_dir)
547
+ if warning:
548
+ out["warning"] = warning
549
+ return out
550
+
551
+
552
+ def deliver_key(out: dict, sink_dir: str) -> dict:
553
+ """Apply the deployment's delivery policy to a freshly minted ENROLLMENT KEY.
554
+
555
+ The 0.9.0 wave took the token out of the reply and left the key in it — but
556
+ a key is a bearer credential for as long as it lives, and whoever reads it
557
+ first gets the account it names. An operator who set a sink said "secrets do
558
+ not come back in replies", and meant this one too.
559
+ """
560
+ if not sink_dir or "key" not in out:
561
+ return out
562
+ key = out.pop("key")
563
+ out["path"] = identity.stash_secret(sink_dir, out["id"], key,
564
+ suffix=".key")
565
+ out["delivered"] = "file"
566
+ out["exposed"] = False
567
+ out["note"] = ("the key was written to that file on the server and "
568
+ "deliberately NOT returned here — read it there and hand it "
569
+ "to its owner")
570
+ return out
571
+
572
+
573
+ def deliver_secret(secret: str, token_id: str, sink_dir: str) -> dict:
574
+ """The reply for a freshly minted token: the secret itself, or — when the
575
+ deployment set a sink — only where it was put. One function so both minting
576
+ doors (this module and the MCP self-service tool) cannot disagree about
577
+ whether a secret is allowed into a response body."""
578
+ if sink_dir:
579
+ path = identity.stash_secret(sink_dir, token_id, secret)
580
+ return {"id": token_id, "delivered": "file", "path": path,
581
+ "exposed": False,
582
+ "note": "the secret was written to that file on the server and "
583
+ "deliberately NOT returned here — read it there"}
584
+ return {"token": secret, "id": token_id, "exposed": True,
585
+ "note": "store this now — it is not recoverable. This secret was "
586
+ "returned in a reply: if that reply reached an LLM, treat "
587
+ "the token as exposed and rotate it once delivered. Set "
588
+ "MEMGRES_TOKEN_SINK to stop returning secrets at all."}
589
+
590
+
591
+ def create_enrollment(conn, p: Principal, *, user_id: str,
592
+ namespace_id: Optional[str] = None,
593
+ permission: str = "write", label: str = "",
594
+ expires_minutes: Optional[int] = None) -> dict:
595
+ """Mint a one-time key that lets `user_id` bind a token THEY generate.
596
+
597
+ Provisioning-tier, and gated exactly like `issue_token`, because it grants
598
+ the same thing by another route: whoever redeems it ends up holding a
599
+ credential for that account. What it does not do is put a secret anywhere —
600
+ the key is worthless after one use and after `expires_minutes`, and the
601
+ token itself is created on the far side and never travels.
602
+ """
603
+ require_manage_users(p)
604
+ _require_target_is_plain_user(conn, p, user_id, "issuing an enrollment key")
605
+ kw = {} if expires_minutes is None else {"expires_minutes": expires_minutes}
606
+ out = identity.create_enrollment(
607
+ conn, user_id, namespace_id=namespace_id, permission=permission,
608
+ label=label, created_by=p.user_id, **kw)
609
+ out["note"] = ("give this key to its owner over any channel you would use "
610
+ "for a meeting link — it is single-use and short-lived. They "
611
+ "generate their own token, put it in their client's config, "
612
+ "and call memory_enroll with this key.")
613
+ warning = _unreachable_warning(conn, user_id, namespace_id)
614
+ if warning:
615
+ out["warning"] = warning
616
+ return out
617
+
618
+
619
+ def list_enrollments(conn, p: Principal, *,
620
+ user_id: Optional[str] = None) -> List[dict]:
621
+ """Enrollment keys and what became of them — metadata only, never a key."""
622
+ require_manage_users(p)
623
+ if user_id is not None:
624
+ _require_target_is_plain_user(conn, p, user_id, "listing enrollment keys")
625
+ return identity.list_enrollments(conn, user_id=user_id)
626
+ # Unfiltered, the listing handed a user_manager the credential timeline of
627
+ # the accounts ABOVE it — `list_tokens` refuses exactly that. The guard has
628
+ # to apply to the shape without a target too, or it is only a speed bump.
629
+ return identity.list_enrollments(conn, plain_users_only=not p.is_admin)
630
+
631
+
632
+ def revoke_enrollment(conn, p: Principal, *, enrollment_id: str) -> bool:
633
+ """Kill an unredeemed key. False if it was already spent, revoked or absent."""
634
+ require_manage_users(p)
635
+ _require_target_is_plain_user(conn, p,
636
+ identity.enrollment_owner(conn, enrollment_id),
637
+ "revoking an enrollment key")
638
+ return identity.revoke_enrollment(conn, enrollment_id)
428
639
 
429
640
 
430
641
  def revoke_token(conn, p: Principal, *, token_id: str) -> bool:
@@ -524,11 +735,26 @@ def request_access(conn, p: Principal, *, namespace_id: str,
524
735
  """
525
736
  if p.user_id is None:
526
737
  raise identity.AuthError("this token has no owning user")
738
+ # You may not ask for more than this credential could exercise if granted.
739
+ # A read-only agent token filing an `admin` request is the input half of an
740
+ # over-grant: the approver sees a plausible ask and the account keeps the
741
+ # membership long after that token is gone.
742
+ permission = identity.perm_min(permission, p.permission)
527
743
  perm = identity.reaches(conn, p.user_id, namespace_id)
528
744
  if perm is not None:
529
745
  return {"status": "already_reachable", "permission": perm}
530
746
  identity.request_access(conn, p.user_id, namespace_id, permission)
531
- return {"status": "submitted"}
747
+ # What is actually pending, which is not always what was asked: a request
748
+ # you already hold may be lowered but not raised, and a read-only credential
749
+ # cannot ask beyond its ceiling. Saying so beats a receipt that agrees with
750
+ # you and a grant that does not.
751
+ with conn.cursor() as cur:
752
+ cur.execute("SELECT requested_permission FROM access_request WHERE "
753
+ "requester_user_id=%s AND namespace_id=%s",
754
+ (p.user_id, identity._as_uuid(namespace_id)))
755
+ row = cur.fetchone()
756
+ return {"status": "submitted",
757
+ "permission": row[0] if row else permission}
532
758
 
533
759
 
534
760
  def list_requests(conn, p: Principal, *, namespace_id: str) -> List[dict]:
@@ -537,16 +763,32 @@ def list_requests(conn, p: Principal, *, namespace_id: str) -> List[dict]:
537
763
  return identity.list_requests(conn, namespace_id)
538
764
 
539
765
 
540
- def decide_access(conn, p: Principal, *, request_id: str, approve: bool) -> None:
541
- """Approve or deny a request, authorized against the namespace it targets."""
766
+ def decide_access(conn, p: Principal, *, request_id: str, approve: bool,
767
+ expect_permission: Optional[str] = None) -> None:
768
+ """Approve or deny a request, authorized against the namespace it targets.
769
+
770
+ `expect_permission` is the permission the approver SAW in `list_requests`.
771
+ Pass it: without it, approval grants whatever the row says when it is read,
772
+ and a requester can raise their own pending ask between the listing and the
773
+ decision. `request_access` no longer amends a pending request, so this is
774
+ the second lock on the same door rather than the only one.
775
+
776
+ Both "no such request" and "a request you may not decide" answer the same
777
+ way, for the same reason `request_access` does: which uuids are real is not
778
+ something a refusal should confirm.
779
+ """
542
780
  with conn.cursor() as cur:
543
781
  cur.execute("SELECT namespace_id FROM access_request WHERE id=%s",
544
782
  (request_id,))
545
783
  row = cur.fetchone()
546
784
  if row is None:
547
785
  raise identity.SpaceNotFound("no such request")
548
- require_namespace_admin(conn, p, str(row[0]))
786
+ try:
787
+ require_namespace_admin(conn, p, str(row[0]))
788
+ except (Forbidden, identity.SpaceNotFound):
789
+ raise identity.SpaceNotFound("no such request") from None
549
790
  if approve:
550
- identity.approve_request(conn, request_id)
791
+ identity.approve_request(conn, request_id,
792
+ expect_permission=expect_permission)
551
793
  else:
552
794
  identity.deny_request(conn, request_id)
@@ -44,22 +44,26 @@ def _read_or_create_token_file(path: str) -> Tuple[str, Optional[str]]:
44
44
  """Return ``(secret, generated_path)``. If the file has a token, read it
45
45
  (``generated_path`` None). If missing/empty, generate a fresh ``mgk_`` token,
46
46
  write it ``0600``, and return the path so the caller can log it."""
47
+ # O_NOFOLLOW on the READ as well: whoever can plant a symlink here would
48
+ # otherwise CHOOSE the deployment's bootstrap admin token by pointing this
49
+ # at a file they wrote. Reading through it is the more dangerous half.
47
50
  try:
48
- with open(path, "r", encoding="utf-8") as f:
51
+ fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
52
+ with os.fdopen(fd, "r", encoding="utf-8") as f:
49
53
  existing = f.read().strip()
50
54
  except FileNotFoundError:
51
55
  existing = ""
52
56
  except OSError as e: # unreadable → fail loud
53
- raise BootstrapError(f"cannot read MEMGRES_ADMIN_TOKEN_FILE {path!r}: {e}")
57
+ raise BootstrapError(
58
+ f"cannot read MEMGRES_ADMIN_TOKEN_FILE {path!r}: {e}"
59
+ + (" (a symlink is refused here on purpose)"
60
+ if os.path.islink(path) else ""))
54
61
  if existing:
55
62
  return existing, None
56
63
 
57
64
  secret = identity.new_token()
58
65
  try:
59
- fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
60
- with os.fdopen(fd, "w", encoding="utf-8") as f:
61
- f.write(secret + "\n")
62
- os.chmod(path, 0o600) # tighten even if the file pre-existed
66
+ identity.write_private(path, secret + "\n")
63
67
  except OSError as e:
64
68
  raise BootstrapError(f"cannot write MEMGRES_ADMIN_TOKEN_FILE {path!r}: {e}")
65
69
  return secret, path
@@ -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),