memgres 0.4.1__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.1 → memgres-0.5.0}/PKG-INFO +1 -1
  2. {memgres-0.4.1 → 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.1 → memgres-0.5.0}/memgres/config.py +18 -1
  6. {memgres-0.4.1 → memgres-0.5.0}/memgres/identity.py +123 -30
  7. {memgres-0.4.1 → memgres-0.5.0}/memgres/mcp_server.py +2 -0
  8. memgres-0.5.0/memgres/migrations/0008_service_roles.sql +27 -0
  9. {memgres-0.4.1 → memgres-0.5.0}/memgres/schema.py +2 -1
  10. {memgres-0.4.1 → memgres-0.5.0}/memgres/server.py +73 -17
  11. {memgres-0.4.1 → memgres-0.5.0}/memgres.egg-info/PKG-INFO +1 -1
  12. {memgres-0.4.1 → memgres-0.5.0}/memgres.egg-info/SOURCES.txt +4 -0
  13. {memgres-0.4.1 → memgres-0.5.0}/memgres.egg-info/entry_points.txt +1 -0
  14. {memgres-0.4.1 → memgres-0.5.0}/pyproject.toml +1 -0
  15. memgres-0.5.0/tests/test_roles_bootstrap.py +236 -0
  16. {memgres-0.4.1 → memgres-0.5.0}/LICENSE +0 -0
  17. {memgres-0.4.1 → memgres-0.5.0}/README.md +0 -0
  18. {memgres-0.4.1 → memgres-0.5.0}/memgres/__init__.py +0 -0
  19. {memgres-0.4.1 → memgres-0.5.0}/memgres/blame.py +0 -0
  20. {memgres-0.4.1 → memgres-0.5.0}/memgres/diffing.py +0 -0
  21. {memgres-0.4.1 → memgres-0.5.0}/memgres/embed_worker.py +0 -0
  22. {memgres-0.4.1 → memgres-0.5.0}/memgres/embeddings.py +0 -0
  23. {memgres-0.4.1 → memgres-0.5.0}/memgres/indexing.py +0 -0
  24. {memgres-0.4.1 → memgres-0.5.0}/memgres/info.py +0 -0
  25. {memgres-0.4.1 → memgres-0.5.0}/memgres/migrations/0001_core.sql +0 -0
  26. {memgres-0.4.1 → memgres-0.5.0}/memgres/migrations/0002_identity.sql +0 -0
  27. {memgres-0.4.1 → memgres-0.5.0}/memgres/migrations/0003_history_author.sql +0 -0
  28. {memgres-0.4.1 → memgres-0.5.0}/memgres/migrations/0004_title.sql +0 -0
  29. {memgres-0.4.1 → memgres-0.5.0}/memgres/migrations/0005_chunk_index.sql +0 -0
  30. {memgres-0.4.1 → memgres-0.5.0}/memgres/migrations/0006_reader_floor.sql +0 -0
  31. {memgres-0.4.1 → memgres-0.5.0}/memgres/migrations/0007_embed_retry.sql +0 -0
  32. {memgres-0.4.1 → memgres-0.5.0}/memgres/reembed.py +0 -0
  33. {memgres-0.4.1 → memgres-0.5.0}/memgres/search.py +0 -0
  34. {memgres-0.4.1 → memgres-0.5.0}/memgres/segments.py +0 -0
  35. {memgres-0.4.1 → memgres-0.5.0}/memgres/store.py +0 -0
  36. {memgres-0.4.1 → memgres-0.5.0}/memgres/vector/__init__.py +0 -0
  37. {memgres-0.4.1 → memgres-0.5.0}/memgres/vector/base.py +0 -0
  38. {memgres-0.4.1 → memgres-0.5.0}/memgres/vector/pgvector.py +0 -0
  39. {memgres-0.4.1 → memgres-0.5.0}/memgres/vector/qdrant.py +0 -0
  40. {memgres-0.4.1 → memgres-0.5.0}/memgres/worker.py +0 -0
  41. {memgres-0.4.1 → memgres-0.5.0}/memgres.egg-info/dependency_links.txt +0 -0
  42. {memgres-0.4.1 → memgres-0.5.0}/memgres.egg-info/requires.txt +0 -0
  43. {memgres-0.4.1 → memgres-0.5.0}/memgres.egg-info/top_level.txt +0 -0
  44. {memgres-0.4.1 → memgres-0.5.0}/setup.cfg +0 -0
  45. {memgres-0.4.1 → memgres-0.5.0}/tests/test_blame_integration.py +0 -0
  46. {memgres-0.4.1 → memgres-0.5.0}/tests/test_chunk_index.py +0 -0
  47. {memgres-0.4.1 → memgres-0.5.0}/tests/test_claim_and_reembed.py +0 -0
  48. {memgres-0.4.1 → memgres-0.5.0}/tests/test_config.py +0 -0
  49. {memgres-0.4.1 → memgres-0.5.0}/tests/test_diffing.py +0 -0
  50. {memgres-0.4.1 → memgres-0.5.0}/tests/test_embed_worker.py +0 -0
  51. {memgres-0.4.1 → memgres-0.5.0}/tests/test_embeddings.py +0 -0
  52. {memgres-0.4.1 → memgres-0.5.0}/tests/test_identity_integration.py +0 -0
  53. {memgres-0.4.1 → memgres-0.5.0}/tests/test_lexical_match.py +0 -0
  54. {memgres-0.4.1 → memgres-0.5.0}/tests/test_limits.py +0 -0
  55. {memgres-0.4.1 → memgres-0.5.0}/tests/test_list.py +0 -0
  56. {memgres-0.4.1 → memgres-0.5.0}/tests/test_mcp_instructions.py +0 -0
  57. {memgres-0.4.1 → memgres-0.5.0}/tests/test_mcp_recall_schema.py +0 -0
  58. {memgres-0.4.1 → memgres-0.5.0}/tests/test_migration_upgrade.py +0 -0
  59. {memgres-0.4.1 → memgres-0.5.0}/tests/test_qdrant_ca.py +0 -0
  60. {memgres-0.4.1 → memgres-0.5.0}/tests/test_qdrant_integration.py +0 -0
  61. {memgres-0.4.1 → memgres-0.5.0}/tests/test_replace_build.py +0 -0
  62. {memgres-0.4.1 → memgres-0.5.0}/tests/test_search_integration.py +0 -0
  63. {memgres-0.4.1 → memgres-0.5.0}/tests/test_security_integration.py +0 -0
  64. {memgres-0.4.1 → memgres-0.5.0}/tests/test_segments.py +0 -0
  65. {memgres-0.4.1 → memgres-0.5.0}/tests/test_segments_store.py +0 -0
  66. {memgres-0.4.1 → memgres-0.5.0}/tests/test_server_info.py +0 -0
  67. {memgres-0.4.1 → memgres-0.5.0}/tests/test_server_integration.py +0 -0
  68. {memgres-0.4.1 → memgres-0.5.0}/tests/test_snippets.py +0 -0
  69. {memgres-0.4.1 → 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.1
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.1"
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,6 +35,7 @@ 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
40
  from .store import Store, build_replace
40
41
 
@@ -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
@@ -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
@@ -30,6 +30,7 @@ from .config import Config, load
30
30
  from .diffing import DiffConflict
31
31
  from .embeddings import get_embedder
32
32
  from .identity import SpaceNotFound
33
+ from .bootstrap import bootstrap_admin
33
34
  from .schema import migrate
34
35
  from .store import Conflict, NoParent, NotFound, Store, TooLarge, build_replace
35
36
 
@@ -65,6 +66,7 @@ def create_app(cfg: Optional[Config] = None):
65
66
  # closures below capture the finalized cfg.
66
67
  with psycopg.connect(cfg.database_url or "") as _mc:
67
68
  migrate(_mc, cfg) # idempotent; stamps embed model/dim
69
+ bootstrap_admin(_mc, cfg) # seed first service admin once (managed)
68
70
  from .embed_worker import wire_server
69
71
  _worker, cfg, backend = wire_server(cfg, embedder)
70
72
  pool = ConnectionPool(cfg.database_url or "", min_size=1,
@@ -367,20 +369,40 @@ def create_app(cfg: Optional[Config] = None):
367
369
  _guard(lambda: _decide_access(conn, tok, req_id, False))
368
370
  return {"denied": req_id}
369
371
 
370
- # ─── admin provisioning (MEMGRES_ADMIN_TOKEN) ───────────────────────────
371
- import hmac as _hmac
372
-
373
- def admin(authorization: Optional[str] = Header(None),
374
- x_memgres_token: Optional[str] = Header(None)):
372
+ # ─── admin provisioning (service roles + env break-glass) ───────────────
373
+ # Provisioning is gated by the caller's service role, resolved from its
374
+ # token — not by a single shared secret. Two tiers:
375
+ # require_manage_users — user_manager, superadmin, or the env break-glass
376
+ # root: create users/namespaces, (re)issue & revoke tokens.
377
+ # require_superadmin — superadmin or env root only: cross-tenant access
378
+ # (add_member) and granting/revoking the superadmin role.
379
+ def _admin_principal(authorization, x_memgres_token):
375
380
  tok = identity.bearer_token(authorization, x_memgres_token)
376
- # constant-time compare; empty admin_token disables admin entirely
377
- if not cfg.admin_token or not tok or \
378
- not _hmac.compare_digest(tok, cfg.admin_token):
379
- raise HTTPException(403, "admin token required")
381
+ with pool.connection() as conn:
382
+ try:
383
+ return identity.resolve(conn, cfg, tok)
384
+ except identity.AuthError as e:
385
+ raise HTTPException(403, str(e))
386
+
387
+ def require_manage_users(authorization: Optional[str] = Header(None),
388
+ x_memgres_token: Optional[str] = Header(None)):
389
+ p = _admin_principal(authorization, x_memgres_token)
390
+ if not identity.can_manage_users(p):
391
+ raise HTTPException(403, "user management requires user_manager or "
392
+ "superadmin")
393
+ return p
394
+
395
+ def require_superadmin(authorization: Optional[str] = Header(None),
396
+ x_memgres_token: Optional[str] = Header(None)):
397
+ p = _admin_principal(authorization, x_memgres_token)
398
+ if not p.is_admin: # superadmin user or env root
399
+ raise HTTPException(403, "superadmin required")
400
+ return p
380
401
 
381
402
  class NewUser(BaseModel):
382
403
  name: str = ""
383
404
  description: str = ""
405
+ role: str = "user"
384
406
 
385
407
  class NewNamespace(BaseModel):
386
408
  owner_user_id: str
@@ -399,20 +421,27 @@ def create_app(cfg: Optional[Config] = None):
399
421
  user_id: str
400
422
  permission: str = "read"
401
423
 
402
- @app.post("/admin/users", status_code=201, dependencies=[Depends(admin)])
403
- def admin_create_user(req: NewUser):
424
+ @app.post("/admin/users", status_code=201)
425
+ def admin_create_user(req: NewUser,
426
+ p=Depends(require_manage_users)):
427
+ # minting an admin-role user is itself a superadmin act — a user_manager
428
+ # must not escalate anyone (nor itself, via a fresh admin user).
429
+ if req.role != "user" and not p.is_admin:
430
+ raise HTTPException(403, "granting an admin role requires superadmin")
404
431
  with pool.connection() as conn:
405
432
  return {"id": _guard(lambda: identity.create_user(
406
- conn, req.name, req.description))}
433
+ conn, req.name, req.description, role=req.role))}
407
434
 
408
- @app.post("/admin/namespaces", status_code=201, dependencies=[Depends(admin)])
435
+ @app.post("/admin/namespaces", status_code=201,
436
+ dependencies=[Depends(require_manage_users)])
409
437
  def admin_create_namespace(req: NewNamespace):
410
438
  with pool.connection() as conn:
411
439
  return {"id": _guard(lambda: identity.create_namespace(
412
440
  conn, req.owner_user_id, req.name, description=req.description,
413
441
  instruction=req.instruction))}
414
442
 
415
- @app.post("/admin/tokens", status_code=201, dependencies=[Depends(admin)])
443
+ @app.post("/admin/tokens", status_code=201,
444
+ dependencies=[Depends(require_manage_users)])
416
445
  def admin_issue_token(req: NewToken):
417
446
  import datetime as dt
418
447
  exp = None
@@ -425,18 +454,20 @@ def create_app(cfg: Optional[Config] = None):
425
454
  return {"token": secret, "id": tid,
426
455
  "note": "store this now — it is not recoverable"}
427
456
 
428
- @app.post("/admin/tokens/{token_id}/revoke", dependencies=[Depends(admin)])
457
+ @app.post("/admin/tokens/{token_id}/revoke",
458
+ dependencies=[Depends(require_manage_users)])
429
459
  def admin_revoke_token(token_id: str):
430
460
  with pool.connection() as conn:
431
461
  return {"revoked": _guard(lambda: identity.revoke_token(conn, token_id))}
432
462
 
433
- @app.get("/admin/users/{user_id}/tokens", dependencies=[Depends(admin)])
463
+ @app.get("/admin/users/{user_id}/tokens",
464
+ dependencies=[Depends(require_manage_users)])
434
465
  def admin_list_tokens(user_id: str):
435
466
  with pool.connection() as conn:
436
467
  return _guard(lambda: identity.list_tokens(conn, user_id))
437
468
 
438
469
  @app.post("/admin/namespaces/{space_id}/members", status_code=201,
439
- dependencies=[Depends(admin)])
470
+ dependencies=[Depends(require_superadmin)])
440
471
  def admin_add_member(space_id: str, req: NewMember):
441
472
  with pool.connection() as conn:
442
473
  _guard(lambda: identity.add_member(
@@ -444,6 +475,31 @@ def create_app(cfg: Optional[Config] = None):
444
475
  return {"namespace_id": space_id, "user_id": req.user_id,
445
476
  "permission": req.permission}
446
477
 
478
+ # ─── service-role management (superadmin only) ──────────────────────────
479
+ @app.post("/admin/users/{user_id}/grant-superadmin",
480
+ dependencies=[Depends(require_superadmin)])
481
+ def admin_grant_superadmin(user_id: str):
482
+ with pool.connection() as conn:
483
+ _guard(lambda: identity.grant_superadmin(conn, user_id))
484
+ return {"user_id": user_id, "role": "superadmin"}
485
+
486
+ class RevokeSuper(BaseModel):
487
+ demote_to: str = "user"
488
+
489
+ @app.post("/admin/users/{user_id}/revoke-superadmin",
490
+ dependencies=[Depends(require_superadmin)])
491
+ def admin_revoke_superadmin(user_id: str, req: RevokeSuper):
492
+ with pool.connection() as conn:
493
+ try:
494
+ identity.revoke_superadmin(conn, user_id, demote_to=req.demote_to)
495
+ except identity.AuthError as e: # anti-lockout: last superadmin
496
+ raise HTTPException(409, str(e))
497
+ except (identity.SpaceNotFound, KeyError):
498
+ raise HTTPException(404, "no such user")
499
+ except ValueError as e:
500
+ raise HTTPException(422, str(e))
501
+ return {"user_id": user_id, "role": req.demote_to}
502
+
447
503
  return app
448
504
 
449
505
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memgres
3
- Version: 0.4.1
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
@@ -3,7 +3,9 @@ README.md
3
3
  pyproject.toml
4
4
  memgres/__init__.py
5
5
  memgres/_version.py
6
+ memgres/admin_cli.py
6
7
  memgres/blame.py
8
+ memgres/bootstrap.py
7
9
  memgres/config.py
8
10
  memgres/diffing.py
9
11
  memgres/embed_worker.py
@@ -32,6 +34,7 @@ memgres/migrations/0004_title.sql
32
34
  memgres/migrations/0005_chunk_index.sql
33
35
  memgres/migrations/0006_reader_floor.sql
34
36
  memgres/migrations/0007_embed_retry.sql
37
+ memgres/migrations/0008_service_roles.sql
35
38
  memgres/vector/__init__.py
36
39
  memgres/vector/base.py
37
40
  memgres/vector/pgvector.py
@@ -53,6 +56,7 @@ tests/test_migration_upgrade.py
53
56
  tests/test_qdrant_ca.py
54
57
  tests/test_qdrant_integration.py
55
58
  tests/test_replace_build.py
59
+ tests/test_roles_bootstrap.py
56
60
  tests/test_search_integration.py
57
61
  tests/test_security_integration.py
58
62
  tests/test_segments.py
@@ -1,4 +1,5 @@
1
1
  [console_scripts]
2
+ memgres-grant-superadmin = memgres.admin_cli:main
2
3
  memgres-mcp = memgres.mcp_server:main
3
4
  memgres-reembed = memgres.reembed:main
4
5
  memgres-server = memgres.server:main
@@ -40,6 +40,7 @@ memgres-server = "memgres.server:main"
40
40
  memgres-mcp = "memgres.mcp_server:main"
41
41
  memgres-worker = "memgres.worker:main"
42
42
  memgres-reembed = "memgres.reembed:main"
43
+ memgres-grant-superadmin = "memgres.admin_cli:main"
43
44
 
44
45
  [build-system]
45
46
  requires = ["setuptools>=68"]
@@ -0,0 +1,236 @@
1
+ """Service roles + first-admin bootstrap, against a live Postgres.
2
+
3
+ Covers the seed (env / token-file read-or-create), the zero-admins invariant and
4
+ idempotency, attribution of a seeded token to its real user, the superadmin
5
+ data-root vs. a user_manager's lack of cross-tenant access, grant/revoke with
6
+ anti-lockout, and the REST role gating.
7
+ """
8
+
9
+ import os
10
+ import stat
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
15
+
16
+ import pytest # noqa: E402
17
+
18
+ psycopg = pytest.importorskip("psycopg")
19
+
20
+ from memgres import bootstrap as bs # noqa: E402
21
+ from memgres import identity # noqa: E402
22
+ from memgres.config import load # noqa: E402
23
+ from memgres.schema import migrate # noqa: E402
24
+
25
+ DSN = os.environ.get("MEMGRES_TEST_DSN",
26
+ "postgresql://memgres:memgres@localhost:55432/memgres")
27
+
28
+
29
+ def _reachable() -> bool:
30
+ try:
31
+ psycopg.connect(DSN, connect_timeout=2).close()
32
+ return True
33
+ except Exception:
34
+ return False
35
+
36
+
37
+ pytestmark = pytest.mark.skipif(not _reachable(), reason="no test Postgres")
38
+
39
+
40
+ @pytest.fixture
41
+ def env(monkeypatch):
42
+ """Fresh schema + a clean MEMGRES_ env pointed at the test DB (managed)."""
43
+ with psycopg.connect(DSN, autocommit=True) as c, c.cursor() as cur:
44
+ cur.execute("DROP SCHEMA public CASCADE; CREATE SCHEMA public;")
45
+ for k in list(os.environ):
46
+ if k.startswith("MEMGRES_"):
47
+ monkeypatch.delenv(k, raising=False)
48
+ monkeypatch.setenv("MEMGRES_DATABASE_URL", DSN)
49
+ monkeypatch.setenv("MEMGRES_EMBED_PROVIDER", "none")
50
+ monkeypatch.setenv("MEMGRES_KEY_MODE", "managed")
51
+ return monkeypatch
52
+
53
+
54
+ def _conn():
55
+ return psycopg.connect(DSN, autocommit=True)
56
+
57
+
58
+ # ─── bootstrap seeding ───────────────────────────────────────────────────────
59
+ def test_seeds_user_manager_by_default_and_is_idempotent(env):
60
+ tok = identity.new_token()
61
+ env.setenv("MEMGRES_ADMIN_TOKEN", tok)
62
+ cfg = load()
63
+ with _conn() as conn:
64
+ migrate(conn, cfg)
65
+ uid = bs.bootstrap_admin(conn, cfg)
66
+ assert uid and identity.count_service_admins(conn) == 1
67
+ assert identity.get_role(conn, uid) == "user_manager"
68
+
69
+ # the env token now resolves to that REAL user — attributed, not anon root
70
+ p = identity.resolve(conn, cfg, tok)
71
+ assert p.user_id == uid and p.role == "user_manager"
72
+ assert p.is_admin is False and identity.can_manage_users(p)
73
+
74
+ # inert on a second call, and even if the env token changes
75
+ assert bs.bootstrap_admin(conn, cfg) is None
76
+ env.setenv("MEMGRES_ADMIN_TOKEN", identity.new_token())
77
+ assert bs.bootstrap_admin(conn, load()) is None
78
+ assert identity.count_service_admins(conn) == 1
79
+
80
+
81
+ def test_seeds_superadmin_with_full_data_root(env):
82
+ env.setenv("MEMGRES_ADMIN_ROLE", "superadmin")
83
+ tok = identity.new_token()
84
+ env.setenv("MEMGRES_ADMIN_TOKEN", tok)
85
+ cfg = load()
86
+ with _conn() as conn:
87
+ migrate(conn, cfg)
88
+ bs.bootstrap_admin(conn, cfg)
89
+ # a separate tenant with a private namespace
90
+ other = identity.create_user(conn, "tenant")
91
+ ns = identity.create_namespace(conn, other, "private")
92
+
93
+ p = identity.resolve(conn, cfg, tok)
94
+ assert p.is_admin and p.role == "superadmin"
95
+ # reaches ANY namespace by id, as admin (capped by the token ceiling)
96
+ nsid, perm = identity.resolve_space(conn, p, space_id=ns)
97
+ assert nsid == ns and perm == "admin"
98
+
99
+
100
+ def test_user_manager_has_no_cross_tenant_data_access(env):
101
+ tok = identity.new_token() # default role: user_manager
102
+ env.setenv("MEMGRES_ADMIN_TOKEN", tok)
103
+ cfg = load()
104
+ with _conn() as conn:
105
+ migrate(conn, cfg)
106
+ bs.bootstrap_admin(conn, cfg)
107
+ other = identity.create_user(conn, "tenant")
108
+ ns = identity.create_namespace(conn, other, "private")
109
+
110
+ p = identity.resolve(conn, cfg, tok)
111
+ assert identity.can_manage_users(p) and not p.is_admin
112
+ with pytest.raises(identity.SpaceNotFound):
113
+ identity.resolve_space(conn, p, space_id=ns)
114
+
115
+
116
+ def test_token_file_is_generated_0600_when_absent(env, tmp_path):
117
+ path = tmp_path / "initial_admin_token"
118
+ env.setenv("MEMGRES_ADMIN_TOKEN_FILE", str(path))
119
+ cfg = load()
120
+ with _conn() as conn:
121
+ migrate(conn, cfg)
122
+ uid = bs.bootstrap_admin(conn, cfg)
123
+ assert uid
124
+ secret = path.read_text().strip()
125
+ assert identity.valid_format(secret)
126
+ assert stat.S_IMODE(os.stat(path).st_mode) == 0o600
127
+ assert identity.resolve(conn, cfg, secret).user_id == uid
128
+
129
+
130
+ def test_token_file_existing_secret_is_used(env, tmp_path):
131
+ tok = identity.new_token()
132
+ path = tmp_path / "initial_admin_token"
133
+ path.write_text(tok + "\n")
134
+ env.setenv("MEMGRES_ADMIN_TOKEN_FILE", str(path))
135
+ cfg = load()
136
+ with _conn() as conn:
137
+ migrate(conn, cfg)
138
+ uid = bs.bootstrap_admin(conn, cfg)
139
+ assert identity.resolve(conn, cfg, tok).user_id == uid
140
+ assert path.read_text().strip() == tok # left untouched
141
+
142
+
143
+ def test_non_mgk_env_token_skips_seed_but_still_works_as_root(env):
144
+ env.setenv("MEMGRES_ADMIN_TOKEN", "legacy-plain-secret")
145
+ cfg = load()
146
+ with _conn() as conn:
147
+ migrate(conn, cfg)
148
+ assert bs.bootstrap_admin(conn, cfg) is None
149
+ assert identity.count_service_admins(conn) == 0
150
+ p = identity.resolve(conn, cfg, "legacy-plain-secret")
151
+ assert p.is_admin and p.user_id is None # anonymous break-glass
152
+
153
+
154
+ @pytest.mark.parametrize("mode", ["single", "open"])
155
+ def test_bootstrap_noop_outside_managed(env, mode):
156
+ env.setenv("MEMGRES_KEY_MODE", mode)
157
+ env.setenv("MEMGRES_ADMIN_TOKEN", identity.new_token())
158
+ cfg = load()
159
+ with _conn() as conn:
160
+ migrate(conn, cfg)
161
+ assert bs.bootstrap_admin(conn, cfg) is None
162
+ assert identity.count_service_admins(conn) == 0
163
+
164
+
165
+ # ─── role management + anti-lockout ──────────────────────────────────────────
166
+ def test_grant_revoke_superadmin_anti_lockout(env):
167
+ env.setenv("MEMGRES_ADMIN_ROLE", "superadmin")
168
+ tok = identity.new_token()
169
+ env.setenv("MEMGRES_ADMIN_TOKEN", tok)
170
+ cfg = load()
171
+ with _conn() as conn:
172
+ migrate(conn, cfg)
173
+ a = bs.bootstrap_admin(conn, cfg)
174
+
175
+ # the only superadmin can't be revoked
176
+ with pytest.raises(identity.AuthError):
177
+ identity.revoke_superadmin(conn, a)
178
+
179
+ b = identity.create_user(conn, "b")
180
+ identity.grant_superadmin(conn, b)
181
+ assert identity.count_superadmins(conn) == 2
182
+
183
+ identity.revoke_superadmin(conn, a) # now allowed
184
+ assert identity.get_role(conn, a) == "user"
185
+ assert identity.count_superadmins(conn) == 1
186
+
187
+ with pytest.raises(identity.AuthError): # b is the last one again
188
+ identity.revoke_superadmin(conn, b)
189
+
190
+
191
+ # ─── REST role gating ────────────────────────────────────────────────────────
192
+ @pytest.fixture
193
+ def managed_client(env):
194
+ pytest.importorskip("fastapi")
195
+ pytest.importorskip("psycopg_pool")
196
+ from fastapi.testclient import TestClient
197
+
198
+ from memgres.server import create_app
199
+
200
+ env.setenv("MEMGRES_ADMIN_ROLE", "superadmin")
201
+ tok = identity.new_token()
202
+ env.setenv("MEMGRES_ADMIN_TOKEN", tok)
203
+ app = create_app(load()) # bootstrap seeds at startup
204
+ with TestClient(app) as c:
205
+ yield c, tok
206
+
207
+
208
+ def test_rest_role_gating(managed_client):
209
+ c, admin_tok = managed_client
210
+ H = {"Authorization": f"Bearer {admin_tok}"}
211
+
212
+ # superadmin mints a user_manager and a token for it
213
+ mgr = c.post("/admin/users", json={"name": "mgr", "role": "user_manager"},
214
+ headers=H).json()["id"]
215
+ mtok = c.post("/admin/tokens", json={"user_id": mgr, "permission": "admin"},
216
+ headers=H).json()["token"]
217
+ Hm = {"Authorization": f"Bearer {mtok}"}
218
+
219
+ # a user_manager may create plain users …
220
+ assert c.post("/admin/users", json={"name": "u"}, headers=Hm).status_code == 201
221
+ # … but may NOT mint an admin-role user (no privilege escalation) …
222
+ assert c.post("/admin/users", json={"name": "x", "role": "superadmin"},
223
+ headers=Hm).status_code == 403
224
+ # … nor grant the superadmin role.
225
+ assert c.post(f"/admin/users/{mgr}/grant-superadmin",
226
+ headers=Hm).status_code == 403
227
+
228
+ # a superadmin can promote, then anti-lockout is enforced on the last one
229
+ assert c.post(f"/admin/users/{mgr}/grant-superadmin",
230
+ headers=H).status_code == 200
231
+ # demote the manager back (two supers now → allowed)
232
+ assert c.post(f"/admin/users/{mgr}/revoke-superadmin", json={},
233
+ headers=H).status_code == 200
234
+
235
+ # no auth at all → 403
236
+ assert c.post("/admin/users", json={"name": "z"}).status_code == 403
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes