memgres 0.4.0__tar.gz → 0.5.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.4.0 → memgres-0.5.0}/PKG-INFO +1 -1
- {memgres-0.4.0 → memgres-0.5.0}/memgres/_version.py +1 -1
- memgres-0.5.0/memgres/admin_cli.py +109 -0
- memgres-0.5.0/memgres/bootstrap.py +126 -0
- {memgres-0.4.0 → memgres-0.5.0}/memgres/config.py +18 -1
- {memgres-0.4.0 → memgres-0.5.0}/memgres/identity.py +123 -30
- {memgres-0.4.0 → memgres-0.5.0}/memgres/mcp_server.py +4 -4
- memgres-0.5.0/memgres/migrations/0008_service_roles.sql +27 -0
- {memgres-0.4.0 → memgres-0.5.0}/memgres/schema.py +2 -1
- {memgres-0.4.0 → memgres-0.5.0}/memgres/server.py +78 -22
- {memgres-0.4.0 → memgres-0.5.0}/memgres/store.py +20 -0
- {memgres-0.4.0 → memgres-0.5.0}/memgres.egg-info/PKG-INFO +1 -1
- {memgres-0.4.0 → memgres-0.5.0}/memgres.egg-info/SOURCES.txt +5 -0
- {memgres-0.4.0 → memgres-0.5.0}/memgres.egg-info/entry_points.txt +1 -0
- {memgres-0.4.0 → memgres-0.5.0}/pyproject.toml +1 -0
- memgres-0.5.0/tests/test_replace_build.py +40 -0
- memgres-0.5.0/tests/test_roles_bootstrap.py +236 -0
- {memgres-0.4.0 → memgres-0.5.0}/tests/test_server_integration.py +19 -0
- {memgres-0.4.0 → memgres-0.5.0}/LICENSE +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/README.md +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/memgres/__init__.py +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/memgres/blame.py +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/memgres/diffing.py +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/memgres/embed_worker.py +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/memgres/embeddings.py +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/memgres/indexing.py +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/memgres/info.py +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/memgres/migrations/0001_core.sql +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/memgres/migrations/0002_identity.sql +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/memgres/migrations/0003_history_author.sql +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/memgres/migrations/0004_title.sql +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/memgres/migrations/0005_chunk_index.sql +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/memgres/migrations/0006_reader_floor.sql +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/memgres/migrations/0007_embed_retry.sql +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/memgres/reembed.py +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/memgres/search.py +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/memgres/segments.py +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/memgres/vector/__init__.py +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/memgres/vector/base.py +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/memgres/vector/pgvector.py +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/memgres/vector/qdrant.py +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/memgres/worker.py +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/memgres.egg-info/dependency_links.txt +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/memgres.egg-info/requires.txt +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/memgres.egg-info/top_level.txt +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/setup.cfg +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/tests/test_blame_integration.py +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/tests/test_chunk_index.py +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/tests/test_claim_and_reembed.py +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/tests/test_config.py +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/tests/test_diffing.py +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/tests/test_embed_worker.py +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/tests/test_embeddings.py +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/tests/test_identity_integration.py +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/tests/test_lexical_match.py +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/tests/test_limits.py +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/tests/test_list.py +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/tests/test_mcp_instructions.py +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/tests/test_mcp_recall_schema.py +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/tests/test_migration_upgrade.py +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/tests/test_qdrant_ca.py +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/tests/test_qdrant_integration.py +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/tests/test_search_integration.py +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/tests/test_security_integration.py +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/tests/test_segments.py +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/tests/test_segments_store.py +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/tests/test_server_info.py +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/tests/test_snippets.py +0 -0
- {memgres-0.4.0 → memgres-0.5.0}/tests/test_store_integration.py +0 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""``memgres-grant-superadmin`` — promote a user to the superadmin service role.
|
|
2
|
+
|
|
3
|
+
The break-glass path for creating a superadmin when bootstrap seeded only a
|
|
4
|
+
``user_manager`` (or none), and the way out of a lockout (the last superadmin was
|
|
5
|
+
revoked). Like Django's ``createsuperuser``, it talks **directly to the database**
|
|
6
|
+
(``MEMGRES_DATABASE_URL``) — so the gate is host/DB access, not a network token.
|
|
7
|
+
|
|
8
|
+
memgres-grant-superadmin --list # show users + roles
|
|
9
|
+
memgres-grant-superadmin --user <uuid>
|
|
10
|
+
memgres-grant-superadmin --token-label <label> # resolve the user by a token label
|
|
11
|
+
memgres-grant-superadmin --revoke --user <uuid> # demote (anti-lockout applies)
|
|
12
|
+
|
|
13
|
+
A raw ``UPDATE`` is the last-ditch fallback; prefer this so the change is
|
|
14
|
+
validated (real user, anti-lockout) rather than silently wrong.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import sys
|
|
21
|
+
|
|
22
|
+
from . import identity
|
|
23
|
+
from .config import load
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _resolve_user(conn, args) -> str:
|
|
27
|
+
"""Return the target user id from --user or --token-label, or exit with a
|
|
28
|
+
clear message."""
|
|
29
|
+
if args.user:
|
|
30
|
+
with conn.cursor() as cur:
|
|
31
|
+
cur.execute("SELECT id FROM app_user WHERE id=%s", (args.user,))
|
|
32
|
+
if cur.fetchone() is None:
|
|
33
|
+
raise SystemExit(f"no such user: {args.user}")
|
|
34
|
+
return args.user
|
|
35
|
+
# by token label — must be unambiguous
|
|
36
|
+
with conn.cursor() as cur:
|
|
37
|
+
cur.execute("SELECT DISTINCT user_id FROM token WHERE label=%s "
|
|
38
|
+
"AND revoked_at IS NULL", (args.token_label,))
|
|
39
|
+
rows = cur.fetchall()
|
|
40
|
+
if not rows:
|
|
41
|
+
raise SystemExit(f"no active token labelled {args.token_label!r}")
|
|
42
|
+
if len(rows) > 1:
|
|
43
|
+
raise SystemExit(
|
|
44
|
+
f"token label {args.token_label!r} maps to {len(rows)} users — "
|
|
45
|
+
"use --user <uuid> instead")
|
|
46
|
+
return str(rows[0][0])
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _list_users(conn) -> None:
|
|
50
|
+
with conn.cursor() as cur:
|
|
51
|
+
cur.execute("SELECT id, role, name, description FROM app_user "
|
|
52
|
+
"ORDER BY role DESC, created_at")
|
|
53
|
+
rows = cur.fetchall()
|
|
54
|
+
if not rows:
|
|
55
|
+
print("(no users yet)")
|
|
56
|
+
return
|
|
57
|
+
print(f"{'id':36} {'role':12} name / description")
|
|
58
|
+
for uid, role, name, desc in rows:
|
|
59
|
+
label = name or desc or ""
|
|
60
|
+
print(f"{str(uid):36} {role:12} {label}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def main(argv=None) -> None: # pragma: no cover - thin entrypoint
|
|
64
|
+
import logging
|
|
65
|
+
|
|
66
|
+
import psycopg
|
|
67
|
+
|
|
68
|
+
logging.basicConfig(level=logging.INFO,
|
|
69
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
|
70
|
+
p = argparse.ArgumentParser(
|
|
71
|
+
prog="memgres-grant-superadmin",
|
|
72
|
+
description="Promote (or demote) a user's superadmin service role.")
|
|
73
|
+
p.add_argument("--list", action="store_true",
|
|
74
|
+
help="list users and their roles, then exit")
|
|
75
|
+
p.add_argument("--user", metavar="UUID", help="target user id")
|
|
76
|
+
p.add_argument("--token-label", metavar="LABEL",
|
|
77
|
+
help="resolve the target user by one of its token labels")
|
|
78
|
+
p.add_argument("--revoke", action="store_true",
|
|
79
|
+
help="demote the user out of superadmin (anti-lockout applies)")
|
|
80
|
+
p.add_argument("--demote-to", default="user", choices=("user", "user_manager"),
|
|
81
|
+
help="role to demote to with --revoke (default: user)")
|
|
82
|
+
args = p.parse_args(argv)
|
|
83
|
+
|
|
84
|
+
cfg = load()
|
|
85
|
+
with psycopg.connect(cfg.database_url or "") as conn:
|
|
86
|
+
conn.autocommit = True
|
|
87
|
+
try:
|
|
88
|
+
if args.list:
|
|
89
|
+
_list_users(conn)
|
|
90
|
+
return
|
|
91
|
+
if not args.user and not args.token_label:
|
|
92
|
+
p.error("give --user or --token-label (or --list)")
|
|
93
|
+
uid = _resolve_user(conn, args)
|
|
94
|
+
if args.revoke:
|
|
95
|
+
identity.revoke_superadmin(conn, uid, demote_to=args.demote_to)
|
|
96
|
+
print(f"revoked superadmin from {uid} (now {args.demote_to})")
|
|
97
|
+
else:
|
|
98
|
+
identity.grant_superadmin(conn, uid)
|
|
99
|
+
print(f"granted superadmin to {uid}")
|
|
100
|
+
except identity.AuthError as e: # anti-lockout
|
|
101
|
+
raise SystemExit(f"refused: {e}")
|
|
102
|
+
except psycopg.errors.UndefinedColumn:
|
|
103
|
+
raise SystemExit(
|
|
104
|
+
"this database has no service-role column yet — start "
|
|
105
|
+
"memgres-server/-mcp once to migrate it, then retry.")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
if __name__ == "__main__": # pragma: no cover
|
|
109
|
+
main()
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""First-admin onboarding for a managed deployment.
|
|
2
|
+
|
|
3
|
+
A ``managed`` server needs one control-plane admin to exist before anyone can be
|
|
4
|
+
provisioned. This module seeds that first admin **once**, at startup, from a
|
|
5
|
+
bootstrap secret — then goes inert. It is deliberately separate from
|
|
6
|
+
:mod:`memgres.identity` (pure DB logic) because it also does file I/O and
|
|
7
|
+
logging.
|
|
8
|
+
|
|
9
|
+
Invariants (see the epic ``meta.memgres.admin_as_role_and_mcp``):
|
|
10
|
+
|
|
11
|
+
* seeding fires **only when the database holds zero admin users** — a fresh
|
|
12
|
+
install. Once any admin exists, the env/file secret is inert (never a standing
|
|
13
|
+
backdoor); the stored token authenticates its real user from then on.
|
|
14
|
+
* the secret is stored **hashed, as an ordinary token** of the seeded user, so
|
|
15
|
+
the same value later resolves to that attributed user, not an anonymous root.
|
|
16
|
+
* only ``managed`` mode bootstraps; ``single``/``open`` are untouched.
|
|
17
|
+
|
|
18
|
+
Secret precedence (config rejects setting both):
|
|
19
|
+
|
|
20
|
+
* ``MEMGRES_ADMIN_TOKEN`` — the secret itself, in the env.
|
|
21
|
+
* ``MEMGRES_ADMIN_TOKEN_FILE`` — a path, **read-or-create** (Jenkins-style):
|
|
22
|
+
present and non-empty → read it; missing/empty → generate an ``mgk_`` token,
|
|
23
|
+
write it ``0600``, and log the **path only** (never the secret). The operator
|
|
24
|
+
copies it out and deletes the file on their own schedule.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import logging
|
|
30
|
+
import os
|
|
31
|
+
from typing import Optional, Tuple
|
|
32
|
+
|
|
33
|
+
from . import identity
|
|
34
|
+
from .config import Config
|
|
35
|
+
|
|
36
|
+
log = logging.getLogger("memgres.bootstrap")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class BootstrapError(RuntimeError):
|
|
40
|
+
"""The bootstrap configuration is unusable (bad secret, unwritable file)."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _read_or_create_token_file(path: str) -> Tuple[str, Optional[str]]:
|
|
44
|
+
"""Return ``(secret, generated_path)``. If the file has a token, read it
|
|
45
|
+
(``generated_path`` None). If missing/empty, generate a fresh ``mgk_`` token,
|
|
46
|
+
write it ``0600``, and return the path so the caller can log it."""
|
|
47
|
+
try:
|
|
48
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
49
|
+
existing = f.read().strip()
|
|
50
|
+
except FileNotFoundError:
|
|
51
|
+
existing = ""
|
|
52
|
+
except OSError as e: # unreadable → fail loud
|
|
53
|
+
raise BootstrapError(f"cannot read MEMGRES_ADMIN_TOKEN_FILE {path!r}: {e}")
|
|
54
|
+
if existing:
|
|
55
|
+
return existing, None
|
|
56
|
+
|
|
57
|
+
secret = identity.new_token()
|
|
58
|
+
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
|
|
63
|
+
except OSError as e:
|
|
64
|
+
raise BootstrapError(f"cannot write MEMGRES_ADMIN_TOKEN_FILE {path!r}: {e}")
|
|
65
|
+
return secret, path
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _bootstrap_secret(cfg: Config) -> Tuple[Optional[str], Optional[str]]:
|
|
69
|
+
"""Resolve the bootstrap secret from config → ``(secret, generated_path)``.
|
|
70
|
+
``(None, None)`` when the operator supplied no source."""
|
|
71
|
+
if cfg.admin_token_file:
|
|
72
|
+
return _read_or_create_token_file(cfg.admin_token_file)
|
|
73
|
+
if cfg.admin_token:
|
|
74
|
+
return cfg.admin_token, None
|
|
75
|
+
return None, None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def bootstrap_admin(conn, cfg: Config) -> Optional[str]:
|
|
79
|
+
"""Seed the first service admin if the database has none. Returns the seeded
|
|
80
|
+
user's id, or ``None`` when nothing was seeded (not managed, admins already
|
|
81
|
+
exist, or no bootstrap source). Idempotent — safe to call on every startup.
|
|
82
|
+
|
|
83
|
+
Runs in its own transaction so a partial seed never commits."""
|
|
84
|
+
if cfg.key_mode != "managed":
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
with conn.transaction():
|
|
88
|
+
if identity.count_service_admins(conn) > 0:
|
|
89
|
+
return None # control plane exists → env/file inert
|
|
90
|
+
|
|
91
|
+
secret, generated_path = _bootstrap_secret(cfg)
|
|
92
|
+
if not secret:
|
|
93
|
+
log.warning(
|
|
94
|
+
"memgres: managed mode has no service admin and neither "
|
|
95
|
+
"MEMGRES_ADMIN_TOKEN nor MEMGRES_ADMIN_TOKEN_FILE is set. "
|
|
96
|
+
"No one can be provisioned until you seed an admin — set a "
|
|
97
|
+
"bootstrap token, or run the memgres-grant-superadmin CLI.")
|
|
98
|
+
return None
|
|
99
|
+
if not identity.valid_format(secret):
|
|
100
|
+
# A legacy or weak env secret: don't crash (it still works as the
|
|
101
|
+
# anonymous break-glass root via identity.resolve), but it can't be
|
|
102
|
+
# stored as an attributable token. Warn and leave the DB adminless.
|
|
103
|
+
log.warning(
|
|
104
|
+
"memgres: the bootstrap token is not a strong mgk_ token, so no "
|
|
105
|
+
"attributable superadmin was seeded — it still works as the "
|
|
106
|
+
"anonymous env root. For an attributed admin, set a strong "
|
|
107
|
+
"MEMGRES_ADMIN_TOKEN (mgk_ + 43 url-safe chars) or leave "
|
|
108
|
+
"MEMGRES_ADMIN_TOKEN_FILE empty to have one generated.")
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
uid = identity.create_user(
|
|
112
|
+
conn, name="bootstrap-admin",
|
|
113
|
+
description="seeded at startup from the bootstrap token",
|
|
114
|
+
role=cfg.admin_role)
|
|
115
|
+
identity.register_token(conn, uid, secret, permission="admin",
|
|
116
|
+
label="bootstrap")
|
|
117
|
+
|
|
118
|
+
if generated_path:
|
|
119
|
+
log.warning(
|
|
120
|
+
"memgres: no admin token was provided — generated one and wrote it "
|
|
121
|
+
"to %s (mode 0600). Copy it now; it is NOT logged and won't be shown "
|
|
122
|
+
"again. Delete the file once you've stored the token.", generated_path)
|
|
123
|
+
else:
|
|
124
|
+
log.info("memgres: seeded the first service admin (role=%s) from the "
|
|
125
|
+
"bootstrap token", cfg.admin_role)
|
|
126
|
+
return uid
|
|
@@ -61,7 +61,15 @@ class Config:
|
|
|
61
61
|
# (set in MCP/env for a single-tenant deployment)
|
|
62
62
|
# identity / tenancy (see docs/TENANCY.md)
|
|
63
63
|
key_mode: str # single | open | managed (how tokens/users are minted)
|
|
64
|
-
admin_token: str #
|
|
64
|
+
admin_token: str # bootstrap/break-glass bearer (managed): seeds the
|
|
65
|
+
# first service admin at startup, then resolves to
|
|
66
|
+
# that real user (see memgres.bootstrap)
|
|
67
|
+
admin_token_file: str # read-or-create path for the bootstrap token
|
|
68
|
+
# (Jenkins-style): present → read it; missing/empty
|
|
69
|
+
# → generate an mgk_ token, write it 0600, log the
|
|
70
|
+
# path only. Mutually exclusive with admin_token.
|
|
71
|
+
admin_role: str # role the bootstrap admin is seeded with:
|
|
72
|
+
# user_manager (default) | superadmin
|
|
65
73
|
# organization
|
|
66
74
|
tree_enabled: bool # ltree path column + GiST index for fast subtree selection
|
|
67
75
|
require_parent: bool # False = sparse paths (create food.apple with no food row);
|
|
@@ -158,6 +166,13 @@ class Config:
|
|
|
158
166
|
raise ValueError(f"unknown MEMGRES_VECTOR_BACKEND: {self.vector_backend}")
|
|
159
167
|
if self.key_mode not in ("single", "open", "managed"):
|
|
160
168
|
raise ValueError(f"unknown MEMGRES_KEY_MODE: {self.key_mode}")
|
|
169
|
+
if self.admin_role not in ("user_manager", "superadmin"):
|
|
170
|
+
raise ValueError(
|
|
171
|
+
"MEMGRES_ADMIN_ROLE must be user_manager or superadmin "
|
|
172
|
+
f"(got {self.admin_role!r})")
|
|
173
|
+
if self.admin_token and self.admin_token_file:
|
|
174
|
+
raise ValueError(
|
|
175
|
+
"set only one of MEMGRES_ADMIN_TOKEN / MEMGRES_ADMIN_TOKEN_FILE")
|
|
161
176
|
if self.embed_provider != "none" and self.vector_backend == "pgvector" \
|
|
162
177
|
and self.embed_dim <= 0:
|
|
163
178
|
raise ValueError(
|
|
@@ -179,6 +194,8 @@ def load() -> Config:
|
|
|
179
194
|
default_token=_str("MEMGRES_TOKEN", ""),
|
|
180
195
|
key_mode=_str("MEMGRES_KEY_MODE", "single"),
|
|
181
196
|
admin_token=_str("MEMGRES_ADMIN_TOKEN", ""),
|
|
197
|
+
admin_token_file=_str("MEMGRES_ADMIN_TOKEN_FILE", ""),
|
|
198
|
+
admin_role=_str("MEMGRES_ADMIN_ROLE", "user_manager"),
|
|
182
199
|
tree_enabled=_bool("MEMGRES_TREE", True),
|
|
183
200
|
require_parent=_bool("MEMGRES_REQUIRE_PARENT", False),
|
|
184
201
|
history_enabled=_bool("MEMGRES_HISTORY", True),
|
|
@@ -48,6 +48,16 @@ def bearer_token(authorization: Optional[str],
|
|
|
48
48
|
# permission lattice
|
|
49
49
|
_RANK = {"read": 1, "write": 2, "admin": 3}
|
|
50
50
|
|
|
51
|
+
# service roles (app_user.role) — orthogonal to the per-namespace permission
|
|
52
|
+
# lattice above. `user` is the default; the two admin roles govern the CONTROL
|
|
53
|
+
# plane (provisioning) and, for superadmin, cross-namespace data access:
|
|
54
|
+
# user — owns namespaces, manages access to its OWN spaces only.
|
|
55
|
+
# user_manager — user + create users + (re)issue tokens. No cross-tenant data.
|
|
56
|
+
# superadmin — full root: read/write any namespace, grant any access,
|
|
57
|
+
# grant/revoke roles. Principal.is_admin derives from this.
|
|
58
|
+
SERVICE_ROLES = ("user", "user_manager", "superadmin")
|
|
59
|
+
_ADMIN_ROLES = ("user_manager", "superadmin")
|
|
60
|
+
|
|
51
61
|
|
|
52
62
|
class AuthError(PermissionError):
|
|
53
63
|
"""Bad/expired/revoked token, or the token may not do this here."""
|
|
@@ -91,46 +101,66 @@ class Principal:
|
|
|
91
101
|
scope_namespace_id: Optional[str] # scoped to one ns, or None = all the user's
|
|
92
102
|
token_id: Optional[str] = None
|
|
93
103
|
token_hash: Optional[str] = None
|
|
94
|
-
is_admin: bool = False #
|
|
104
|
+
is_admin: bool = False # full root: env break-glass (user_id
|
|
105
|
+
# None) or a superadmin-role user
|
|
95
106
|
provisional: bool = False # valid token, user not yet materialized
|
|
107
|
+
role: str = "user" # service role of the owning user
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def can_manage_users(p: "Principal") -> bool:
|
|
111
|
+
"""May this principal provision users / (re)issue tokens? True for a
|
|
112
|
+
user_manager, a superadmin, or the env break-glass root."""
|
|
113
|
+
return p.is_admin or p.role in _ADMIN_ROLES
|
|
96
114
|
|
|
97
115
|
|
|
98
116
|
# ─── authentication ──────────────────────────────────────────────────────────
|
|
99
117
|
def resolve(conn, cfg, secret: Optional[str]) -> Principal:
|
|
100
118
|
"""Authenticate a bearer secret into a :class:`Principal`.
|
|
101
119
|
|
|
102
|
-
*
|
|
103
|
-
|
|
120
|
+
* known token → its user/ceiling/scope/role (rejects revoked/expired). A
|
|
121
|
+
token whose user is a superadmin resolves with ``is_admin`` — so once
|
|
122
|
+
bootstrap has stored the env token as a real superadmin's token, that same
|
|
123
|
+
secret authenticates as the *attributed* user, not the anonymous root.
|
|
124
|
+
* env ``admin_token`` (break-glass) → anonymous admin principal. Tried only
|
|
125
|
+
*after* the stored-token lookup, so a seeded env token attributes to its
|
|
126
|
+
user; reachable before the first seed, or in modes bootstrap skips.
|
|
104
127
|
* unknown but well-formed token in ``open`` mode → *provisional* principal
|
|
105
128
|
(user materialized on first write); in ``managed`` mode → rejected.
|
|
106
129
|
"""
|
|
107
|
-
if cfg.admin_token and secret and hmac.compare_digest(secret, cfg.admin_token):
|
|
108
|
-
return Principal(user_id=None, permission="admin",
|
|
109
|
-
scope_namespace_id=None, is_admin=True)
|
|
110
130
|
if not secret:
|
|
111
131
|
raise AuthError("a token is required")
|
|
112
|
-
if not valid_format(secret):
|
|
113
|
-
raise AuthError("malformed token (expected mgk_ + 43 url-safe chars)")
|
|
114
132
|
|
|
115
133
|
h = token_hash(secret)
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
134
|
+
if valid_format(secret):
|
|
135
|
+
with conn.cursor() as cur:
|
|
136
|
+
cur.execute(
|
|
137
|
+
"SELECT t.id, t.user_id, t.namespace_id, t.permission, "
|
|
138
|
+
" (t.revoked_at IS NOT NULL) AS revoked, "
|
|
139
|
+
" (t.expires_at IS NOT NULL AND t.expires_at <= now()) AS expired, "
|
|
140
|
+
" u.role "
|
|
141
|
+
"FROM token t JOIN app_user u ON u.id = t.user_id "
|
|
142
|
+
"WHERE t.token_hash=%s", (h,))
|
|
143
|
+
row = cur.fetchone()
|
|
144
|
+
if row is not None:
|
|
145
|
+
tid, uid, nsid, perm, revoked, expired, role = row
|
|
146
|
+
if revoked:
|
|
147
|
+
raise AuthError("token revoked")
|
|
148
|
+
if expired:
|
|
149
|
+
raise AuthError("token expired")
|
|
150
|
+
cur.execute("UPDATE token SET last_used_at=now() WHERE id=%s", (tid,))
|
|
151
|
+
return Principal(user_id=str(uid), permission=perm,
|
|
152
|
+
scope_namespace_id=str(nsid) if nsid else None,
|
|
153
|
+
token_id=str(tid), token_hash=h, role=role,
|
|
154
|
+
is_admin=(role == "superadmin"))
|
|
155
|
+
|
|
156
|
+
# env break-glass root — any format (an operator may set a non-mgk secret);
|
|
157
|
+
# constant-time compare. Never reached for a seeded env token (matched above).
|
|
158
|
+
if cfg.admin_token and hmac.compare_digest(secret, cfg.admin_token):
|
|
159
|
+
return Principal(user_id=None, permission="admin",
|
|
160
|
+
scope_namespace_id=None, is_admin=True)
|
|
133
161
|
|
|
162
|
+
if not valid_format(secret):
|
|
163
|
+
raise AuthError("malformed token (expected mgk_ + 43 url-safe chars)")
|
|
134
164
|
if cfg.key_mode == "open":
|
|
135
165
|
# accepted, but nothing is created until the first write
|
|
136
166
|
return Principal(user_id=None, permission="write",
|
|
@@ -197,7 +227,8 @@ def resolve_space(conn, principal: Principal, *, space_id: Optional[str] = None,
|
|
|
197
227
|
default). Reads never create. The returned permission is the token ceiling
|
|
198
228
|
min the caller's membership; the caller enforces it against the op needed.
|
|
199
229
|
"""
|
|
200
|
-
if principal.is_admin:
|
|
230
|
+
if principal.is_admin and principal.user_id is None:
|
|
231
|
+
# env break-glass root: anonymous, addresses only by id
|
|
201
232
|
if space_id:
|
|
202
233
|
return str(space_id), "admin"
|
|
203
234
|
raise AuthError("global admin must address a space by id")
|
|
@@ -216,11 +247,14 @@ def resolve_space(conn, principal: Principal, *, space_id: Optional[str] = None,
|
|
|
216
247
|
raise AuthError("token is scoped to a different namespace")
|
|
217
248
|
|
|
218
249
|
with conn.cursor() as cur:
|
|
219
|
-
# 1) by id — reach anything owned or shared
|
|
250
|
+
# 1) by id — reach anything owned or shared; a superadmin user reaches
|
|
251
|
+
# ANY space by id (full root), still capped by its token ceiling.
|
|
220
252
|
if space_id is not None:
|
|
221
253
|
_scoped_ok(space_id)
|
|
222
254
|
perm = _reach(cur, uid, str(space_id))
|
|
223
255
|
if perm is None:
|
|
256
|
+
if principal.is_admin:
|
|
257
|
+
return str(space_id), perm_min("admin", ceiling)
|
|
224
258
|
raise SpaceNotFound(f"namespace {space_id} not reachable")
|
|
225
259
|
return str(space_id), perm_min(perm, ceiling)
|
|
226
260
|
|
|
@@ -259,13 +293,72 @@ def resolve_space(conn, principal: Principal, *, space_id: Optional[str] = None,
|
|
|
259
293
|
|
|
260
294
|
|
|
261
295
|
# ─── management: users / namespaces / members ────────────────────────────────
|
|
262
|
-
def create_user(conn, name: str = "", description: str = ""
|
|
296
|
+
def create_user(conn, name: str = "", description: str = "",
|
|
297
|
+
role: str = "user") -> str:
|
|
298
|
+
if role not in SERVICE_ROLES:
|
|
299
|
+
raise ValueError(f"bad role: {role}")
|
|
263
300
|
with conn.cursor() as cur:
|
|
264
|
-
cur.execute("INSERT INTO app_user (name, description
|
|
265
|
-
"RETURNING id", (name, description))
|
|
301
|
+
cur.execute("INSERT INTO app_user (name, description, role) "
|
|
302
|
+
"VALUES (%s, %s, %s) RETURNING id", (name, description, role))
|
|
266
303
|
return str(cur.fetchone()[0])
|
|
267
304
|
|
|
268
305
|
|
|
306
|
+
# ─── service roles (control plane; see SERVICE_ROLES) ────────────────────────
|
|
307
|
+
def count_service_admins(conn) -> int:
|
|
308
|
+
"""How many users hold an admin role (user_manager or superadmin). Zero ⇒ a
|
|
309
|
+
fresh install with no control plane — the trigger for bootstrap seeding."""
|
|
310
|
+
with conn.cursor() as cur:
|
|
311
|
+
cur.execute("SELECT count(*) FROM app_user WHERE role IN "
|
|
312
|
+
"('user_manager','superadmin')")
|
|
313
|
+
return int(cur.fetchone()[0])
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def count_superadmins(conn) -> int:
|
|
317
|
+
with conn.cursor() as cur:
|
|
318
|
+
cur.execute("SELECT count(*) FROM app_user WHERE role='superadmin'")
|
|
319
|
+
return int(cur.fetchone()[0])
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def get_role(conn, user_id: str) -> Optional[str]:
|
|
323
|
+
with conn.cursor() as cur:
|
|
324
|
+
cur.execute("SELECT role FROM app_user WHERE id=%s", (user_id,))
|
|
325
|
+
row = cur.fetchone()
|
|
326
|
+
return row[0] if row else None
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def set_role(conn, user_id: str, role: str) -> None:
|
|
330
|
+
"""Set a user's service role directly. Callers that lower a superadmin must
|
|
331
|
+
guard against lockout themselves; :func:`revoke_superadmin` does that."""
|
|
332
|
+
if role not in SERVICE_ROLES:
|
|
333
|
+
raise ValueError(f"bad role: {role}")
|
|
334
|
+
with conn.cursor() as cur:
|
|
335
|
+
cur.execute("UPDATE app_user SET role=%s WHERE id=%s", (role, user_id))
|
|
336
|
+
if cur.rowcount == 0:
|
|
337
|
+
raise SpaceNotFound(f"no such user {user_id}")
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def grant_superadmin(conn, user_id: str) -> None:
|
|
341
|
+
set_role(conn, user_id, "superadmin")
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def revoke_superadmin(conn, user_id: str, *, demote_to: str = "user") -> None:
|
|
345
|
+
"""Drop a user out of the superadmin role. Anti-lockout: refuses to remove
|
|
346
|
+
the **last** superadmin (recover such a lockout via the grant CLI)."""
|
|
347
|
+
if demote_to not in SERVICE_ROLES or demote_to == "superadmin":
|
|
348
|
+
raise ValueError(f"bad demote target: {demote_to}")
|
|
349
|
+
with conn.cursor() as cur:
|
|
350
|
+
cur.execute("SELECT role FROM app_user WHERE id=%s", (user_id,))
|
|
351
|
+
row = cur.fetchone()
|
|
352
|
+
if row is None:
|
|
353
|
+
raise SpaceNotFound(f"no such user {user_id}")
|
|
354
|
+
if row[0] != "superadmin":
|
|
355
|
+
return # nothing to revoke
|
|
356
|
+
cur.execute("SELECT count(*) FROM app_user WHERE role='superadmin'")
|
|
357
|
+
if int(cur.fetchone()[0]) <= 1:
|
|
358
|
+
raise AuthError("cannot revoke the last superadmin")
|
|
359
|
+
cur.execute("UPDATE app_user SET role=%s WHERE id=%s", (demote_to, user_id))
|
|
360
|
+
|
|
361
|
+
|
|
269
362
|
def create_namespace(conn, owner_user_id: str, name: str, *,
|
|
270
363
|
description: str = "", instruction: str = "") -> str:
|
|
271
364
|
"""Create (or return the existing) namespace ``name`` owned by the user."""
|
|
@@ -35,8 +35,9 @@ except ImportError: # mcp SDK 1.x
|
|
|
35
35
|
from . import identity
|
|
36
36
|
from .config import Config, load
|
|
37
37
|
from .embeddings import get_embedder
|
|
38
|
+
from .bootstrap import bootstrap_admin
|
|
38
39
|
from .schema import migrate
|
|
39
|
-
from .store import Store
|
|
40
|
+
from .store import Store, build_replace
|
|
40
41
|
|
|
41
42
|
|
|
42
43
|
# The MCP `initialize` response carries a server-side `instructions` string; a
|
|
@@ -87,6 +88,7 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
87
88
|
max_size=cfg.pool_size, open=True)
|
|
88
89
|
with pool.connection() as conn:
|
|
89
90
|
migrate(conn, cfg)
|
|
91
|
+
bootstrap_admin(conn, cfg) # seed first service admin once (managed)
|
|
90
92
|
# Start the in-process embed worker (if warranted) and set cfg.embed_dispatch
|
|
91
93
|
# to match, so writes defer to it. Kept alive by its own daemon thread.
|
|
92
94
|
from .embed_worker import wire_server
|
|
@@ -180,9 +182,7 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
180
182
|
curated caption (set whole, searchable via `memory_find`); `source`/`reason`
|
|
181
183
|
record provenance. `space` picks one of your namespaces by name (`space_id`
|
|
182
184
|
for a shared one); omit both to use your default."""
|
|
183
|
-
replace =
|
|
184
|
-
if replace_old is not None or replace_new is not None:
|
|
185
|
-
replace = (replace_old or "", replace_new or "")
|
|
185
|
+
replace = build_replace(replace_old, replace_new)
|
|
186
186
|
with pool.connection() as conn:
|
|
187
187
|
return _mem(_store(conn).write(
|
|
188
188
|
_token(ctx, token), id=id or None, body=body, diff=diff,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
-- memgres service roles (schema v9, additive — old readers ignore the column).
|
|
2
|
+
--
|
|
3
|
+
-- A user's *service* role, orthogonal to the per-namespace membership lattice
|
|
4
|
+
-- (read/write/admin) which governs data access WITHIN a space. The service role
|
|
5
|
+
-- governs the CONTROL PLANE — provisioning users/tokens and, for a superadmin,
|
|
6
|
+
-- reaching across namespaces:
|
|
7
|
+
--
|
|
8
|
+
-- user (default) — owns namespaces, manages access to its OWN spaces
|
|
9
|
+
-- (via request/approve); no cross-tenant powers.
|
|
10
|
+
-- user_manager — user + create users + (re)issue tokens on loss.
|
|
11
|
+
-- Provisioning only; does NOT read others' data nor
|
|
12
|
+
-- manage other spaces' access.
|
|
13
|
+
-- superadmin — full root: read/write ANY namespace, grant any
|
|
14
|
+
-- access, grant/revoke service roles. Principal.is_admin
|
|
15
|
+
-- derives from this (replaces the anonymous env-root).
|
|
16
|
+
--
|
|
17
|
+
-- Bootstrap seeds the FIRST admin user (see identity.bootstrap_admin); the role
|
|
18
|
+
-- it seeds is MEMGRES_ADMIN_ROLE (default user_manager). Later escalation is the
|
|
19
|
+
-- memgres-grant-superadmin CLI, not this migration.
|
|
20
|
+
ALTER TABLE app_user
|
|
21
|
+
ADD COLUMN IF NOT EXISTS role text NOT NULL DEFAULT 'user'
|
|
22
|
+
CHECK (role IN ('user', 'user_manager', 'superadmin'));
|
|
23
|
+
|
|
24
|
+
-- Find superadmins fast (anti-lockout counts them; bootstrap checks for zero
|
|
25
|
+
-- admins of any kind). Partial index — the admin rows are a tiny minority.
|
|
26
|
+
CREATE INDEX IF NOT EXISTS app_user_role_idx ON app_user (role)
|
|
27
|
+
WHERE role <> 'user';
|
|
@@ -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 = 9
|
|
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
|
|
@@ -33,6 +33,7 @@ SCHEMA_VERSION = 8
|
|
|
33
33
|
# v6 (0005): dropped the whole-body doc vector, moved ranking to chunks → BREAKING.
|
|
34
34
|
# v7 (0006): added min_reader_version column → additive, floor stays 6.
|
|
35
35
|
# v8 (0007): added embed_attempts/embed_failed_at → additive, floor stays 6.
|
|
36
|
+
# v9 (0008): added app_user.role service role → additive, floor stays 6.
|
|
36
37
|
SCHEMA_BREAKING_VERSION = 6
|
|
37
38
|
|
|
38
39
|
# Dev layout: repo/migrations next to the package. When packaged, migrations are
|