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.
Files changed (69) hide show
  1. {memgres-0.4.0 → memgres-0.5.0}/PKG-INFO +1 -1
  2. {memgres-0.4.0 → memgres-0.5.0}/memgres/_version.py +1 -1
  3. memgres-0.5.0/memgres/admin_cli.py +109 -0
  4. memgres-0.5.0/memgres/bootstrap.py +126 -0
  5. {memgres-0.4.0 → memgres-0.5.0}/memgres/config.py +18 -1
  6. {memgres-0.4.0 → memgres-0.5.0}/memgres/identity.py +123 -30
  7. {memgres-0.4.0 → memgres-0.5.0}/memgres/mcp_server.py +4 -4
  8. memgres-0.5.0/memgres/migrations/0008_service_roles.sql +27 -0
  9. {memgres-0.4.0 → memgres-0.5.0}/memgres/schema.py +2 -1
  10. {memgres-0.4.0 → memgres-0.5.0}/memgres/server.py +78 -22
  11. {memgres-0.4.0 → memgres-0.5.0}/memgres/store.py +20 -0
  12. {memgres-0.4.0 → memgres-0.5.0}/memgres.egg-info/PKG-INFO +1 -1
  13. {memgres-0.4.0 → memgres-0.5.0}/memgres.egg-info/SOURCES.txt +5 -0
  14. {memgres-0.4.0 → memgres-0.5.0}/memgres.egg-info/entry_points.txt +1 -0
  15. {memgres-0.4.0 → memgres-0.5.0}/pyproject.toml +1 -0
  16. memgres-0.5.0/tests/test_replace_build.py +40 -0
  17. memgres-0.5.0/tests/test_roles_bootstrap.py +236 -0
  18. {memgres-0.4.0 → memgres-0.5.0}/tests/test_server_integration.py +19 -0
  19. {memgres-0.4.0 → memgres-0.5.0}/LICENSE +0 -0
  20. {memgres-0.4.0 → memgres-0.5.0}/README.md +0 -0
  21. {memgres-0.4.0 → memgres-0.5.0}/memgres/__init__.py +0 -0
  22. {memgres-0.4.0 → memgres-0.5.0}/memgres/blame.py +0 -0
  23. {memgres-0.4.0 → memgres-0.5.0}/memgres/diffing.py +0 -0
  24. {memgres-0.4.0 → memgres-0.5.0}/memgres/embed_worker.py +0 -0
  25. {memgres-0.4.0 → memgres-0.5.0}/memgres/embeddings.py +0 -0
  26. {memgres-0.4.0 → memgres-0.5.0}/memgres/indexing.py +0 -0
  27. {memgres-0.4.0 → memgres-0.5.0}/memgres/info.py +0 -0
  28. {memgres-0.4.0 → memgres-0.5.0}/memgres/migrations/0001_core.sql +0 -0
  29. {memgres-0.4.0 → memgres-0.5.0}/memgres/migrations/0002_identity.sql +0 -0
  30. {memgres-0.4.0 → memgres-0.5.0}/memgres/migrations/0003_history_author.sql +0 -0
  31. {memgres-0.4.0 → memgres-0.5.0}/memgres/migrations/0004_title.sql +0 -0
  32. {memgres-0.4.0 → memgres-0.5.0}/memgres/migrations/0005_chunk_index.sql +0 -0
  33. {memgres-0.4.0 → memgres-0.5.0}/memgres/migrations/0006_reader_floor.sql +0 -0
  34. {memgres-0.4.0 → memgres-0.5.0}/memgres/migrations/0007_embed_retry.sql +0 -0
  35. {memgres-0.4.0 → memgres-0.5.0}/memgres/reembed.py +0 -0
  36. {memgres-0.4.0 → memgres-0.5.0}/memgres/search.py +0 -0
  37. {memgres-0.4.0 → memgres-0.5.0}/memgres/segments.py +0 -0
  38. {memgres-0.4.0 → memgres-0.5.0}/memgres/vector/__init__.py +0 -0
  39. {memgres-0.4.0 → memgres-0.5.0}/memgres/vector/base.py +0 -0
  40. {memgres-0.4.0 → memgres-0.5.0}/memgres/vector/pgvector.py +0 -0
  41. {memgres-0.4.0 → memgres-0.5.0}/memgres/vector/qdrant.py +0 -0
  42. {memgres-0.4.0 → memgres-0.5.0}/memgres/worker.py +0 -0
  43. {memgres-0.4.0 → memgres-0.5.0}/memgres.egg-info/dependency_links.txt +0 -0
  44. {memgres-0.4.0 → memgres-0.5.0}/memgres.egg-info/requires.txt +0 -0
  45. {memgres-0.4.0 → memgres-0.5.0}/memgres.egg-info/top_level.txt +0 -0
  46. {memgres-0.4.0 → memgres-0.5.0}/setup.cfg +0 -0
  47. {memgres-0.4.0 → memgres-0.5.0}/tests/test_blame_integration.py +0 -0
  48. {memgres-0.4.0 → memgres-0.5.0}/tests/test_chunk_index.py +0 -0
  49. {memgres-0.4.0 → memgres-0.5.0}/tests/test_claim_and_reembed.py +0 -0
  50. {memgres-0.4.0 → memgres-0.5.0}/tests/test_config.py +0 -0
  51. {memgres-0.4.0 → memgres-0.5.0}/tests/test_diffing.py +0 -0
  52. {memgres-0.4.0 → memgres-0.5.0}/tests/test_embed_worker.py +0 -0
  53. {memgres-0.4.0 → memgres-0.5.0}/tests/test_embeddings.py +0 -0
  54. {memgres-0.4.0 → memgres-0.5.0}/tests/test_identity_integration.py +0 -0
  55. {memgres-0.4.0 → memgres-0.5.0}/tests/test_lexical_match.py +0 -0
  56. {memgres-0.4.0 → memgres-0.5.0}/tests/test_limits.py +0 -0
  57. {memgres-0.4.0 → memgres-0.5.0}/tests/test_list.py +0 -0
  58. {memgres-0.4.0 → memgres-0.5.0}/tests/test_mcp_instructions.py +0 -0
  59. {memgres-0.4.0 → memgres-0.5.0}/tests/test_mcp_recall_schema.py +0 -0
  60. {memgres-0.4.0 → memgres-0.5.0}/tests/test_migration_upgrade.py +0 -0
  61. {memgres-0.4.0 → memgres-0.5.0}/tests/test_qdrant_ca.py +0 -0
  62. {memgres-0.4.0 → memgres-0.5.0}/tests/test_qdrant_integration.py +0 -0
  63. {memgres-0.4.0 → memgres-0.5.0}/tests/test_search_integration.py +0 -0
  64. {memgres-0.4.0 → memgres-0.5.0}/tests/test_security_integration.py +0 -0
  65. {memgres-0.4.0 → memgres-0.5.0}/tests/test_segments.py +0 -0
  66. {memgres-0.4.0 → memgres-0.5.0}/tests/test_segments_store.py +0 -0
  67. {memgres-0.4.0 → memgres-0.5.0}/tests/test_server_info.py +0 -0
  68. {memgres-0.4.0 → memgres-0.5.0}/tests/test_snippets.py +0 -0
  69. {memgres-0.4.0 → memgres-0.5.0}/tests/test_store_integration.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memgres
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Drop-in memory for AI agents: one Postgres, lexical + semantic recall, diff-versioned history, GDPR-erasable.
5
5
  Author: mozgsml
6
6
  License-Expression: MIT
@@ -8,4 +8,4 @@ here at release; nowhere else carries the number.
8
8
  PEP 440: a ``.devN`` suffix marks an unreleased build ahead of the last tag.
9
9
  """
10
10
 
11
- __version__ = "0.4.0"
11
+ __version__ = "0.5.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 # global-admin bearer: provision users/namespaces anywhere
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 # global admin (MEMGRES_ADMIN_TOKEN)
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
- * global admin token → admin principal;
103
- * known token → its user/ceiling/scope (rejects revoked/expired);
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
- with conn.cursor() as cur:
117
- cur.execute(
118
- "SELECT id, user_id, namespace_id, permission, "
119
- " (revoked_at IS NOT NULL) AS revoked, "
120
- " (expires_at IS NOT NULL AND expires_at <= now()) AS expired "
121
- "FROM token WHERE token_hash=%s", (h,))
122
- row = cur.fetchone()
123
- if row is not None:
124
- tid, uid, nsid, perm, revoked, expired = row
125
- if revoked:
126
- raise AuthError("token revoked")
127
- if expired:
128
- raise AuthError("token expired")
129
- cur.execute("UPDATE token SET last_used_at=now() WHERE id=%s", (tid,))
130
- return Principal(user_id=str(uid), permission=perm,
131
- scope_namespace_id=str(nsid) if nsid else None,
132
- token_id=str(tid), token_hash=h)
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 = "") -> 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) VALUES (%s, %s) "
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 = None
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 = 8
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