memgres 0.9.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.9.0 → memgres-0.11.0}/PKG-INFO +1 -1
  2. {memgres-0.9.0 → memgres-0.11.0}/memgres/_version.py +1 -1
  3. {memgres-0.9.0 → memgres-0.11.0}/memgres/admin.py +190 -14
  4. {memgres-0.9.0 → memgres-0.11.0}/memgres/bootstrap.py +10 -6
  5. {memgres-0.9.0 → memgres-0.11.0}/memgres/identity.py +385 -40
  6. {memgres-0.9.0 → memgres-0.11.0}/memgres/mcp_server.py +138 -20
  7. memgres-0.11.0/memgres/migrations/0022_user_disabled.sql +12 -0
  8. {memgres-0.9.0 → memgres-0.11.0}/memgres/schema.py +1 -1
  9. {memgres-0.9.0 → memgres-0.11.0}/memgres/server.py +49 -7
  10. {memgres-0.9.0 → memgres-0.11.0}/memgres/token_cli.py +22 -12
  11. {memgres-0.9.0 → memgres-0.11.0}/memgres.egg-info/PKG-INFO +1 -1
  12. {memgres-0.9.0 → memgres-0.11.0}/memgres.egg-info/SOURCES.txt +3 -0
  13. memgres-0.11.0/tests/test_admin_two_way.py +415 -0
  14. {memgres-0.9.0 → memgres-0.11.0}/tests/test_mcp_admin_tools.py +6 -0
  15. memgres-0.11.0/tests/test_security_followups.py +353 -0
  16. {memgres-0.9.0 → memgres-0.11.0}/tests/test_security_integration.py +7 -3
  17. {memgres-0.9.0 → memgres-0.11.0}/tests/test_server_integration.py +4 -1
  18. {memgres-0.9.0 → memgres-0.11.0}/LICENSE +0 -0
  19. {memgres-0.9.0 → memgres-0.11.0}/README.md +0 -0
  20. {memgres-0.9.0 → memgres-0.11.0}/memgres/__init__.py +0 -0
  21. {memgres-0.9.0 → memgres-0.11.0}/memgres/admin_cli.py +0 -0
  22. {memgres-0.9.0 → memgres-0.11.0}/memgres/blame.py +0 -0
  23. {memgres-0.9.0 → memgres-0.11.0}/memgres/config.py +0 -0
  24. {memgres-0.9.0 → memgres-0.11.0}/memgres/delimiters.py +0 -0
  25. {memgres-0.9.0 → memgres-0.11.0}/memgres/diffing.py +0 -0
  26. {memgres-0.9.0 → memgres-0.11.0}/memgres/embed_worker.py +0 -0
  27. {memgres-0.9.0 → memgres-0.11.0}/memgres/embeddings.py +0 -0
  28. {memgres-0.9.0 → memgres-0.11.0}/memgres/healthcheck.py +0 -0
  29. {memgres-0.9.0 → memgres-0.11.0}/memgres/indexing.py +0 -0
  30. {memgres-0.9.0 → memgres-0.11.0}/memgres/info.py +0 -0
  31. {memgres-0.9.0 → memgres-0.11.0}/memgres/lines.py +0 -0
  32. {memgres-0.9.0 → memgres-0.11.0}/memgres/links.py +0 -0
  33. {memgres-0.9.0 → memgres-0.11.0}/memgres/migrations/0001_core.sql +0 -0
  34. {memgres-0.9.0 → memgres-0.11.0}/memgres/migrations/0002_identity.sql +0 -0
  35. {memgres-0.9.0 → memgres-0.11.0}/memgres/migrations/0003_history_author.sql +0 -0
  36. {memgres-0.9.0 → memgres-0.11.0}/memgres/migrations/0004_title.sql +0 -0
  37. {memgres-0.9.0 → memgres-0.11.0}/memgres/migrations/0005_chunk_index.sql +0 -0
  38. {memgres-0.9.0 → memgres-0.11.0}/memgres/migrations/0006_reader_floor.sql +0 -0
  39. {memgres-0.9.0 → memgres-0.11.0}/memgres/migrations/0007_embed_retry.sql +0 -0
  40. {memgres-0.9.0 → memgres-0.11.0}/memgres/migrations/0008_service_roles.sql +0 -0
  41. {memgres-0.9.0 → memgres-0.11.0}/memgres/migrations/0009_create_namespace_right.sql +0 -0
  42. {memgres-0.9.0 → memgres-0.11.0}/memgres/migrations/0010_namespace_alias.sql +0 -0
  43. {memgres-0.9.0 → memgres-0.11.0}/memgres/migrations/0011_drop_default_namespace.sql +0 -0
  44. {memgres-0.9.0 → memgres-0.11.0}/memgres/migrations/0012_user_profile.sql +0 -0
  45. {memgres-0.9.0 → memgres-0.11.0}/memgres/migrations/0013_hash_version.sql +0 -0
  46. {memgres-0.9.0 → memgres-0.11.0}/memgres/migrations/0014_access_request_no_fk.sql +0 -0
  47. {memgres-0.9.0 → memgres-0.11.0}/memgres/migrations/0015_normalize_tags.sql +0 -0
  48. {memgres-0.9.0 → memgres-0.11.0}/memgres/migrations/0016_valid_at.sql +0 -0
  49. {memgres-0.9.0 → memgres-0.11.0}/memgres/migrations/0017_memory_link.sql +0 -0
  50. {memgres-0.9.0 → memgres-0.11.0}/memgres/migrations/0018_links_built.sql +0 -0
  51. {memgres-0.9.0 → memgres-0.11.0}/memgres/migrations/0019_memory_usage.sql +0 -0
  52. {memgres-0.9.0 → memgres-0.11.0}/memgres/migrations/0020_memory_usage_no_fk.sql +0 -0
  53. {memgres-0.9.0 → memgres-0.11.0}/memgres/migrations/0021_enrollment_key.sql +0 -0
  54. {memgres-0.9.0 → memgres-0.11.0}/memgres/periodic.py +0 -0
  55. {memgres-0.9.0 → memgres-0.11.0}/memgres/reembed.py +0 -0
  56. {memgres-0.9.0 → memgres-0.11.0}/memgres/relink.py +0 -0
  57. {memgres-0.9.0 → memgres-0.11.0}/memgres/search.py +0 -0
  58. {memgres-0.9.0 → memgres-0.11.0}/memgres/segments.py +0 -0
  59. {memgres-0.9.0 → memgres-0.11.0}/memgres/store.py +0 -0
  60. {memgres-0.9.0 → memgres-0.11.0}/memgres/tags.py +0 -0
  61. {memgres-0.9.0 → memgres-0.11.0}/memgres/vector/__init__.py +0 -0
  62. {memgres-0.9.0 → memgres-0.11.0}/memgres/vector/base.py +0 -0
  63. {memgres-0.9.0 → memgres-0.11.0}/memgres/vector/pgvector.py +0 -0
  64. {memgres-0.9.0 → memgres-0.11.0}/memgres/vector/qdrant.py +0 -0
  65. {memgres-0.9.0 → memgres-0.11.0}/memgres/worker.py +0 -0
  66. {memgres-0.9.0 → memgres-0.11.0}/memgres.egg-info/dependency_links.txt +0 -0
  67. {memgres-0.9.0 → memgres-0.11.0}/memgres.egg-info/entry_points.txt +0 -0
  68. {memgres-0.9.0 → memgres-0.11.0}/memgres.egg-info/requires.txt +0 -0
  69. {memgres-0.9.0 → memgres-0.11.0}/memgres.egg-info/top_level.txt +0 -0
  70. {memgres-0.9.0 → memgres-0.11.0}/pyproject.toml +0 -0
  71. {memgres-0.9.0 → memgres-0.11.0}/setup.cfg +0 -0
  72. {memgres-0.9.0 → memgres-0.11.0}/tests/test_blame_integration.py +0 -0
  73. {memgres-0.9.0 → memgres-0.11.0}/tests/test_chunk_index.py +0 -0
  74. {memgres-0.9.0 → memgres-0.11.0}/tests/test_claim_and_reembed.py +0 -0
  75. {memgres-0.9.0 → memgres-0.11.0}/tests/test_config.py +0 -0
  76. {memgres-0.9.0 → memgres-0.11.0}/tests/test_diffing.py +0 -0
  77. {memgres-0.9.0 → memgres-0.11.0}/tests/test_embed_worker.py +0 -0
  78. {memgres-0.9.0 → memgres-0.11.0}/tests/test_embeddings.py +0 -0
  79. {memgres-0.9.0 → memgres-0.11.0}/tests/test_enrollment.py +0 -0
  80. {memgres-0.9.0 → memgres-0.11.0}/tests/test_healthcheck.py +0 -0
  81. {memgres-0.9.0 → memgres-0.11.0}/tests/test_identity_integration.py +0 -0
  82. {memgres-0.9.0 → memgres-0.11.0}/tests/test_lexical_match.py +0 -0
  83. {memgres-0.9.0 → memgres-0.11.0}/tests/test_limits.py +0 -0
  84. {memgres-0.9.0 → memgres-0.11.0}/tests/test_links.py +0 -0
  85. {memgres-0.9.0 → memgres-0.11.0}/tests/test_list.py +0 -0
  86. {memgres-0.9.0 → memgres-0.11.0}/tests/test_mcp_http_transport.py +0 -0
  87. {memgres-0.9.0 → memgres-0.11.0}/tests/test_mcp_instructions.py +0 -0
  88. {memgres-0.9.0 → memgres-0.11.0}/tests/test_mcp_recall_schema.py +0 -0
  89. {memgres-0.9.0 → memgres-0.11.0}/tests/test_mcp_tool_visibility.py +0 -0
  90. {memgres-0.9.0 → memgres-0.11.0}/tests/test_mcp_tool_visibility_http.py +0 -0
  91. {memgres-0.9.0 → memgres-0.11.0}/tests/test_migration_upgrade.py +0 -0
  92. {memgres-0.9.0 → memgres-0.11.0}/tests/test_multi_space_search.py +0 -0
  93. {memgres-0.9.0 → memgres-0.11.0}/tests/test_path_addressing.py +0 -0
  94. {memgres-0.9.0 → memgres-0.11.0}/tests/test_qdrant_ca.py +0 -0
  95. {memgres-0.9.0 → memgres-0.11.0}/tests/test_qdrant_integration.py +0 -0
  96. {memgres-0.9.0 → memgres-0.11.0}/tests/test_replace_build.py +0 -0
  97. {memgres-0.9.0 → memgres-0.11.0}/tests/test_require_title.py +0 -0
  98. {memgres-0.9.0 → memgres-0.11.0}/tests/test_retention.py +0 -0
  99. {memgres-0.9.0 → memgres-0.11.0}/tests/test_roles_bootstrap.py +0 -0
  100. {memgres-0.9.0 → memgres-0.11.0}/tests/test_search_integration.py +0 -0
  101. {memgres-0.9.0 → memgres-0.11.0}/tests/test_segments.py +0 -0
  102. {memgres-0.9.0 → memgres-0.11.0}/tests/test_segments_store.py +0 -0
  103. {memgres-0.9.0 → memgres-0.11.0}/tests/test_server_info.py +0 -0
  104. {memgres-0.9.0 → memgres-0.11.0}/tests/test_snippets.py +0 -0
  105. {memgres-0.9.0 → memgres-0.11.0}/tests/test_store_integration.py +0 -0
  106. {memgres-0.9.0 → memgres-0.11.0}/tests/test_tags.py +0 -0
  107. {memgres-0.9.0 → memgres-0.11.0}/tests/test_token_sink.py +0 -0
  108. {memgres-0.9.0 → memgres-0.11.0}/tests/test_usage.py +0 -0
  109. {memgres-0.9.0 → memgres-0.11.0}/tests/test_valid_at.py +0 -0
  110. {memgres-0.9.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.9.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
@@ -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.9.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,10 +493,31 @@ 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
519
  label: str = "", expires_days: Optional[int] = None,
413
- sink_dir: str = "") -> dict:
520
+ sink_dir: str = "", defer_delivery: bool = False) -> dict:
414
521
  """Mint a token for `user_id`. The secret is returned once and never again.
415
522
 
416
523
  `expires_days` rather than a timestamp: both doors were converting the same
@@ -426,10 +533,41 @@ def issue_token(conn, p: Principal, *, user_id: str,
426
533
  expires_at = None
427
534
  if expires_days:
428
535
  expires_at = dt.datetime.now(dt.timezone.utc) + dt.timedelta(days=expires_days)
536
+ warning = _unreachable_warning(conn, user_id, namespace_id)
429
537
  secret, tid = identity.issue_token(conn, user_id, namespace_id=namespace_id,
430
538
  permission=permission, label=label,
431
539
  expires_at=expires_at)
432
- return deliver_secret(secret, tid, sink_dir)
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
433
571
 
434
572
 
435
573
  def deliver_secret(secret: str, token_id: str, sink_dir: str) -> dict:
@@ -472,6 +610,9 @@ def create_enrollment(conn, p: Principal, *, user_id: str,
472
610
  "for a meeting link — it is single-use and short-lived. They "
473
611
  "generate their own token, put it in their client's config, "
474
612
  "and call memory_enroll with this key.")
613
+ warning = _unreachable_warning(conn, user_id, namespace_id)
614
+ if warning:
615
+ out["warning"] = warning
475
616
  return out
476
617
 
477
618
 
@@ -481,7 +622,11 @@ def list_enrollments(conn, p: Principal, *,
481
622
  require_manage_users(p)
482
623
  if user_id is not None:
483
624
  _require_target_is_plain_user(conn, p, user_id, "listing enrollment keys")
484
- 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)
485
630
 
486
631
 
487
632
  def revoke_enrollment(conn, p: Principal, *, enrollment_id: str) -> bool:
@@ -590,11 +735,26 @@ def request_access(conn, p: Principal, *, namespace_id: str,
590
735
  """
591
736
  if p.user_id is None:
592
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)
593
743
  perm = identity.reaches(conn, p.user_id, namespace_id)
594
744
  if perm is not None:
595
745
  return {"status": "already_reachable", "permission": perm}
596
746
  identity.request_access(conn, p.user_id, namespace_id, permission)
597
- 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}
598
758
 
599
759
 
600
760
  def list_requests(conn, p: Principal, *, namespace_id: str) -> List[dict]:
@@ -603,16 +763,32 @@ def list_requests(conn, p: Principal, *, namespace_id: str) -> List[dict]:
603
763
  return identity.list_requests(conn, namespace_id)
604
764
 
605
765
 
606
- def decide_access(conn, p: Principal, *, request_id: str, approve: bool) -> None:
607
- """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
+ """
608
780
  with conn.cursor() as cur:
609
781
  cur.execute("SELECT namespace_id FROM access_request WHERE id=%s",
610
782
  (request_id,))
611
783
  row = cur.fetchone()
612
784
  if row is None:
613
785
  raise identity.SpaceNotFound("no such request")
614
- 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
615
790
  if approve:
616
- identity.approve_request(conn, request_id)
791
+ identity.approve_request(conn, request_id,
792
+ expect_permission=expect_permission)
617
793
  else:
618
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