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.
- {memgres-0.4.1 → memgres-0.5.0}/PKG-INFO +1 -1
- {memgres-0.4.1 → 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.1 → memgres-0.5.0}/memgres/config.py +18 -1
- {memgres-0.4.1 → memgres-0.5.0}/memgres/identity.py +123 -30
- {memgres-0.4.1 → memgres-0.5.0}/memgres/mcp_server.py +2 -0
- memgres-0.5.0/memgres/migrations/0008_service_roles.sql +27 -0
- {memgres-0.4.1 → memgres-0.5.0}/memgres/schema.py +2 -1
- {memgres-0.4.1 → memgres-0.5.0}/memgres/server.py +73 -17
- {memgres-0.4.1 → memgres-0.5.0}/memgres.egg-info/PKG-INFO +1 -1
- {memgres-0.4.1 → memgres-0.5.0}/memgres.egg-info/SOURCES.txt +4 -0
- {memgres-0.4.1 → memgres-0.5.0}/memgres.egg-info/entry_points.txt +1 -0
- {memgres-0.4.1 → memgres-0.5.0}/pyproject.toml +1 -0
- memgres-0.5.0/tests/test_roles_bootstrap.py +236 -0
- {memgres-0.4.1 → memgres-0.5.0}/LICENSE +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/README.md +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/memgres/__init__.py +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/memgres/blame.py +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/memgres/diffing.py +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/memgres/embed_worker.py +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/memgres/embeddings.py +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/memgres/indexing.py +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/memgres/info.py +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/memgres/migrations/0001_core.sql +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/memgres/migrations/0002_identity.sql +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/memgres/migrations/0003_history_author.sql +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/memgres/migrations/0004_title.sql +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/memgres/migrations/0005_chunk_index.sql +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/memgres/migrations/0006_reader_floor.sql +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/memgres/migrations/0007_embed_retry.sql +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/memgres/reembed.py +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/memgres/search.py +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/memgres/segments.py +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/memgres/store.py +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/memgres/vector/__init__.py +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/memgres/vector/base.py +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/memgres/vector/pgvector.py +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/memgres/vector/qdrant.py +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/memgres/worker.py +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/memgres.egg-info/dependency_links.txt +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/memgres.egg-info/requires.txt +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/memgres.egg-info/top_level.txt +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/setup.cfg +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/tests/test_blame_integration.py +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/tests/test_chunk_index.py +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/tests/test_claim_and_reembed.py +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/tests/test_config.py +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/tests/test_diffing.py +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/tests/test_embed_worker.py +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/tests/test_embeddings.py +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/tests/test_identity_integration.py +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/tests/test_lexical_match.py +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/tests/test_limits.py +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/tests/test_list.py +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/tests/test_mcp_instructions.py +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/tests/test_mcp_recall_schema.py +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/tests/test_migration_upgrade.py +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/tests/test_qdrant_ca.py +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/tests/test_qdrant_integration.py +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/tests/test_replace_build.py +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/tests/test_search_integration.py +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/tests/test_security_integration.py +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/tests/test_segments.py +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/tests/test_segments_store.py +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/tests/test_server_info.py +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/tests/test_server_integration.py +0 -0
- {memgres-0.4.1 → memgres-0.5.0}/tests/test_snippets.py +0 -0
- {memgres-0.4.1 → 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,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 =
|
|
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 (
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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",
|
|
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",
|
|
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(
|
|
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
|
|
|
@@ -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
|
|
@@ -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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|