memgres 0.10.0__tar.gz → 0.12.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 (112) hide show
  1. {memgres-0.10.0 → memgres-0.12.0}/PKG-INFO +2 -1
  2. {memgres-0.10.0 → memgres-0.12.0}/README.md +1 -0
  3. {memgres-0.10.0 → memgres-0.12.0}/memgres/__init__.py +2 -1
  4. {memgres-0.10.0 → memgres-0.12.0}/memgres/_version.py +1 -1
  5. {memgres-0.10.0 → memgres-0.12.0}/memgres/admin.py +82 -8
  6. {memgres-0.10.0 → memgres-0.12.0}/memgres/bootstrap.py +10 -6
  7. {memgres-0.10.0 → memgres-0.12.0}/memgres/config.py +6 -0
  8. {memgres-0.10.0 → memgres-0.12.0}/memgres/identity.py +199 -32
  9. {memgres-0.10.0 → memgres-0.12.0}/memgres/info.py +8 -0
  10. {memgres-0.10.0 → memgres-0.12.0}/memgres/links.py +63 -4
  11. {memgres-0.10.0 → memgres-0.12.0}/memgres/mcp_server.py +36 -16
  12. memgres-0.12.0/memgres/migrations/0023_relink_after_parser_fix.sql +16 -0
  13. {memgres-0.10.0 → memgres-0.12.0}/memgres/schema.py +5 -1
  14. {memgres-0.10.0 → memgres-0.12.0}/memgres/server.py +18 -6
  15. {memgres-0.10.0 → memgres-0.12.0}/memgres/store.py +69 -2
  16. {memgres-0.10.0 → memgres-0.12.0}/memgres/token_cli.py +22 -12
  17. {memgres-0.10.0 → memgres-0.12.0}/memgres.egg-info/PKG-INFO +2 -1
  18. {memgres-0.10.0 → memgres-0.12.0}/memgres.egg-info/SOURCES.txt +3 -0
  19. {memgres-0.10.0 → memgres-0.12.0}/tests/test_admin_two_way.py +15 -2
  20. {memgres-0.10.0 → memgres-0.12.0}/tests/test_links.py +43 -1
  21. {memgres-0.10.0 → memgres-0.12.0}/tests/test_list.py +1 -1
  22. memgres-0.12.0/tests/test_required_fields.py +195 -0
  23. memgres-0.12.0/tests/test_security_followups.py +353 -0
  24. {memgres-0.10.0 → memgres-0.12.0}/tests/test_security_integration.py +7 -3
  25. {memgres-0.10.0 → memgres-0.12.0}/tests/test_server_info.py +20 -1
  26. {memgres-0.10.0 → memgres-0.12.0}/tests/test_server_integration.py +6 -2
  27. {memgres-0.10.0 → memgres-0.12.0}/LICENSE +0 -0
  28. {memgres-0.10.0 → memgres-0.12.0}/memgres/admin_cli.py +0 -0
  29. {memgres-0.10.0 → memgres-0.12.0}/memgres/blame.py +0 -0
  30. {memgres-0.10.0 → memgres-0.12.0}/memgres/delimiters.py +0 -0
  31. {memgres-0.10.0 → memgres-0.12.0}/memgres/diffing.py +0 -0
  32. {memgres-0.10.0 → memgres-0.12.0}/memgres/embed_worker.py +0 -0
  33. {memgres-0.10.0 → memgres-0.12.0}/memgres/embeddings.py +0 -0
  34. {memgres-0.10.0 → memgres-0.12.0}/memgres/healthcheck.py +0 -0
  35. {memgres-0.10.0 → memgres-0.12.0}/memgres/indexing.py +0 -0
  36. {memgres-0.10.0 → memgres-0.12.0}/memgres/lines.py +0 -0
  37. {memgres-0.10.0 → memgres-0.12.0}/memgres/migrations/0001_core.sql +0 -0
  38. {memgres-0.10.0 → memgres-0.12.0}/memgres/migrations/0002_identity.sql +0 -0
  39. {memgres-0.10.0 → memgres-0.12.0}/memgres/migrations/0003_history_author.sql +0 -0
  40. {memgres-0.10.0 → memgres-0.12.0}/memgres/migrations/0004_title.sql +0 -0
  41. {memgres-0.10.0 → memgres-0.12.0}/memgres/migrations/0005_chunk_index.sql +0 -0
  42. {memgres-0.10.0 → memgres-0.12.0}/memgres/migrations/0006_reader_floor.sql +0 -0
  43. {memgres-0.10.0 → memgres-0.12.0}/memgres/migrations/0007_embed_retry.sql +0 -0
  44. {memgres-0.10.0 → memgres-0.12.0}/memgres/migrations/0008_service_roles.sql +0 -0
  45. {memgres-0.10.0 → memgres-0.12.0}/memgres/migrations/0009_create_namespace_right.sql +0 -0
  46. {memgres-0.10.0 → memgres-0.12.0}/memgres/migrations/0010_namespace_alias.sql +0 -0
  47. {memgres-0.10.0 → memgres-0.12.0}/memgres/migrations/0011_drop_default_namespace.sql +0 -0
  48. {memgres-0.10.0 → memgres-0.12.0}/memgres/migrations/0012_user_profile.sql +0 -0
  49. {memgres-0.10.0 → memgres-0.12.0}/memgres/migrations/0013_hash_version.sql +0 -0
  50. {memgres-0.10.0 → memgres-0.12.0}/memgres/migrations/0014_access_request_no_fk.sql +0 -0
  51. {memgres-0.10.0 → memgres-0.12.0}/memgres/migrations/0015_normalize_tags.sql +0 -0
  52. {memgres-0.10.0 → memgres-0.12.0}/memgres/migrations/0016_valid_at.sql +0 -0
  53. {memgres-0.10.0 → memgres-0.12.0}/memgres/migrations/0017_memory_link.sql +0 -0
  54. {memgres-0.10.0 → memgres-0.12.0}/memgres/migrations/0018_links_built.sql +0 -0
  55. {memgres-0.10.0 → memgres-0.12.0}/memgres/migrations/0019_memory_usage.sql +0 -0
  56. {memgres-0.10.0 → memgres-0.12.0}/memgres/migrations/0020_memory_usage_no_fk.sql +0 -0
  57. {memgres-0.10.0 → memgres-0.12.0}/memgres/migrations/0021_enrollment_key.sql +0 -0
  58. {memgres-0.10.0 → memgres-0.12.0}/memgres/migrations/0022_user_disabled.sql +0 -0
  59. {memgres-0.10.0 → memgres-0.12.0}/memgres/periodic.py +0 -0
  60. {memgres-0.10.0 → memgres-0.12.0}/memgres/reembed.py +0 -0
  61. {memgres-0.10.0 → memgres-0.12.0}/memgres/relink.py +0 -0
  62. {memgres-0.10.0 → memgres-0.12.0}/memgres/search.py +0 -0
  63. {memgres-0.10.0 → memgres-0.12.0}/memgres/segments.py +0 -0
  64. {memgres-0.10.0 → memgres-0.12.0}/memgres/tags.py +0 -0
  65. {memgres-0.10.0 → memgres-0.12.0}/memgres/vector/__init__.py +0 -0
  66. {memgres-0.10.0 → memgres-0.12.0}/memgres/vector/base.py +0 -0
  67. {memgres-0.10.0 → memgres-0.12.0}/memgres/vector/pgvector.py +0 -0
  68. {memgres-0.10.0 → memgres-0.12.0}/memgres/vector/qdrant.py +0 -0
  69. {memgres-0.10.0 → memgres-0.12.0}/memgres/worker.py +0 -0
  70. {memgres-0.10.0 → memgres-0.12.0}/memgres.egg-info/dependency_links.txt +0 -0
  71. {memgres-0.10.0 → memgres-0.12.0}/memgres.egg-info/entry_points.txt +0 -0
  72. {memgres-0.10.0 → memgres-0.12.0}/memgres.egg-info/requires.txt +0 -0
  73. {memgres-0.10.0 → memgres-0.12.0}/memgres.egg-info/top_level.txt +0 -0
  74. {memgres-0.10.0 → memgres-0.12.0}/pyproject.toml +0 -0
  75. {memgres-0.10.0 → memgres-0.12.0}/setup.cfg +0 -0
  76. {memgres-0.10.0 → memgres-0.12.0}/tests/test_blame_integration.py +0 -0
  77. {memgres-0.10.0 → memgres-0.12.0}/tests/test_chunk_index.py +0 -0
  78. {memgres-0.10.0 → memgres-0.12.0}/tests/test_claim_and_reembed.py +0 -0
  79. {memgres-0.10.0 → memgres-0.12.0}/tests/test_config.py +0 -0
  80. {memgres-0.10.0 → memgres-0.12.0}/tests/test_diffing.py +0 -0
  81. {memgres-0.10.0 → memgres-0.12.0}/tests/test_embed_worker.py +0 -0
  82. {memgres-0.10.0 → memgres-0.12.0}/tests/test_embeddings.py +0 -0
  83. {memgres-0.10.0 → memgres-0.12.0}/tests/test_enrollment.py +0 -0
  84. {memgres-0.10.0 → memgres-0.12.0}/tests/test_healthcheck.py +0 -0
  85. {memgres-0.10.0 → memgres-0.12.0}/tests/test_identity_integration.py +0 -0
  86. {memgres-0.10.0 → memgres-0.12.0}/tests/test_lexical_match.py +0 -0
  87. {memgres-0.10.0 → memgres-0.12.0}/tests/test_limits.py +0 -0
  88. {memgres-0.10.0 → memgres-0.12.0}/tests/test_mcp_admin_tools.py +0 -0
  89. {memgres-0.10.0 → memgres-0.12.0}/tests/test_mcp_http_transport.py +0 -0
  90. {memgres-0.10.0 → memgres-0.12.0}/tests/test_mcp_instructions.py +0 -0
  91. {memgres-0.10.0 → memgres-0.12.0}/tests/test_mcp_recall_schema.py +0 -0
  92. {memgres-0.10.0 → memgres-0.12.0}/tests/test_mcp_tool_visibility.py +0 -0
  93. {memgres-0.10.0 → memgres-0.12.0}/tests/test_mcp_tool_visibility_http.py +0 -0
  94. {memgres-0.10.0 → memgres-0.12.0}/tests/test_migration_upgrade.py +0 -0
  95. {memgres-0.10.0 → memgres-0.12.0}/tests/test_multi_space_search.py +0 -0
  96. {memgres-0.10.0 → memgres-0.12.0}/tests/test_path_addressing.py +0 -0
  97. {memgres-0.10.0 → memgres-0.12.0}/tests/test_qdrant_ca.py +0 -0
  98. {memgres-0.10.0 → memgres-0.12.0}/tests/test_qdrant_integration.py +0 -0
  99. {memgres-0.10.0 → memgres-0.12.0}/tests/test_replace_build.py +0 -0
  100. {memgres-0.10.0 → memgres-0.12.0}/tests/test_require_title.py +0 -0
  101. {memgres-0.10.0 → memgres-0.12.0}/tests/test_retention.py +0 -0
  102. {memgres-0.10.0 → memgres-0.12.0}/tests/test_roles_bootstrap.py +0 -0
  103. {memgres-0.10.0 → memgres-0.12.0}/tests/test_search_integration.py +0 -0
  104. {memgres-0.10.0 → memgres-0.12.0}/tests/test_segments.py +0 -0
  105. {memgres-0.10.0 → memgres-0.12.0}/tests/test_segments_store.py +0 -0
  106. {memgres-0.10.0 → memgres-0.12.0}/tests/test_snippets.py +0 -0
  107. {memgres-0.10.0 → memgres-0.12.0}/tests/test_store_integration.py +0 -0
  108. {memgres-0.10.0 → memgres-0.12.0}/tests/test_tags.py +0 -0
  109. {memgres-0.10.0 → memgres-0.12.0}/tests/test_token_sink.py +0 -0
  110. {memgres-0.10.0 → memgres-0.12.0}/tests/test_usage.py +0 -0
  111. {memgres-0.10.0 → memgres-0.12.0}/tests/test_valid_at.py +0 -0
  112. {memgres-0.10.0 → memgres-0.12.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.10.0
3
+ Version: 0.12.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
@@ -216,6 +216,7 @@ Everything is env, all optional (defaults suit a single-user embed). Full list i
216
216
  | `MEMGRES_TOKEN` | — | default token used when a call passes none (single-tenant endpoints) |
217
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) |
218
218
  | `MEMGRES_TREE` | `true` | `ltree` path column + GiST index (fast subtree select) |
219
+ | `MEMGRES_REQUIRED_FIELDS` | — | comma-separated fields a content-storing write must carry (`source`, `reason`), refused by name when absent. A policy about a corpus, not a property of the software: a scratch database wants none, a corporate one wants `source`. `move`/`retag` are exempt. Announced in `server_info.write_requirements` |
219
220
  | `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) |
220
221
  | `MEMGRES_REQUIRE_PARENT` | `false` | `true` = a node's parent path must already exist |
221
222
  | `MEMGRES_HISTORY` | `true` | keep the hash-chained diff history (deleted with the record) |
@@ -180,6 +180,7 @@ Everything is env, all optional (defaults suit a single-user embed). Full list i
180
180
  | `MEMGRES_TOKEN` | — | default token used when a call passes none (single-tenant endpoints) |
181
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) |
182
182
  | `MEMGRES_TREE` | `true` | `ltree` path column + GiST index (fast subtree select) |
183
+ | `MEMGRES_REQUIRED_FIELDS` | — | comma-separated fields a content-storing write must carry (`source`, `reason`), refused by name when absent. A policy about a corpus, not a property of the software: a scratch database wants none, a corporate one wants `source`. `move`/`retag` are exempt. Announced in `server_info.write_requirements` |
183
184
  | `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) |
184
185
  | `MEMGRES_REQUIRE_PARENT` | `false` | `true` = a node's parent path must already exist |
185
186
  | `MEMGRES_HISTORY` | `true` | keep the hash-chained diff history (deleted with the record) |
@@ -23,7 +23,7 @@ from .schema import migrate, SchemaMismatch, SCHEMA_VERSION
23
23
  from .search import Hit, recall
24
24
  from .blame import annotate, annotate_grouped, reconstruct, replay
25
25
  from .store import (Store, Memory, Conflict, NotFound, TooLarge, NoParent,
26
- MissingTitle)
26
+ MissingTitle, MissingField)
27
27
  from .identity import (
28
28
  Principal, AuthError, SpaceNotFound,
29
29
  resolve, resolve_space, new_token, valid_format,
@@ -38,6 +38,7 @@ __all__ = [
38
38
  "Config", "load_config",
39
39
  "Store", "Memory", "Conflict", "NotFound", "TooLarge", "NoParent",
40
40
  "MissingTitle",
41
+ "MissingField",
41
42
  "make_diff", "apply_diff", "content_hash", "DiffConflict",
42
43
  "Embedder", "get_embedder",
43
44
  "migrate", "SchemaMismatch", "SCHEMA_VERSION",
@@ -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.10.0"
11
+ __version__ = "0.12.0"
@@ -362,7 +362,14 @@ def edit_namespace(conn, p: Principal, *, namespace_id: str,
362
362
  the instruction agents read to choose a namespace could not be fixed through
363
363
  any door at all.
364
364
  """
365
- 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)
366
373
  identity.edit_namespace(conn, namespace_id, description=description,
367
374
  instruction=instruction, name=name)
368
375
  out = {"namespace_id": namespace_id}
@@ -382,6 +389,11 @@ def require_namespace_owner(conn, p: Principal, namespace_id: str) -> None:
382
389
  matters here — which is why this is its own check rather than a flag.
383
390
  """
384
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")
385
397
  if p.is_admin:
386
398
  return
387
399
  if identity.namespace_owner(conn, namespace_id) != p.user_id:
@@ -505,7 +517,7 @@ def _unreachable_warning(conn, user_id: str,
505
517
  def issue_token(conn, p: Principal, *, user_id: str,
506
518
  namespace_id: Optional[str] = None, permission: str = "write",
507
519
  label: str = "", expires_days: Optional[int] = None,
508
- sink_dir: str = "") -> dict:
520
+ sink_dir: str = "", defer_delivery: bool = False) -> dict:
509
521
  """Mint a token for `user_id`. The secret is returned once and never again.
510
522
 
511
523
  `expires_days` rather than a timestamp: both doors were converting the same
@@ -525,12 +537,39 @@ def issue_token(conn, p: Principal, *, user_id: str,
525
537
  secret, tid = identity.issue_token(conn, user_id, namespace_id=namespace_id,
526
538
  permission=permission, label=label,
527
539
  expires_at=expires_at)
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}
528
546
  out = deliver_secret(secret, tid, sink_dir)
529
547
  if warning:
530
548
  out["warning"] = warning
531
549
  return out
532
550
 
533
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
+
534
573
  def deliver_secret(secret: str, token_id: str, sink_dir: str) -> dict:
535
574
  """The reply for a freshly minted token: the secret itself, or — when the
536
575
  deployment set a sink — only where it was put. One function so both minting
@@ -583,7 +622,11 @@ def list_enrollments(conn, p: Principal, *,
583
622
  require_manage_users(p)
584
623
  if user_id is not None:
585
624
  _require_target_is_plain_user(conn, p, user_id, "listing enrollment keys")
586
- return identity.list_enrollments(conn, user_id=user_id)
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)
587
630
 
588
631
 
589
632
  def revoke_enrollment(conn, p: Principal, *, enrollment_id: str) -> bool:
@@ -692,11 +735,26 @@ def request_access(conn, p: Principal, *, namespace_id: str,
692
735
  """
693
736
  if p.user_id is None:
694
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)
695
743
  perm = identity.reaches(conn, p.user_id, namespace_id)
696
744
  if perm is not None:
697
745
  return {"status": "already_reachable", "permission": perm}
698
746
  identity.request_access(conn, p.user_id, namespace_id, permission)
699
- 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}
700
758
 
701
759
 
702
760
  def list_requests(conn, p: Principal, *, namespace_id: str) -> List[dict]:
@@ -705,16 +763,32 @@ def list_requests(conn, p: Principal, *, namespace_id: str) -> List[dict]:
705
763
  return identity.list_requests(conn, namespace_id)
706
764
 
707
765
 
708
- def decide_access(conn, p: Principal, *, request_id: str, approve: bool) -> None:
709
- """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
+ """
710
780
  with conn.cursor() as cur:
711
781
  cur.execute("SELECT namespace_id FROM access_request WHERE id=%s",
712
782
  (request_id,))
713
783
  row = cur.fetchone()
714
784
  if row is None:
715
785
  raise identity.SpaceNotFound("no such request")
716
- 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
717
790
  if approve:
718
- identity.approve_request(conn, request_id)
791
+ identity.approve_request(conn, request_id,
792
+ expect_permission=expect_permission)
719
793
  else:
720
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
@@ -79,6 +79,8 @@ class Config:
79
79
  # organization
80
80
  tree_enabled: bool # ltree path column + GiST index for fast subtree selection
81
81
  require_title: bool # True = a write that stores CONTENT must caption it
82
+ required_fields: tuple # extra fields a content-storing write must carry
83
+ # (MEMGRES_REQUIRED_FIELDS=source), refused when absent
82
84
  require_parent: bool # False = sparse paths (create food.apple with no food row);
83
85
  # True = a node's parent path must already exist as a memory
84
86
  # history
@@ -222,6 +224,10 @@ def load() -> Config:
222
224
  token_sink=_str("MEMGRES_TOKEN_SINK", ""),
223
225
  tree_enabled=_bool("MEMGRES_TREE", True),
224
226
  require_title=_bool("MEMGRES_REQUIRE_TITLE", True),
227
+ required_fields=tuple(
228
+ f for f in (x.strip().lower()
229
+ for x in _str("MEMGRES_REQUIRED_FIELDS", "").split(","))
230
+ if f),
225
231
  require_parent=_bool("MEMGRES_REQUIRE_PARENT", False),
226
232
  history_enabled=_bool("MEMGRES_HISTORY", True),
227
233
  fts_language=_str("MEMGRES_FTS_LANGUAGE", "simple"),
@@ -27,12 +27,15 @@ from __future__ import annotations
27
27
  import datetime as dt
28
28
  import hashlib
29
29
  import hmac
30
+ import logging
30
31
  import os
31
32
  import re
32
33
  import secrets
33
34
  from dataclasses import dataclass
34
35
  from typing import List, Optional, Sequence, Tuple
35
36
 
37
+ logger = logging.getLogger("memgres.identity")
38
+
36
39
  # ─── token format: mgk_ + 43 url-safe chars (256-bit) ────────────────────────
37
40
  TOKEN_RE = re.compile(r"^mgk_[A-Za-z0-9_-]{43}$")
38
41
 
@@ -205,7 +208,22 @@ def resolve(conn, cfg, secret: Optional[str], *, touch: bool = True) -> Principa
205
208
  # an account that is off has to be off for every token it
206
209
  # holds and every one it could still be issued, and the only
207
210
  # place all of those meet is authentication.
208
- raise AuthError("this account is disabled")
211
+ #
212
+ # One exception, and it is the recovery path: bootstrap
213
+ # stores the env secret AS A TOKEN of the seeded admin, so
214
+ # this lookup matches it first and disabling that account
215
+ # would take the operator's out-of-band way back in with it.
216
+ # Two ordinary control-plane calls could then leave a
217
+ # deployment nobody can enter. The env secret keeps working,
218
+ # as the anonymous root it is when no account holds it.
219
+ if not (cfg.admin_token
220
+ and hmac.compare_digest(secret, cfg.admin_token)):
221
+ raise AuthError("this account is disabled")
222
+ logger.warning(
223
+ "memgres: the bootstrap admin account is disabled; the "
224
+ "env break-glass token authenticated as anonymous root")
225
+ return Principal(user_id=None, permission="admin",
226
+ scope_namespace_id=None, is_admin=True)
209
227
  if touch:
210
228
  cur.execute("UPDATE token SET last_used_at=now() WHERE id=%s",
211
229
  (tid,))
@@ -779,21 +797,43 @@ def list_users(conn, *, role: Optional[str] = None, limit: Optional[int] = None,
779
797
 
780
798
 
781
799
  # ─── service roles (control plane; see SERVICE_ROLES) ────────────────────────
800
+ # Every anti-lockout count asks the same question — "is anyone left who can
801
+ # undo this?" — and a disabled account is not anyone. Two of these used to count
802
+ # disabled admins as present, which let the guards be walked around: disable the
803
+ # second superadmin (allowed, two are active), then DEMOTE the first through
804
+ # `revoke_superadmin`, which saw two superadmins and agreed. End state: zero
805
+ # active superadmins and recovery by hand-editing the database.
806
+ _ACTIVE_ADMIN = "role IN ('user_manager','superadmin') AND disabled_at IS NULL"
807
+ _ACTIVE_SUPERADMIN = "role='superadmin' AND disabled_at IS NULL"
808
+
809
+
782
810
  def count_service_admins(conn) -> int:
783
- """How many users hold an admin role (user_manager or superadmin). Zero ⇒ a
784
- fresh install with no control plane — the trigger for bootstrap seeding."""
811
+ """How many users hold an admin role AND can still use it. Zero ⇒ nobody is
812
+ running the control plane — the trigger for bootstrap seeding, which is
813
+ exactly what should happen when the last admin has been switched off."""
785
814
  with conn.cursor() as cur:
786
- cur.execute("SELECT count(*) FROM app_user WHERE role IN "
787
- "('user_manager','superadmin')")
815
+ cur.execute(f"SELECT count(*) FROM app_user WHERE {_ACTIVE_ADMIN}")
788
816
  return int(cur.fetchone()[0])
789
817
 
790
818
 
791
819
  def count_superadmins(conn) -> int:
820
+ """Active superadmins. See `_ACTIVE_ADMIN` for why `disabled_at` matters."""
792
821
  with conn.cursor() as cur:
793
- cur.execute("SELECT count(*) FROM app_user WHERE role='superadmin'")
822
+ cur.execute(f"SELECT count(*) FROM app_user WHERE {_ACTIVE_SUPERADMIN}")
794
823
  return int(cur.fetchone()[0])
795
824
 
796
825
 
826
+ def _lock_admin_rows(cur) -> None:
827
+ """Serialize the anti-lockout guards against each other.
828
+
829
+ Both `set_disabled` and `revoke_superadmin` read a count and then write.
830
+ Two sessions removing the last two superadmins each saw two, and both were
831
+ allowed. Locking the admin rows first makes the second one read the first
832
+ one's result instead of the state it started from.
833
+ """
834
+ cur.execute(f"SELECT id FROM app_user WHERE {_ACTIVE_ADMIN} FOR UPDATE")
835
+
836
+
797
837
  def get_role(conn, user_id: str) -> Optional[str]:
798
838
  with conn.cursor() as cur:
799
839
  cur.execute("SELECT role FROM app_user WHERE id=%s", (user_id,))
@@ -828,9 +868,10 @@ def revoke_superadmin(conn, user_id: str, *, demote_to: str = "user") -> None:
828
868
  raise SpaceNotFound(f"no such user {user_id}")
829
869
  if row[0] != "superadmin":
830
870
  return # nothing to revoke
831
- cur.execute("SELECT count(*) FROM app_user WHERE role='superadmin'")
871
+ _lock_admin_rows(cur)
872
+ cur.execute(f"SELECT count(*) FROM app_user WHERE {_ACTIVE_SUPERADMIN}")
832
873
  if int(cur.fetchone()[0]) <= 1:
833
- raise AuthError("cannot revoke the last superadmin")
874
+ raise AuthError("cannot revoke the last active superadmin")
834
875
  cur.execute("UPDATE app_user SET role=%s WHERE id=%s", (demote_to, user_id))
835
876
 
836
877
 
@@ -853,8 +894,8 @@ def set_disabled(conn, user_id: str, disabled: bool) -> dict:
853
894
  return {"user_id": str(user_id), "disabled": bool(disabled),
854
895
  "unchanged": True}
855
896
  if disabled and role == "superadmin":
856
- cur.execute("SELECT count(*) FROM app_user WHERE role='superadmin' "
857
- "AND disabled_at IS NULL")
897
+ _lock_admin_rows(cur)
898
+ cur.execute(f"SELECT count(*) FROM app_user WHERE {_ACTIVE_SUPERADMIN}")
858
899
  if int(cur.fetchone()[0]) <= 1:
859
900
  raise AuthError("cannot disable the last active superadmin")
860
901
  cur.execute("UPDATE app_user SET disabled_at = %s WHERE id=%s",
@@ -1139,8 +1180,28 @@ def transfer_namespace(conn, namespace_id: str, new_owner_user_id: str, *,
1139
1180
  # The receiving account has its own names and aliases, and (owner, name)
1140
1181
  # is unique: without this the transfer dies on a constraint violation
1141
1182
  # rather than on an explanation.
1142
- _require_name_free(cur, str(new_owner_user_id), name,
1143
- except_namespace_id=namespace_id)
1183
+ #
1184
+ # The message deliberately does NOT say which of theirs it collides
1185
+ # with. Quoting the target's inventory made this a free, repeatable
1186
+ # probe of another tenant's namespace names — the check runs before the
1187
+ # UPDATE, so a positive costs the prober nothing.
1188
+ try:
1189
+ _require_name_free(cur, str(new_owner_user_id), name,
1190
+ except_namespace_id=namespace_id)
1191
+ except SpaceAmbiguous:
1192
+ raise SpaceAmbiguous(
1193
+ "the receiving account cannot hold a namespace by this name — "
1194
+ "rename it first, then transfer") from None
1195
+ # The per-account cap is enforced where namespaces are CREATED; a
1196
+ # transfer is the other way one arrives. Without this an account could
1197
+ # mint its 50, push them onto someone else, and repeat — leaving a
1198
+ # victim owning namespaces they never asked for and cannot delete.
1199
+ cur.execute("SELECT count(*) FROM namespace WHERE owner_user_id=%s",
1200
+ (_as_uuid(new_owner_user_id),))
1201
+ if cur.fetchone()[0] >= MAX_NAMESPACES_PER_USER:
1202
+ raise SpaceAmbiguous(
1203
+ f"the receiving account already owns {MAX_NAMESPACES_PER_USER} "
1204
+ "namespaces, which is the cap")
1144
1205
  cur.execute("DELETE FROM namespace_member WHERE namespace_id=%s AND "
1145
1206
  "user_id=%s", (_as_uuid(namespace_id), _as_uuid(new_owner_user_id)))
1146
1207
  cur.execute("UPDATE namespace SET owner_user_id=%s WHERE id=%s",
@@ -1258,8 +1319,9 @@ def issue_token(conn, user_id: str, *, namespace_id: Optional[str] = None,
1258
1319
  return secret, str(cur.fetchone()[0])
1259
1320
 
1260
1321
 
1261
- def stash_secret(sink_dir: str, token_id: str, secret: str) -> str:
1262
- """Write a freshly minted secret to ``<sink_dir>/<token_id>.token`` (0600)
1322
+ def stash_secret(sink_dir: str, token_id: str, secret: str,
1323
+ *, suffix: str = ".token") -> str:
1324
+ """Write a freshly minted secret to ``<sink_dir>/<token_id><suffix>`` (0600)
1263
1325
  and return the path.
1264
1326
 
1265
1327
  The out-of-band delivery channel for a deployment whose callers are agents.
@@ -1271,15 +1333,57 @@ def stash_secret(sink_dir: str, token_id: str, secret: str) -> str:
1271
1333
  The directory is created 0700 if missing, and the file is opened with
1272
1334
  ``O_CREAT`` at 0600 so the secret is never briefly world-readable.
1273
1335
  """
1274
- os.makedirs(sink_dir, mode=0o700, exist_ok=True)
1275
- path = os.path.join(sink_dir, f"{token_id}.token")
1276
- fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
1277
- with os.fdopen(fd, "w") as fh:
1278
- fh.write(secret + "\n")
1279
- os.chmod(path, 0o600) # tighten even if the file pre-existed
1336
+ _make_private_dir(sink_dir)
1337
+ path = os.path.join(sink_dir, f"{token_id}{suffix}")
1338
+ write_private(path, secret + "\n")
1280
1339
  return path
1281
1340
 
1282
1341
 
1342
+ def _make_private_dir(path: str) -> None:
1343
+ """Create `path` (and its parents) 0700, and tighten it if it already exists.
1344
+
1345
+ `os.makedirs(mode=…)` applies the mode to the LEAF only, and not at all to a
1346
+ directory that is already there — so a sink an operator made by hand at 0755
1347
+ stayed 0755, and every intermediate directory was created 0775. The
1348
+ docstring above used to promise otherwise, which is the worse half: an
1349
+ operator who read it believed the secrets were private.
1350
+ """
1351
+ parts = []
1352
+ head = os.path.abspath(path)
1353
+ while head and not os.path.isdir(head):
1354
+ head, tail = os.path.split(head)
1355
+ parts.append(os.path.join(head, tail))
1356
+ for d in reversed(parts):
1357
+ os.mkdir(d, 0o700)
1358
+ if os.path.isdir(path) and not os.path.islink(path):
1359
+ os.chmod(path, 0o700)
1360
+
1361
+
1362
+ def write_private(path: str, text: str) -> None:
1363
+ """Write `text` to `path` as a fresh 0600 file, refusing to follow a symlink.
1364
+
1365
+ Three defects this closes, all measured:
1366
+
1367
+ * without ``O_NOFOLLOW`` the write follows a symlink, so anyone who can
1368
+ plant one where a secret is about to land redirects it into a file they
1369
+ own — proven end to end with the provisioning CLI;
1370
+ * without ``O_EXCL`` (and the unlink before it), ``O_CREAT``'s mode is
1371
+ IGNORED for a file that already exists, so the secret sat in a 0644 file
1372
+ for the duration of the write and only became 0600 afterwards;
1373
+ * a partially written secret is never left behind, because the target is
1374
+ removed and recreated rather than truncated in place.
1375
+ """
1376
+ try:
1377
+ os.unlink(path)
1378
+ except FileNotFoundError:
1379
+ pass
1380
+ except IsADirectoryError:
1381
+ raise ValueError(f"{path} is a directory") from None
1382
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
1383
+ with os.fdopen(fd, "w") as fh:
1384
+ fh.write(text)
1385
+
1386
+
1283
1387
  # ─── enrollment: bind a token the client generated itself ────────────────────
1284
1388
  def new_enrollment_key() -> str:
1285
1389
  """A fresh one-time key: ``mge_`` + 43 url-safe chars."""
@@ -1381,6 +1485,15 @@ def redeem_enrollment(conn, key: str, secret: str) -> dict:
1381
1485
  if cur.fetchone() is not None:
1382
1486
  raise AuthError("that credential is already known to this server — "
1383
1487
  "generate a fresh one")
1488
+ # An offboarded account still had usable keys in flight: the binding
1489
+ # succeeded, the key was spent, the reply said "bound" — and the token
1490
+ # was refused at the first call. No access was gained, but the answer
1491
+ # was a lie and the key was gone. Refuse while it still costs nothing.
1492
+ cur.execute("SELECT 1 FROM app_user WHERE id=%s AND disabled_at IS NOT NULL",
1493
+ (uid,))
1494
+ if cur.fetchone() is not None:
1495
+ raise AuthError("that account is disabled — ask whoever issued this "
1496
+ "key to re-enable it first")
1384
1497
  tid = register_token(conn, str(uid), secret, namespace_id=
1385
1498
  str(nsid) if nsid else None,
1386
1499
  permission=perm, label=label or "enrolled")
@@ -1391,19 +1504,31 @@ def redeem_enrollment(conn, key: str, secret: str) -> dict:
1391
1504
  "enrollment_id": str(eid)}
1392
1505
 
1393
1506
 
1394
- def list_enrollments(conn, *, user_id: Optional[str] = None) -> List[dict]:
1395
- """Pending and spent keys — metadata only, never the key itself."""
1507
+ def list_enrollments(conn, *, user_id: Optional[str] = None,
1508
+ plain_users_only: bool = False) -> List[dict]:
1509
+ """Pending and spent keys — metadata only, never the key itself.
1510
+
1511
+ `plain_users_only` hides keys belonging to admin-role accounts, which is
1512
+ what the provisioning tier is allowed to see (see `admin.list_enrollments`).
1513
+ """
1396
1514
  # `expired` is computed by the DATABASE: the key's clock and the reader's
1397
1515
  # are not the same clock, and a listing that disagreed with what redeeming
1398
1516
  # does would be worse than no listing.
1399
- sql = ("SELECT id, user_id, namespace_id, permission, label, created_by, "
1400
- "created_at, expires_at, used_at, used_token_id, revoked_at, "
1401
- "expires_at <= now() AS expired FROM enrollment_key")
1517
+ sql = ("SELECT k.id, k.user_id, k.namespace_id, k.permission, k.label, "
1518
+ "k.created_by, k.created_at, k.expires_at, k.used_at, "
1519
+ "k.used_token_id, k.revoked_at, k.expires_at <= now() AS expired "
1520
+ "FROM enrollment_key k")
1402
1521
  args: list = []
1522
+ where = []
1403
1523
  if user_id is not None:
1404
- sql += " WHERE user_id=%s"
1524
+ where.append("k.user_id=%s")
1405
1525
  args.append(user_id)
1406
- sql += " ORDER BY created_at DESC, id"
1526
+ if plain_users_only:
1527
+ sql += " JOIN app_user u ON u.id = k.user_id"
1528
+ where.append("u.role = 'user'")
1529
+ if where:
1530
+ sql += " WHERE " + " AND ".join(where)
1531
+ sql += " ORDER BY k.created_at DESC, k.id"
1407
1532
  with conn.cursor() as cur:
1408
1533
  cur.execute(sql, args)
1409
1534
  cols = [c.name for c in cur.description]
@@ -1578,14 +1703,42 @@ def request_access(conn, requester_user_id: str, namespace_id: str,
1578
1703
  raise ValueError(
1579
1704
  f"you already have {MAX_PENDING_REQUESTS_PER_USER} requests "
1580
1705
  "waiting to be decided, which is the cap")
1706
+ # 🔴 A PENDING request may be LOWERED but never RAISED. It used to be
1707
+ # freely rewritable, so a requester could file `read`, let the owner see
1708
+ # `read` in `list_requests`, and raise it to `admin` before — or
1709
+ # during — the approval: approval grants what the row says when it is
1710
+ # read, so the admin authorized one thing and granted another. Measured
1711
+ # working both across the natural gap and inside the approver's own
1712
+ # transaction, since READ COMMITTED gives each statement its own
1713
+ # snapshot.
1714
+ #
1715
+ # Lowering stays allowed because it cannot be an over-grant, and because
1716
+ # a caller at the cap has no other way to withdraw an `admin` ask (an
1717
+ # amendment adds no row, so the cap does not apply to it). A DECIDED
1718
+ # request may be re-opened at any level: that is a fresh decision the
1719
+ # owner makes with the value in front of them.
1720
+ rank = "array_position(ARRAY['read','write','admin'], %s)"
1581
1721
  cur.execute(
1582
1722
  "INSERT INTO access_request (requester_user_id, namespace_id, "
1583
1723
  "requested_permission) VALUES (%s, %s, %s) "
1584
1724
  "ON CONFLICT (requester_user_id, namespace_id) DO UPDATE "
1585
1725
  "SET requested_permission=EXCLUDED.requested_permission, "
1586
- " status='pending', decided_at=NULL RETURNING id",
1726
+ " status='pending', decided_at=NULL "
1727
+ "WHERE access_request.status <> 'pending' "
1728
+ f" OR {rank % 'EXCLUDED.requested_permission'} < "
1729
+ f" {rank % 'access_request.requested_permission'} "
1730
+ "RETURNING id, requested_permission",
1587
1731
  (requester_user_id, namespace_id, permission))
1588
- return str(cur.fetchone()[0])
1732
+ row = cur.fetchone()
1733
+ if row is not None:
1734
+ return str(row[0])
1735
+ # The conflicting row was pending and at least as strong: it stands, and
1736
+ # the caller is told which permission is actually on the table.
1737
+ cur.execute("SELECT id, requested_permission FROM access_request "
1738
+ "WHERE requester_user_id=%s AND namespace_id=%s",
1739
+ (requester_user_id, namespace_id))
1740
+ row = cur.fetchone()
1741
+ return str(row[0])
1589
1742
 
1590
1743
 
1591
1744
  def list_requests(conn, namespace_id: str, *, pending_only: bool = True) -> List[dict]:
@@ -1607,16 +1760,30 @@ def list_requests(conn, namespace_id: str, *, pending_only: bool = True) -> List
1607
1760
  return out
1608
1761
 
1609
1762
 
1610
- def approve_request(conn, request_id: str) -> None:
1611
- """Grant the requested membership and close the request."""
1763
+ def approve_request(conn, request_id: str, *,
1764
+ expect_permission: Optional[str] = None) -> None:
1765
+ """Grant the requested membership and close the request.
1766
+
1767
+ `expect_permission` is what the approver SAW when they decided. Supplying it
1768
+ turns "the request changed under me" from a silent over-grant into a refusal.
1769
+ """
1612
1770
  with conn.cursor() as cur:
1771
+ # FOR UPDATE so the row cannot change between the authorization above
1772
+ # and the grant below. Belt to the braces in `request_access`: that stops
1773
+ # a pending request being amended at all, this stops any future writer
1774
+ # from reintroducing the race.
1613
1775
  cur.execute("SELECT requester_user_id, namespace_id, requested_permission "
1614
- "FROM access_request WHERE id=%s AND status='pending'",
1776
+ "FROM access_request WHERE id=%s AND status='pending' "
1777
+ "FOR UPDATE",
1615
1778
  (request_id,))
1616
1779
  row = cur.fetchone()
1617
1780
  if row is None:
1618
1781
  raise SpaceNotFound(f"no pending request {request_id}")
1619
1782
  requester, nsid, perm = row
1783
+ if expect_permission is not None and perm != expect_permission:
1784
+ raise AuthError(
1785
+ f"this request now asks for '{perm}', not '{expect_permission}' "
1786
+ "— look at it again before approving")
1620
1787
  add_member(conn, str(nsid), str(requester), perm)
1621
1788
  with conn.cursor() as cur:
1622
1789
  cur.execute("UPDATE access_request SET status='approved', decided_at=now() "
@@ -65,6 +65,14 @@ def server_info(cfg: Config, embed_dim: Optional[int] = None) -> dict:
65
65
  "renew_on_read": bool(days > 0 and cfg.renew_on_read),
66
66
  "policy": _retention_policy(days, cfg.renew_on_read),
67
67
  },
68
+ # What a write MUST carry here. Announced rather than discovered from a
69
+ # refusal: a client that learns the rule by being rejected has already
70
+ # composed the memory, and a rule nobody can see before writing is a rule
71
+ # that gets satisfied with junk on the second attempt.
72
+ "write_requirements": {
73
+ "title": cfg.require_title,
74
+ "fields": list(cfg.required_fields),
75
+ },
68
76
  "recall_modes": ["lexical"] if lexical_only
69
77
  else ["lexical", "semantic", "hybrid", "auto"],
70
78
  "vector_backend": cfg.vector_backend,