memgres 0.9.0__tar.gz → 0.10.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {memgres-0.9.0 → memgres-0.10.0}/PKG-INFO +1 -1
- {memgres-0.9.0 → memgres-0.10.0}/memgres/_version.py +1 -1
- {memgres-0.9.0 → memgres-0.10.0}/memgres/admin.py +108 -6
- {memgres-0.9.0 → memgres-0.10.0}/memgres/identity.py +191 -13
- {memgres-0.9.0 → memgres-0.10.0}/memgres/mcp_server.py +104 -6
- memgres-0.10.0/memgres/migrations/0022_user_disabled.sql +12 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres/schema.py +1 -1
- {memgres-0.9.0 → memgres-0.10.0}/memgres/server.py +31 -1
- {memgres-0.9.0 → memgres-0.10.0}/memgres.egg-info/PKG-INFO +1 -1
- {memgres-0.9.0 → memgres-0.10.0}/memgres.egg-info/SOURCES.txt +2 -0
- memgres-0.10.0/tests/test_admin_two_way.py +402 -0
- {memgres-0.9.0 → memgres-0.10.0}/tests/test_mcp_admin_tools.py +6 -0
- {memgres-0.9.0 → memgres-0.10.0}/LICENSE +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/README.md +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres/__init__.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres/admin_cli.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres/blame.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres/bootstrap.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres/config.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres/delimiters.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres/diffing.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres/embed_worker.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres/embeddings.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres/healthcheck.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres/indexing.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres/info.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres/lines.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres/links.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres/migrations/0001_core.sql +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres/migrations/0002_identity.sql +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres/migrations/0003_history_author.sql +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres/migrations/0004_title.sql +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres/migrations/0005_chunk_index.sql +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres/migrations/0006_reader_floor.sql +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres/migrations/0007_embed_retry.sql +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres/migrations/0008_service_roles.sql +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres/migrations/0009_create_namespace_right.sql +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres/migrations/0010_namespace_alias.sql +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres/migrations/0011_drop_default_namespace.sql +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres/migrations/0012_user_profile.sql +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres/migrations/0013_hash_version.sql +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres/migrations/0014_access_request_no_fk.sql +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres/migrations/0015_normalize_tags.sql +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres/migrations/0016_valid_at.sql +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres/migrations/0017_memory_link.sql +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres/migrations/0018_links_built.sql +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres/migrations/0019_memory_usage.sql +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres/migrations/0020_memory_usage_no_fk.sql +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres/migrations/0021_enrollment_key.sql +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres/periodic.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres/reembed.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres/relink.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres/search.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres/segments.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres/store.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres/tags.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres/token_cli.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres/vector/__init__.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres/vector/base.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres/vector/pgvector.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres/vector/qdrant.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres/worker.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres.egg-info/dependency_links.txt +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres.egg-info/entry_points.txt +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres.egg-info/requires.txt +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/memgres.egg-info/top_level.txt +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/pyproject.toml +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/setup.cfg +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/tests/test_blame_integration.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/tests/test_chunk_index.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/tests/test_claim_and_reembed.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/tests/test_config.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/tests/test_diffing.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/tests/test_embed_worker.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/tests/test_embeddings.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/tests/test_enrollment.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/tests/test_healthcheck.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/tests/test_identity_integration.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/tests/test_lexical_match.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/tests/test_limits.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/tests/test_links.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/tests/test_list.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/tests/test_mcp_http_transport.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/tests/test_mcp_instructions.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/tests/test_mcp_recall_schema.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/tests/test_mcp_tool_visibility.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/tests/test_mcp_tool_visibility_http.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/tests/test_migration_upgrade.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/tests/test_multi_space_search.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/tests/test_path_addressing.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/tests/test_qdrant_ca.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/tests/test_qdrant_integration.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/tests/test_replace_build.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/tests/test_require_title.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/tests/test_retention.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/tests/test_roles_bootstrap.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/tests/test_search_integration.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/tests/test_security_integration.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/tests/test_segments.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/tests/test_segments_store.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/tests/test_server_info.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/tests/test_server_integration.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/tests/test_snippets.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/tests/test_store_integration.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/tests/test_tags.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/tests/test_token_sink.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/tests/test_usage.py +0 -0
- {memgres-0.9.0 → memgres-0.10.0}/tests/test_valid_at.py +0 -0
- {memgres-0.9.0 → memgres-0.10.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
|
|
@@ -363,19 +364,92 @@ def edit_namespace(conn, p: Principal, *, namespace_id: str,
|
|
|
363
364
|
"""
|
|
364
365
|
require_namespace_admin(conn, p, namespace_id)
|
|
365
366
|
identity.edit_namespace(conn, namespace_id, description=description,
|
|
366
|
-
instruction=instruction)
|
|
367
|
-
|
|
367
|
+
instruction=instruction, name=name)
|
|
368
|
+
out = {"namespace_id": namespace_id}
|
|
369
|
+
if name is not None:
|
|
370
|
+
out["name"] = name
|
|
371
|
+
out["warning"] = ("renamed — anyone addressing it by the old name now "
|
|
372
|
+
"gets 'no such namespace'. Ids and aliases still work")
|
|
373
|
+
return out
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def require_namespace_owner(conn, p: Principal, namespace_id: str) -> None:
|
|
377
|
+
"""Owner-or-superadmin, a tier narrower than `require_namespace_admin`.
|
|
378
|
+
|
|
379
|
+
For the acts that dispose of the namespace itself rather than work inside
|
|
380
|
+
it. An admin MEMBER was given authority over the contents; giving away
|
|
381
|
+
somebody else's namespace is not part of that, and the difference only
|
|
382
|
+
matters here — which is why this is its own check rather than a flag.
|
|
383
|
+
"""
|
|
384
|
+
require_namespace_admin(conn, p, namespace_id) # ceiling + scope + exists
|
|
385
|
+
if p.is_admin:
|
|
386
|
+
return
|
|
387
|
+
if identity.namespace_owner(conn, namespace_id) != p.user_id:
|
|
388
|
+
raise Forbidden("only the owner (or a superadmin) may do this")
|
|
368
389
|
|
|
369
390
|
|
|
370
391
|
def add_member(conn, p: Principal, *, namespace_id: str, user_id: str,
|
|
371
392
|
permission: str = "read") -> dict:
|
|
372
|
-
"""Share a namespace with another user
|
|
373
|
-
|
|
393
|
+
"""Share a namespace with another user.
|
|
394
|
+
|
|
395
|
+
Authorized per-NAMESPACE, not deployment-wide. It used to demand superadmin,
|
|
396
|
+
on the reasoning that sharing reaches across tenants — but the thing being
|
|
397
|
+
shared is the caller's OWN namespace, and requiring the deployment's root
|
|
398
|
+
for that made "let a colleague into my cabinet" an operator ticket. What it
|
|
399
|
+
still demands is an admin-ceiling credential for that namespace, so a
|
|
400
|
+
read-only or differently-scoped token cannot hand out access.
|
|
401
|
+
"""
|
|
402
|
+
require_namespace_admin(conn, p, namespace_id)
|
|
374
403
|
identity.add_member(conn, namespace_id, user_id, permission)
|
|
375
404
|
return {"namespace_id": namespace_id, "user_id": user_id,
|
|
376
405
|
"permission": permission}
|
|
377
406
|
|
|
378
407
|
|
|
408
|
+
def remove_member(conn, p: Principal, *, namespace_id: str, user_id: str) -> dict:
|
|
409
|
+
"""Un-share a namespace. `removed: false` means they were not a member.
|
|
410
|
+
|
|
411
|
+
The other half of `add_member`, and its absence was the sharpest gap in this
|
|
412
|
+
control plane: access could be granted and never taken back except by
|
|
413
|
+
revoking every token the person held, which cuts them off from everything
|
|
414
|
+
rather than from this.
|
|
415
|
+
"""
|
|
416
|
+
require_namespace_admin(conn, p, namespace_id)
|
|
417
|
+
return {"namespace_id": namespace_id, "user_id": user_id,
|
|
418
|
+
"removed": identity.remove_member(conn, namespace_id, user_id)}
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def transfer_namespace(conn, p: Principal, *, namespace_id: str,
|
|
422
|
+
new_owner_user_id: str,
|
|
423
|
+
keep_previous_owner: Optional[str] = "admin") -> dict:
|
|
424
|
+
"""Hand a namespace to another account — owner or superadmin.
|
|
425
|
+
|
|
426
|
+
The outgoing owner stays behind as an `admin` member unless
|
|
427
|
+
`keep_previous_owner` is null. Defaulting to keeping them is the safer
|
|
428
|
+
footing: the alternative is a single call that removes the caller from a
|
|
429
|
+
namespace whose contents they may be the only one who knows.
|
|
430
|
+
"""
|
|
431
|
+
require_namespace_owner(conn, p, namespace_id)
|
|
432
|
+
return identity.transfer_namespace(
|
|
433
|
+
conn, namespace_id, new_owner_user_id,
|
|
434
|
+
keep_previous_owner=keep_previous_owner)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def set_disabled(conn, p: Principal, *, user_id: str, disabled: bool) -> dict:
|
|
438
|
+
"""Switch an account off, or back on. Every token it holds stops at once.
|
|
439
|
+
|
|
440
|
+
Offboarding as ONE act. Doing it by revoking tokens one at a time is a loop
|
|
441
|
+
that has to be complete to be correct, and nothing stops a new token being
|
|
442
|
+
issued afterwards. Reversible and destructive of nothing — authorship,
|
|
443
|
+
namespaces and memberships all survive, which is what makes it usable for
|
|
444
|
+
"gone for now" as well as "gone".
|
|
445
|
+
"""
|
|
446
|
+
require_manage_users(p)
|
|
447
|
+
_require_target_is_plain_user(conn, p, user_id,
|
|
448
|
+
"disabling an account" if disabled
|
|
449
|
+
else "re-enabling an account")
|
|
450
|
+
return identity.set_disabled(conn, user_id, disabled)
|
|
451
|
+
|
|
452
|
+
|
|
379
453
|
def list_namespaces(conn, p: Principal, *, owner_user_id: Optional[str] = None,
|
|
380
454
|
limit: Optional[int] = None, offset: int = 0) -> List[dict]:
|
|
381
455
|
"""Deployment-wide namespace inventory.
|
|
@@ -407,6 +481,27 @@ def list_spaces(conn, p: Principal) -> List[dict]:
|
|
|
407
481
|
|
|
408
482
|
# ─── tokens ──────────────────────────────────────────────────────────────────
|
|
409
483
|
|
|
484
|
+
def _unreachable_warning(conn, user_id: str,
|
|
485
|
+
namespace_id: Optional[str]) -> Optional[str]:
|
|
486
|
+
"""Warn when a credential is about to be scoped to a namespace its owner
|
|
487
|
+
cannot reach.
|
|
488
|
+
|
|
489
|
+
Scoping is not granting: reach comes from ownership or membership, and a
|
|
490
|
+
token pinned to a namespace the account is not in is perfectly valid and
|
|
491
|
+
reaches nothing. The person enrols, everything answers "no namespace", and
|
|
492
|
+
it reads as a broken server rather than an unfinished provisioning. A
|
|
493
|
+
warning rather than a refusal, because issuing the credential first and
|
|
494
|
+
adding the membership after is a legitimate order.
|
|
495
|
+
"""
|
|
496
|
+
if not namespace_id:
|
|
497
|
+
return None
|
|
498
|
+
if identity.reaches(conn, user_id, namespace_id) is not None:
|
|
499
|
+
return None
|
|
500
|
+
return ("that user cannot reach this namespace yet, so the token will be "
|
|
501
|
+
"valid and see NOTHING — add them with memory_admin_add_member "
|
|
502
|
+
"(or hand the namespace over) before they try to use it")
|
|
503
|
+
|
|
504
|
+
|
|
410
505
|
def issue_token(conn, p: Principal, *, user_id: str,
|
|
411
506
|
namespace_id: Optional[str] = None, permission: str = "write",
|
|
412
507
|
label: str = "", expires_days: Optional[int] = None,
|
|
@@ -426,10 +521,14 @@ def issue_token(conn, p: Principal, *, user_id: str,
|
|
|
426
521
|
expires_at = None
|
|
427
522
|
if expires_days:
|
|
428
523
|
expires_at = dt.datetime.now(dt.timezone.utc) + dt.timedelta(days=expires_days)
|
|
524
|
+
warning = _unreachable_warning(conn, user_id, namespace_id)
|
|
429
525
|
secret, tid = identity.issue_token(conn, user_id, namespace_id=namespace_id,
|
|
430
526
|
permission=permission, label=label,
|
|
431
527
|
expires_at=expires_at)
|
|
432
|
-
|
|
528
|
+
out = deliver_secret(secret, tid, sink_dir)
|
|
529
|
+
if warning:
|
|
530
|
+
out["warning"] = warning
|
|
531
|
+
return out
|
|
433
532
|
|
|
434
533
|
|
|
435
534
|
def deliver_secret(secret: str, token_id: str, sink_dir: str) -> dict:
|
|
@@ -472,6 +571,9 @@ def create_enrollment(conn, p: Principal, *, user_id: str,
|
|
|
472
571
|
"for a meeting link — it is single-use and short-lived. They "
|
|
473
572
|
"generate their own token, put it in their client's config, "
|
|
474
573
|
"and call memory_enroll with this key.")
|
|
574
|
+
warning = _unreachable_warning(conn, user_id, namespace_id)
|
|
575
|
+
if warning:
|
|
576
|
+
out["warning"] = warning
|
|
475
577
|
return out
|
|
476
578
|
|
|
477
579
|
|
|
@@ -24,6 +24,7 @@ resolve a space before every operation.
|
|
|
24
24
|
|
|
25
25
|
from __future__ import annotations
|
|
26
26
|
|
|
27
|
+
import datetime as dt
|
|
27
28
|
import hashlib
|
|
28
29
|
import hmac
|
|
29
30
|
import os
|
|
@@ -189,16 +190,22 @@ def resolve(conn, cfg, secret: Optional[str], *, touch: bool = True) -> Principa
|
|
|
189
190
|
"SELECT t.id, t.user_id, t.namespace_id, t.permission, "
|
|
190
191
|
" (t.revoked_at IS NOT NULL) AS revoked, "
|
|
191
192
|
" (t.expires_at IS NOT NULL AND t.expires_at <= now()) AS expired, "
|
|
192
|
-
" u.role "
|
|
193
|
+
" u.role, (u.disabled_at IS NOT NULL) AS disabled "
|
|
193
194
|
"FROM token t JOIN app_user u ON u.id = t.user_id "
|
|
194
195
|
"WHERE t.token_hash=%s", (h,))
|
|
195
196
|
row = cur.fetchone()
|
|
196
197
|
if row is not None:
|
|
197
|
-
tid, uid, nsid, perm, revoked, expired, role = row
|
|
198
|
+
tid, uid, nsid, perm, revoked, expired, role, disabled = row
|
|
198
199
|
if revoked:
|
|
199
200
|
raise AuthError("token revoked")
|
|
200
201
|
if expired:
|
|
201
202
|
raise AuthError("token expired")
|
|
203
|
+
if disabled:
|
|
204
|
+
# Checked HERE, on the credential, rather than at each door:
|
|
205
|
+
# an account that is off has to be off for every token it
|
|
206
|
+
# holds and every one it could still be issued, and the only
|
|
207
|
+
# place all of those meet is authentication.
|
|
208
|
+
raise AuthError("this account is disabled")
|
|
202
209
|
if touch:
|
|
203
210
|
cur.execute("UPDATE token SET last_used_at=now() WHERE id=%s",
|
|
204
211
|
(tid,))
|
|
@@ -742,7 +749,8 @@ def list_users(conn, *, role: Optional[str] = None, limit: Optional[int] = None,
|
|
|
742
749
|
if role is not None and role not in SERVICE_ROLES:
|
|
743
750
|
raise ValueError(f"bad role: {role}")
|
|
744
751
|
sql = ("SELECT id, name, description, role, can_create_namespace, "
|
|
745
|
-
"created_at, email, full_name, department, position
|
|
752
|
+
"created_at, email, full_name, department, position, "
|
|
753
|
+
"disabled_at FROM app_user")
|
|
746
754
|
args: list = []
|
|
747
755
|
if role is not None:
|
|
748
756
|
sql += " WHERE role=%s" # backed by app_user_role_idx
|
|
@@ -755,13 +763,17 @@ def list_users(conn, *, role: Optional[str] = None, limit: Optional[int] = None,
|
|
|
755
763
|
sql += " OFFSET %s"
|
|
756
764
|
args.append(offset)
|
|
757
765
|
cols = ["id", "name", "description", "role", "can_create_namespace",
|
|
758
|
-
"created_at", *PROFILE_FIELDS]
|
|
766
|
+
"created_at", *PROFILE_FIELDS, "disabled_at"]
|
|
759
767
|
with conn.cursor() as cur:
|
|
760
768
|
cur.execute(sql, args)
|
|
761
769
|
out = []
|
|
762
770
|
for r in cur.fetchall():
|
|
763
771
|
d = dict(zip(cols, r))
|
|
764
772
|
d["id"] = str(d["id"])
|
|
773
|
+
# Both, deliberately: `disabled` is what a caller branches on, and
|
|
774
|
+
# `disabled_at` answers "since when", which is the question that
|
|
775
|
+
# follows every time the first one is true.
|
|
776
|
+
d["disabled"] = d["disabled_at"] is not None
|
|
765
777
|
out.append(d)
|
|
766
778
|
return out
|
|
767
779
|
|
|
@@ -822,12 +834,81 @@ def revoke_superadmin(conn, user_id: str, *, demote_to: str = "user") -> None:
|
|
|
822
834
|
cur.execute("UPDATE app_user SET role=%s WHERE id=%s", (demote_to, user_id))
|
|
823
835
|
|
|
824
836
|
|
|
837
|
+
def set_disabled(conn, user_id: str, disabled: bool) -> dict:
|
|
838
|
+
"""Turn an account off (or back on). Reversible, and destroys nothing.
|
|
839
|
+
|
|
840
|
+
Anti-lockout, in the same spirit as `revoke_superadmin`: the last ACTIVE
|
|
841
|
+
superadmin cannot be switched off, because nobody would be left who could
|
|
842
|
+
switch it back on. Recovery from such a lockout would mean editing the
|
|
843
|
+
database by hand.
|
|
844
|
+
"""
|
|
845
|
+
with conn.cursor() as cur:
|
|
846
|
+
cur.execute("SELECT role, disabled_at IS NOT NULL FROM app_user WHERE id=%s",
|
|
847
|
+
(_as_uuid(user_id),))
|
|
848
|
+
row = cur.fetchone()
|
|
849
|
+
if row is None:
|
|
850
|
+
raise SpaceNotFound(f"no such user {user_id}")
|
|
851
|
+
role, already = row
|
|
852
|
+
if bool(already) == bool(disabled):
|
|
853
|
+
return {"user_id": str(user_id), "disabled": bool(disabled),
|
|
854
|
+
"unchanged": True}
|
|
855
|
+
if disabled and role == "superadmin":
|
|
856
|
+
cur.execute("SELECT count(*) FROM app_user WHERE role='superadmin' "
|
|
857
|
+
"AND disabled_at IS NULL")
|
|
858
|
+
if int(cur.fetchone()[0]) <= 1:
|
|
859
|
+
raise AuthError("cannot disable the last active superadmin")
|
|
860
|
+
cur.execute("UPDATE app_user SET disabled_at = %s WHERE id=%s",
|
|
861
|
+
(None if not disabled else dt.datetime.now(dt.timezone.utc),
|
|
862
|
+
_as_uuid(user_id)))
|
|
863
|
+
return {"user_id": str(user_id), "disabled": bool(disabled)}
|
|
864
|
+
|
|
865
|
+
|
|
825
866
|
# How many namespaces one account may own. Not a business rule — a bound, so a
|
|
826
867
|
# self-service deployment cannot be turned into an INSERT loop by anyone holding
|
|
827
868
|
# a well-formed token.
|
|
828
869
|
MAX_NAMESPACES_PER_USER = 50
|
|
829
870
|
|
|
830
871
|
|
|
872
|
+
def _require_name_free(cur, owner_user_id: str, name: str, *,
|
|
873
|
+
except_namespace_id: Optional[str] = None,
|
|
874
|
+
upsert: bool = False) -> None:
|
|
875
|
+
"""Refuse a name that would be unaddressable for `owner_user_id`.
|
|
876
|
+
|
|
877
|
+
Two ways that happens, and they are the same failure seen from two sides: an
|
|
878
|
+
ALIAS of theirs already means something else (so the namespace could not be
|
|
879
|
+
reached by its own name, and their existing calls would silently keep
|
|
880
|
+
resolving elsewhere), or they already OWN a namespace by that name.
|
|
881
|
+
|
|
882
|
+
Extracted from `create_namespace` because renaming and transferring hit the
|
|
883
|
+
identical wall — and a check that lives at only one of three doors is the
|
|
884
|
+
kind of duplication this codebase keeps paying for.
|
|
885
|
+
"""
|
|
886
|
+
cur.execute("SELECT namespace_id FROM namespace_alias "
|
|
887
|
+
"WHERE user_id=%s AND alias=%s", (owner_user_id, name))
|
|
888
|
+
if cur.fetchone() is not None:
|
|
889
|
+
raise SpaceAmbiguous(
|
|
890
|
+
f"'{name}' is already one of that user's aliases — the namespace "
|
|
891
|
+
"could not be addressed by its own name, and their existing calls "
|
|
892
|
+
"using it would keep resolving elsewhere. Drop the alias or choose "
|
|
893
|
+
"another name")
|
|
894
|
+
if upsert:
|
|
895
|
+
# `create_namespace` is an idempotent upsert BY DESIGN: calling it again
|
|
896
|
+
# with the same name returns the existing id rather than failing, which
|
|
897
|
+
# is what makes provisioning scripts re-runnable. Only renaming and
|
|
898
|
+
# transferring must refuse the collision, because for them there is no
|
|
899
|
+
# "the one you meant" to return.
|
|
900
|
+
return
|
|
901
|
+
sql = "SELECT id FROM namespace WHERE owner_user_id=%s AND name=%s"
|
|
902
|
+
args = [owner_user_id, name]
|
|
903
|
+
if except_namespace_id is not None:
|
|
904
|
+
sql += " AND id <> %s"
|
|
905
|
+
args.append(_as_uuid(except_namespace_id))
|
|
906
|
+
cur.execute(sql, args)
|
|
907
|
+
if cur.fetchone() is not None:
|
|
908
|
+
raise SpaceAmbiguous(
|
|
909
|
+
f"that account already owns a namespace called '{name}'")
|
|
910
|
+
|
|
911
|
+
|
|
831
912
|
def create_namespace(conn, owner_user_id: str, name: str, *,
|
|
832
913
|
description: str = "", instruction: str = "") -> str:
|
|
833
914
|
"""Create (or return the existing) namespace ``name`` owned by the user.
|
|
@@ -841,14 +922,7 @@ def create_namespace(conn, owner_user_id: str, name: str, *,
|
|
|
841
922
|
codebase keeps paying for.
|
|
842
923
|
"""
|
|
843
924
|
with conn.cursor() as cur:
|
|
844
|
-
cur
|
|
845
|
-
"WHERE user_id=%s AND alias=%s", (owner_user_id, name))
|
|
846
|
-
if cur.fetchone() is not None:
|
|
847
|
-
raise SpaceAmbiguous(
|
|
848
|
-
f"'{name}' is already one of that user's aliases — the new "
|
|
849
|
-
"namespace could not be addressed by its own name, and their "
|
|
850
|
-
"existing calls using it would keep resolving elsewhere. Drop "
|
|
851
|
-
"the alias or choose another name")
|
|
925
|
+
_require_name_free(cur, owner_user_id, name, upsert=True)
|
|
852
926
|
cur.execute("SELECT count(*) FROM namespace WHERE owner_user_id=%s",
|
|
853
927
|
(owner_user_id,))
|
|
854
928
|
if cur.fetchone()[0] >= MAX_NAMESPACES_PER_USER:
|
|
@@ -966,8 +1040,25 @@ def add_member(conn, namespace_id: str, user_id: str, permission: str = "read")
|
|
|
966
1040
|
|
|
967
1041
|
|
|
968
1042
|
def edit_namespace(conn, namespace_id: str, *, description: Optional[str] = None,
|
|
969
|
-
instruction: Optional[str] = None
|
|
1043
|
+
instruction: Optional[str] = None,
|
|
1044
|
+
name: Optional[str] = None) -> None:
|
|
1045
|
+
"""Amend a namespace. `name` renames it — checked against the OWNER's other
|
|
1046
|
+
namespaces and aliases, since that is who the name has to stay addressable
|
|
1047
|
+
for. Anyone addressing it by the old name gets "no such namespace" from then
|
|
1048
|
+
on; ids and aliases are unaffected."""
|
|
970
1049
|
sets, params = [], []
|
|
1050
|
+
if name is not None:
|
|
1051
|
+
if not name.strip():
|
|
1052
|
+
raise ValueError("a namespace name cannot be blank")
|
|
1053
|
+
with conn.cursor() as cur:
|
|
1054
|
+
cur.execute("SELECT owner_user_id FROM namespace WHERE id=%s",
|
|
1055
|
+
(_as_uuid(namespace_id),))
|
|
1056
|
+
row = cur.fetchone()
|
|
1057
|
+
if row is None:
|
|
1058
|
+
raise SpaceNotFound(f"no such namespace {namespace_id}")
|
|
1059
|
+
_require_name_free(cur, str(row[0]), name,
|
|
1060
|
+
except_namespace_id=namespace_id)
|
|
1061
|
+
sets.append("name=%s"); params.append(name)
|
|
971
1062
|
if description is not None:
|
|
972
1063
|
sets.append("description=%s"); params.append(description)
|
|
973
1064
|
if instruction is not None:
|
|
@@ -979,6 +1070,93 @@ def edit_namespace(conn, namespace_id: str, *, description: Optional[str] = None
|
|
|
979
1070
|
cur.execute(f"UPDATE namespace SET {', '.join(sets)} WHERE id=%s", params)
|
|
980
1071
|
|
|
981
1072
|
|
|
1073
|
+
def remove_member(conn, namespace_id: str, user_id: str) -> bool:
|
|
1074
|
+
"""Take a shared namespace away again. False if they were not a member.
|
|
1075
|
+
|
|
1076
|
+
Takes effect on the next call: reach is computed per request from the member
|
|
1077
|
+
table, never cached. A token that was SCOPED to this namespace is not
|
|
1078
|
+
revoked — it simply stops reaching anything, which is the same state as a
|
|
1079
|
+
token scoped to a namespace that was never shared. Revoking it as well is
|
|
1080
|
+
the caller's decision, not a side effect of un-sharing.
|
|
1081
|
+
"""
|
|
1082
|
+
with conn.cursor() as cur:
|
|
1083
|
+
cur.execute("SELECT owner_user_id FROM namespace WHERE id=%s",
|
|
1084
|
+
(_as_uuid(namespace_id),))
|
|
1085
|
+
row = cur.fetchone()
|
|
1086
|
+
if row is None:
|
|
1087
|
+
raise SpaceNotFound(f"no such namespace {namespace_id}")
|
|
1088
|
+
if str(row[0]) == str(user_id):
|
|
1089
|
+
# Ownership is not a membership row, so deleting one would report
|
|
1090
|
+
# success and change nothing — the owner would keep full access and
|
|
1091
|
+
# whoever asked would believe they had removed it.
|
|
1092
|
+
raise AuthError(
|
|
1093
|
+
"that user OWNS this namespace — ownership is not a membership "
|
|
1094
|
+
"and cannot be removed. Transfer the namespace instead")
|
|
1095
|
+
cur.execute("DELETE FROM namespace_member WHERE namespace_id=%s "
|
|
1096
|
+
"AND user_id=%s RETURNING user_id",
|
|
1097
|
+
(_as_uuid(namespace_id), _as_uuid(user_id)))
|
|
1098
|
+
return cur.fetchone() is not None
|
|
1099
|
+
|
|
1100
|
+
|
|
1101
|
+
def namespace_owner(conn, namespace_id: str) -> Optional[str]:
|
|
1102
|
+
with conn.cursor() as cur:
|
|
1103
|
+
cur.execute("SELECT owner_user_id FROM namespace WHERE id=%s",
|
|
1104
|
+
(_as_uuid(namespace_id),))
|
|
1105
|
+
row = cur.fetchone()
|
|
1106
|
+
return str(row[0]) if row else None
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
def transfer_namespace(conn, namespace_id: str, new_owner_user_id: str, *,
|
|
1110
|
+
keep_previous_owner: Optional[str] = "admin") -> dict:
|
|
1111
|
+
"""Hand a namespace to somebody else.
|
|
1112
|
+
|
|
1113
|
+
`keep_previous_owner` leaves the outgoing owner behind as a member at that
|
|
1114
|
+
permission (default `admin`), and defaults to doing so for a reason: without
|
|
1115
|
+
it, one call removes the caller from a namespace they may be the only person
|
|
1116
|
+
who knows the contents of, and nothing in the reply would have warned them.
|
|
1117
|
+
Pass None for a clean hand-off.
|
|
1118
|
+
|
|
1119
|
+
The new owner's stale membership row, if any, is dropped — ownership already
|
|
1120
|
+
grants more than any membership can, and leaving a `read` row behind means a
|
|
1121
|
+
later transfer silently demotes them.
|
|
1122
|
+
"""
|
|
1123
|
+
if keep_previous_owner is not None and keep_previous_owner not in _RANK:
|
|
1124
|
+
raise ValueError(f"bad permission: {keep_previous_owner}")
|
|
1125
|
+
with conn.cursor() as cur:
|
|
1126
|
+
cur.execute("SELECT owner_user_id, name FROM namespace WHERE id=%s "
|
|
1127
|
+
"FOR UPDATE", (_as_uuid(namespace_id),))
|
|
1128
|
+
row = cur.fetchone()
|
|
1129
|
+
if row is None:
|
|
1130
|
+
raise SpaceNotFound(f"no such namespace {namespace_id}")
|
|
1131
|
+
old_owner, name = str(row[0]), row[1]
|
|
1132
|
+
if old_owner == str(new_owner_user_id):
|
|
1133
|
+
return {"namespace_id": str(namespace_id), "owner_user_id": old_owner,
|
|
1134
|
+
"unchanged": True}
|
|
1135
|
+
cur.execute("SELECT 1 FROM app_user WHERE id=%s",
|
|
1136
|
+
(_as_uuid(new_owner_user_id),))
|
|
1137
|
+
if cur.fetchone() is None:
|
|
1138
|
+
raise SpaceNotFound(f"no such user {new_owner_user_id}")
|
|
1139
|
+
# The receiving account has its own names and aliases, and (owner, name)
|
|
1140
|
+
# is unique: without this the transfer dies on a constraint violation
|
|
1141
|
+
# rather than on an explanation.
|
|
1142
|
+
_require_name_free(cur, str(new_owner_user_id), name,
|
|
1143
|
+
except_namespace_id=namespace_id)
|
|
1144
|
+
cur.execute("DELETE FROM namespace_member WHERE namespace_id=%s AND "
|
|
1145
|
+
"user_id=%s", (_as_uuid(namespace_id), _as_uuid(new_owner_user_id)))
|
|
1146
|
+
cur.execute("UPDATE namespace SET owner_user_id=%s WHERE id=%s",
|
|
1147
|
+
(_as_uuid(new_owner_user_id), _as_uuid(namespace_id)))
|
|
1148
|
+
if keep_previous_owner is not None:
|
|
1149
|
+
cur.execute(
|
|
1150
|
+
"INSERT INTO namespace_member (namespace_id, user_id, permission) "
|
|
1151
|
+
"VALUES (%s, %s, %s) ON CONFLICT (namespace_id, user_id) "
|
|
1152
|
+
"DO UPDATE SET permission=EXCLUDED.permission",
|
|
1153
|
+
(_as_uuid(namespace_id), _as_uuid(old_owner), keep_previous_owner))
|
|
1154
|
+
return {"namespace_id": str(namespace_id),
|
|
1155
|
+
"owner_user_id": str(new_owner_user_id),
|
|
1156
|
+
"previous_owner_user_id": old_owner,
|
|
1157
|
+
"previous_owner_permission": keep_previous_owner}
|
|
1158
|
+
|
|
1159
|
+
|
|
982
1160
|
def list_spaces(conn, user_id: str) -> List[dict]:
|
|
983
1161
|
"""Every namespace the user can reach: owned + shared, with flags."""
|
|
984
1162
|
aliases = list_aliases(conn, user_id)
|
|
@@ -160,7 +160,7 @@ TOOL_VISIBILITY = {
|
|
|
160
160
|
"memory_admin_list_tokens": ("identity", "can_manage_users"),
|
|
161
161
|
"memory_admin_revoke_token": ("identity", "can_manage_users"),
|
|
162
162
|
"memory_admin_set_role": ("identity", "can_administer_deployment"),
|
|
163
|
-
"
|
|
163
|
+
"memory_admin_set_disabled": ("identity", "can_manage_users"),
|
|
164
164
|
"memory_admin_count_orphans": ("identity", "can_administer_deployment"),
|
|
165
165
|
"memory_admin_adopt_orphans": ("identity", "can_administer_deployment"),
|
|
166
166
|
# per-namespace admin: any namespace OWNER qualifies, so no deployment-wide
|
|
@@ -168,6 +168,13 @@ TOOL_VISIBILITY = {
|
|
|
168
168
|
# ceiling, since the effective permission is membership ∧ ceiling
|
|
169
169
|
"memory_admin_edit_namespace": ("identity", "has_admin_ceiling"),
|
|
170
170
|
"memory_admin_list_members": ("identity", "has_admin_ceiling"),
|
|
171
|
+
"memory_admin_add_member": ("identity", "has_admin_ceiling"),
|
|
172
|
+
"memory_admin_remove_member": ("identity", "has_admin_ceiling"),
|
|
173
|
+
"memory_admin_transfer_namespace": ("identity", "has_admin_ceiling"),
|
|
174
|
+
"memory_admin_list_requests": ("identity", "has_admin_ceiling"),
|
|
175
|
+
"memory_admin_decide_access": ("identity", "has_admin_ceiling"),
|
|
176
|
+
# asking is not administering — anyone with an account may
|
|
177
|
+
"memory_request_access": ("identity",),
|
|
171
178
|
}
|
|
172
179
|
|
|
173
180
|
|
|
@@ -782,6 +789,21 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
782
789
|
out.update({"permission": permission, "namespace_id": nsid})
|
|
783
790
|
return out
|
|
784
791
|
|
|
792
|
+
@mcp.tool()
|
|
793
|
+
def memory_request_access(space_id: str, permission: str = "read",
|
|
794
|
+
ctx: Context = None) -> dict:
|
|
795
|
+
"""Ask the owner of a namespace to let you in.
|
|
796
|
+
|
|
797
|
+
The push side of sharing needs somebody to know your user id; this is
|
|
798
|
+
the pull side, for when you know a namespace exists and want in. You
|
|
799
|
+
need its id — the answer deliberately cannot tell a namespace you cannot
|
|
800
|
+
reach from one that does not exist, so ids come from a person, not from
|
|
801
|
+
browsing. `already_reachable` means you are in it already."""
|
|
802
|
+
with pool.connection() as conn, conn.transaction():
|
|
803
|
+
return admin.request_access(conn, _principal(conn, _token(ctx)),
|
|
804
|
+
namespace_id=space_id,
|
|
805
|
+
permission=permission)
|
|
806
|
+
|
|
785
807
|
@mcp.tool()
|
|
786
808
|
def memory_list_tokens(ctx: Context = None) -> List[dict]:
|
|
787
809
|
"""List your tokens (metadata only — never the secret)."""
|
|
@@ -922,15 +944,20 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
922
944
|
def memory_admin_edit_namespace(space_id: str,
|
|
923
945
|
description: Optional[str] = None,
|
|
924
946
|
instruction: Optional[str] = None,
|
|
947
|
+
name: Optional[str] = None,
|
|
925
948
|
ctx: Context = None) -> dict:
|
|
926
|
-
"""Amend a namespace's description
|
|
927
|
-
it again will not: that call ignores conflicts, so this is
|
|
928
|
-
way to correct the text agents route by.
|
|
949
|
+
"""Amend a namespace's description, routing instruction, or NAME.
|
|
950
|
+
Creating it again will not: that call ignores conflicts, so this is
|
|
951
|
+
the only way to correct the text agents route by.
|
|
952
|
+
|
|
953
|
+
Renaming is addressed by id and checked against the owner's other
|
|
954
|
+
namespaces and aliases. Callers using the old name get "no such
|
|
955
|
+
namespace" from then on; ids and aliases keep working."""
|
|
929
956
|
with pool.connection() as conn, conn.transaction():
|
|
930
957
|
return admin.edit_namespace(
|
|
931
958
|
conn, _principal(conn, _token(ctx)),
|
|
932
959
|
namespace_id=space_id, description=description,
|
|
933
|
-
instruction=instruction)
|
|
960
|
+
instruction=instruction, name=name)
|
|
934
961
|
|
|
935
962
|
@mcp.tool()
|
|
936
963
|
def memory_admin_count_orphans(ctx: Context = None) -> dict:
|
|
@@ -959,12 +986,83 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
959
986
|
permission: str = "read",
|
|
960
987
|
ctx: Context = None) -> dict:
|
|
961
988
|
"""Share a namespace with another user at read | write | admin.
|
|
962
|
-
|
|
989
|
+
Yours to give: the owner of a namespace (or a superadmin) may share
|
|
990
|
+
it, with an admin-ceiling credential for it. Re-adding an existing
|
|
991
|
+
member changes their permission."""
|
|
963
992
|
with pool.connection() as conn, conn.transaction():
|
|
964
993
|
return admin.add_member(conn, _principal(conn, _token(ctx)),
|
|
965
994
|
namespace_id=space_id, user_id=user_id,
|
|
966
995
|
permission=permission)
|
|
967
996
|
|
|
997
|
+
@mcp.tool()
|
|
998
|
+
def memory_admin_remove_member(space_id: str, user_id: str,
|
|
999
|
+
ctx: Context = None) -> dict:
|
|
1000
|
+
"""Take a shared namespace away again. `removed: false` means they
|
|
1001
|
+
were not a member.
|
|
1002
|
+
|
|
1003
|
+
Effective immediately — reach is recomputed on every call. A token
|
|
1004
|
+
they hold that was SCOPED to this namespace keeps existing and stops
|
|
1005
|
+
reaching anything; revoke it separately if that is what you meant.
|
|
1006
|
+
The OWNER cannot be removed this way: ownership is not a membership,
|
|
1007
|
+
so hand the namespace over instead."""
|
|
1008
|
+
with pool.connection() as conn, conn.transaction():
|
|
1009
|
+
return admin.remove_member(conn, _principal(conn, _token(ctx)),
|
|
1010
|
+
namespace_id=space_id, user_id=user_id)
|
|
1011
|
+
|
|
1012
|
+
@mcp.tool()
|
|
1013
|
+
def memory_admin_transfer_namespace(
|
|
1014
|
+
space_id: str, new_owner_user_id: str,
|
|
1015
|
+
keep_previous_owner: Optional[str] = "admin",
|
|
1016
|
+
ctx: Context = None) -> dict:
|
|
1017
|
+
"""Hand a namespace to another account — owner or superadmin only.
|
|
1018
|
+
|
|
1019
|
+
The outgoing owner stays behind as a member at
|
|
1020
|
+
`keep_previous_owner` (default `admin`); pass null for a clean
|
|
1021
|
+
hand-off, knowing that this removes them from a namespace whose
|
|
1022
|
+
contents they may be the only one who knows. Refused if the new
|
|
1023
|
+
owner already owns — or aliases — a namespace by the same name,
|
|
1024
|
+
since the name has to stay addressable for whoever holds it."""
|
|
1025
|
+
with pool.connection() as conn, conn.transaction():
|
|
1026
|
+
return admin.transfer_namespace(
|
|
1027
|
+
conn, _principal(conn, _token(ctx)), namespace_id=space_id,
|
|
1028
|
+
new_owner_user_id=new_owner_user_id,
|
|
1029
|
+
keep_previous_owner=keep_previous_owner)
|
|
1030
|
+
|
|
1031
|
+
@mcp.tool()
|
|
1032
|
+
def memory_admin_list_requests(space_id: str,
|
|
1033
|
+
ctx: Context = None) -> List[dict]:
|
|
1034
|
+
"""Pending requests to join a namespace you administer — who asked,
|
|
1035
|
+
for what permission, when."""
|
|
1036
|
+
with pool.connection() as conn:
|
|
1037
|
+
out = admin.list_requests(conn, _principal(conn, _token(ctx)),
|
|
1038
|
+
namespace_id=space_id)
|
|
1039
|
+
return _iso(out, "created_at")
|
|
1040
|
+
|
|
1041
|
+
@mcp.tool()
|
|
1042
|
+
def memory_admin_decide_access(request_id: str, approve: bool = True,
|
|
1043
|
+
ctx: Context = None) -> dict:
|
|
1044
|
+
"""Approve or deny a request to join. Approving adds them as a
|
|
1045
|
+
member at the permission they asked for — the same act as
|
|
1046
|
+
`memory_admin_add_member`, reached from their side."""
|
|
1047
|
+
with pool.connection() as conn, conn.transaction():
|
|
1048
|
+
admin.decide_access(conn, _principal(conn, _token(ctx)),
|
|
1049
|
+
request_id=request_id, approve=approve)
|
|
1050
|
+
return {"request_id": request_id, "approved": approve}
|
|
1051
|
+
|
|
1052
|
+
@mcp.tool()
|
|
1053
|
+
def memory_admin_set_disabled(user_id: str, disabled: bool = True,
|
|
1054
|
+
ctx: Context = None) -> dict:
|
|
1055
|
+
"""Switch an account off, or back on.
|
|
1056
|
+
|
|
1057
|
+
Offboarding in one act: every token it holds stops authenticating at
|
|
1058
|
+
once, and so does any token issued to it later. Nothing is
|
|
1059
|
+
destroyed — authorship, namespaces and memberships all survive, and
|
|
1060
|
+
re-enabling restores the account as it was. The last ACTIVE
|
|
1061
|
+
superadmin cannot be switched off."""
|
|
1062
|
+
with pool.connection() as conn, conn.transaction():
|
|
1063
|
+
return admin.set_disabled(conn, _principal(conn, _token(ctx)),
|
|
1064
|
+
user_id=user_id, disabled=disabled)
|
|
1065
|
+
|
|
968
1066
|
@mcp.tool()
|
|
969
1067
|
def memory_admin_list_members(space_id: str,
|
|
970
1068
|
ctx: Context = None) -> List[dict]:
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
-- Turning an account off without destroying it (schema v23).
|
|
2
|
+
--
|
|
3
|
+
-- Offboarding had no operation. The only way to stop someone was to revoke
|
|
4
|
+
-- their tokens one at a time — a loop over a list that has to be complete to be
|
|
5
|
+
-- correct, with nothing preventing a fresh token being issued a minute later.
|
|
6
|
+
-- A person leaving is not a person being deleted: their authorship stays, their
|
|
7
|
+
-- namespaces stay, and coming back should not mean being re-provisioned.
|
|
8
|
+
--
|
|
9
|
+
-- A timestamp rather than a boolean: "when did this account stop working" is
|
|
10
|
+
-- the question actually asked afterwards, and a bool cannot answer it. NULL =
|
|
11
|
+
-- active, which makes every existing row correct without a backfill.
|
|
12
|
+
ALTER TABLE app_user ADD COLUMN IF NOT EXISTS disabled_at timestamptz;
|
|
@@ -18,7 +18,7 @@ from pathlib import Path
|
|
|
18
18
|
from .config import Config
|
|
19
19
|
|
|
20
20
|
# The version this build migrates the database TO (the latest migration it carries).
|
|
21
|
-
SCHEMA_VERSION =
|
|
21
|
+
SCHEMA_VERSION = 23
|
|
22
22
|
|
|
23
23
|
# The compatibility FLOOR: the schema version of the most recent backward-
|
|
24
24
|
# INCOMPATIBLE migration — one that changed the shape/semantics old code relied on
|