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.
- {memgres-0.9.0 → memgres-0.11.0}/PKG-INFO +1 -1
- {memgres-0.9.0 → memgres-0.11.0}/memgres/_version.py +1 -1
- {memgres-0.9.0 → memgres-0.11.0}/memgres/admin.py +190 -14
- {memgres-0.9.0 → memgres-0.11.0}/memgres/bootstrap.py +10 -6
- {memgres-0.9.0 → memgres-0.11.0}/memgres/identity.py +385 -40
- {memgres-0.9.0 → memgres-0.11.0}/memgres/mcp_server.py +138 -20
- memgres-0.11.0/memgres/migrations/0022_user_disabled.sql +12 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres/schema.py +1 -1
- {memgres-0.9.0 → memgres-0.11.0}/memgres/server.py +49 -7
- {memgres-0.9.0 → memgres-0.11.0}/memgres/token_cli.py +22 -12
- {memgres-0.9.0 → memgres-0.11.0}/memgres.egg-info/PKG-INFO +1 -1
- {memgres-0.9.0 → memgres-0.11.0}/memgres.egg-info/SOURCES.txt +3 -0
- memgres-0.11.0/tests/test_admin_two_way.py +415 -0
- {memgres-0.9.0 → memgres-0.11.0}/tests/test_mcp_admin_tools.py +6 -0
- memgres-0.11.0/tests/test_security_followups.py +353 -0
- {memgres-0.9.0 → memgres-0.11.0}/tests/test_security_integration.py +7 -3
- {memgres-0.9.0 → memgres-0.11.0}/tests/test_server_integration.py +4 -1
- {memgres-0.9.0 → memgres-0.11.0}/LICENSE +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/README.md +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres/__init__.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres/admin_cli.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres/blame.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres/config.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres/delimiters.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres/diffing.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres/embed_worker.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres/embeddings.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres/healthcheck.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres/indexing.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres/info.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres/lines.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres/links.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres/migrations/0001_core.sql +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres/migrations/0002_identity.sql +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres/migrations/0003_history_author.sql +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres/migrations/0004_title.sql +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres/migrations/0005_chunk_index.sql +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres/migrations/0006_reader_floor.sql +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres/migrations/0007_embed_retry.sql +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres/migrations/0008_service_roles.sql +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres/migrations/0009_create_namespace_right.sql +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres/migrations/0010_namespace_alias.sql +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres/migrations/0011_drop_default_namespace.sql +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres/migrations/0012_user_profile.sql +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres/migrations/0013_hash_version.sql +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres/migrations/0014_access_request_no_fk.sql +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres/migrations/0015_normalize_tags.sql +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres/migrations/0016_valid_at.sql +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres/migrations/0017_memory_link.sql +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres/migrations/0018_links_built.sql +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres/migrations/0019_memory_usage.sql +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres/migrations/0020_memory_usage_no_fk.sql +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres/migrations/0021_enrollment_key.sql +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres/periodic.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres/reembed.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres/relink.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres/search.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres/segments.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres/store.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres/tags.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres/vector/__init__.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres/vector/base.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres/vector/pgvector.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres/vector/qdrant.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres/worker.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres.egg-info/dependency_links.txt +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres.egg-info/entry_points.txt +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres.egg-info/requires.txt +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/memgres.egg-info/top_level.txt +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/pyproject.toml +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/setup.cfg +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/tests/test_blame_integration.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/tests/test_chunk_index.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/tests/test_claim_and_reembed.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/tests/test_config.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/tests/test_diffing.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/tests/test_embed_worker.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/tests/test_embeddings.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/tests/test_enrollment.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/tests/test_healthcheck.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/tests/test_identity_integration.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/tests/test_lexical_match.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/tests/test_limits.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/tests/test_links.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/tests/test_list.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/tests/test_mcp_http_transport.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/tests/test_mcp_instructions.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/tests/test_mcp_recall_schema.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/tests/test_mcp_tool_visibility.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/tests/test_mcp_tool_visibility_http.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/tests/test_migration_upgrade.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/tests/test_multi_space_search.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/tests/test_path_addressing.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/tests/test_qdrant_ca.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/tests/test_qdrant_integration.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/tests/test_replace_build.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/tests/test_require_title.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/tests/test_retention.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/tests/test_roles_bootstrap.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/tests/test_search_integration.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/tests/test_segments.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/tests/test_segments_store.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/tests/test_server_info.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/tests/test_snippets.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/tests/test_store_integration.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/tests/test_tags.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/tests/test_token_sink.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/tests/test_usage.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/tests/test_valid_at.py +0 -0
- {memgres-0.9.0 → memgres-0.11.0}/tests/test_write_ergonomics.py +0 -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
373
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
607
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|