context-graph-spaces 0.1.0__py3-none-any.whl
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.
- context_graph_spaces/__init__.py +13 -0
- context_graph_spaces/cli.py +123 -0
- context_graph_spaces/provision.py +421 -0
- context_graph_spaces/resolver.py +129 -0
- context_graph_spaces/spec.py +157 -0
- context_graph_spaces/worker.py +126 -0
- context_graph_spaces-0.1.0.dist-info/METADATA +106 -0
- context_graph_spaces-0.1.0.dist-info/RECORD +11 -0
- context_graph_spaces-0.1.0.dist-info/WHEEL +4 -0
- context_graph_spaces-0.1.0.dist-info/entry_points.txt +5 -0
- context_graph_spaces-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Context Graph's Enterprise edition: personal and shared memory spaces for many users on one Memgraph.
|
|
2
|
+
|
|
3
|
+
Selected by ``edition = "enterprise"`` in the Context Graph config file, which
|
|
4
|
+
makes :class:`EnterpriseSpaces` the space resolver. The admin side provisions
|
|
5
|
+
spaces from a spec (:mod:`.spec`, :mod:`.provision`, ``context-graph-spaces
|
|
6
|
+
plan|apply``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .provision import Applied, Plan, apply, observed_state, plan
|
|
10
|
+
from .resolver import EnterpriseSpaces
|
|
11
|
+
from .spec import Spec, SpecError, load_spec
|
|
12
|
+
|
|
13
|
+
__all__ = ["Applied", "EnterpriseSpaces", "Plan", "Spec", "SpecError", "apply", "load_spec", "observed_state", "plan"]
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""``context-graph-spaces``: the admin's commands for provisioning spaces from a spec.
|
|
2
|
+
|
|
3
|
+
The admin connection comes from flags, falling back to ``MEMGRAPH_URL`` /
|
|
4
|
+
``MEMGRAPH_USER`` / ``MEMGRAPH_PASSWORD``. This is an interactive admin tool,
|
|
5
|
+
not a hook, so the config-file-only rule for hooks doesn't apply; the admin's
|
|
6
|
+
login is never written anywhere.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
15
|
+
|
|
16
|
+
from .provision import SERVICE_ACCOUNT, Plan, Query, Step, apply, observed_state, plan
|
|
17
|
+
from .spec import SpecError, load_spec
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import Sequence
|
|
21
|
+
|
|
22
|
+
from .spec import Spec
|
|
23
|
+
|
|
24
|
+
#: Where the reconcile worker finds the service login's password.
|
|
25
|
+
SERVICE_PASSWORD_ENV = "CG_SERVICE_PASSWORD"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
29
|
+
"""Run ``plan`` or ``apply``; returns the process exit code."""
|
|
30
|
+
parser = argparse.ArgumentParser(prog="context-graph-spaces", description=__doc__)
|
|
31
|
+
parser.add_argument("--url", default=os.environ.get("MEMGRAPH_URL", "bolt://localhost:7687"))
|
|
32
|
+
parser.add_argument("--user", default=os.environ.get("MEMGRAPH_USER", ""), help="An admin login.")
|
|
33
|
+
parser.add_argument("--password", default=os.environ.get("MEMGRAPH_PASSWORD", ""))
|
|
34
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
35
|
+
for name, text in (("plan", "Show what apply would change."), ("apply", "Make Memgraph match the spec.")):
|
|
36
|
+
command = commands.add_parser(name, help=text)
|
|
37
|
+
command.add_argument("spec", help="The spaces spec (TOML).")
|
|
38
|
+
command.add_argument("--prune", action="store_true", help="Also drop removed users and databases.")
|
|
39
|
+
reconcile = commands.add_parser("reconcile", help="Reconcile shared spaces' changed memory files, as cg_service.")
|
|
40
|
+
reconcile.add_argument("spec", help="The spaces spec (TOML): which shared spaces to reconcile.")
|
|
41
|
+
reconcile.add_argument("--watch", action="store_true", help="Keep going, one pass every --interval seconds.")
|
|
42
|
+
reconcile.add_argument("--interval", type=float, default=30.0)
|
|
43
|
+
args = parser.parse_args(argv)
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
spec = load_spec(args.spec)
|
|
47
|
+
except SpecError as exc:
|
|
48
|
+
print(exc, file=sys.stderr)
|
|
49
|
+
return 2
|
|
50
|
+
if args.command == "reconcile":
|
|
51
|
+
return _reconcile(spec, args.url, watch=args.watch, interval=args.interval)
|
|
52
|
+
query, close = admin_query(args.url, args.user, args.password)
|
|
53
|
+
try:
|
|
54
|
+
if args.command == "plan":
|
|
55
|
+
_print_plan(plan(spec, observed_state(query, spec.members), prune=args.prune))
|
|
56
|
+
return 0
|
|
57
|
+
applied = apply(spec, query, prune=args.prune)
|
|
58
|
+
finally:
|
|
59
|
+
close()
|
|
60
|
+
_print_steps(applied.steps, "Applied" if applied.steps else "Nothing to change; Memgraph matches the spec.")
|
|
61
|
+
_print_kept(applied.kept)
|
|
62
|
+
for login, password in sorted(applied.passwords.items()):
|
|
63
|
+
if login == SERVICE_ACCOUNT:
|
|
64
|
+
print(f"\nNew service login {login!r} for the shared-space reconcile worker; it isn't stored anywhere.")
|
|
65
|
+
print(f" Give the worker: {SERVICE_PASSWORD_ENV}='{password}'")
|
|
66
|
+
continue
|
|
67
|
+
print(f"\nNew login {login!r}. Hand this to them once; it isn't stored anywhere:")
|
|
68
|
+
print(f" memgraph-context-graph join {args.url} {login} --password='{password}'")
|
|
69
|
+
return 0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _reconcile(spec: Spec, url: str, *, watch: bool, interval: float) -> int:
|
|
73
|
+
"""Run the worker once, or every *interval* seconds with *watch*; exit 1 if the last pass had an error."""
|
|
74
|
+
import time
|
|
75
|
+
|
|
76
|
+
from .worker import reconcile_spaces
|
|
77
|
+
|
|
78
|
+
password = os.environ.get(SERVICE_PASSWORD_ENV)
|
|
79
|
+
if not password:
|
|
80
|
+
print(f"Set {SERVICE_PASSWORD_ENV} to {SERVICE_ACCOUNT}'s password (apply printed it once).", file=sys.stderr)
|
|
81
|
+
return 2
|
|
82
|
+
while True:
|
|
83
|
+
reports = reconcile_spaces(spec, url=url, password=password)
|
|
84
|
+
for report in reports:
|
|
85
|
+
if report.error:
|
|
86
|
+
print(f"{report.space}: not reconciled: {report.error}", file=sys.stderr)
|
|
87
|
+
continue
|
|
88
|
+
failed = "".join(f"\n failed {path}: {error}" for path, error in report.failed)
|
|
89
|
+
print(f"{report.space}: {len(report.completed)} reconciled, {report.pruned} leftovers removed{failed}")
|
|
90
|
+
if not watch:
|
|
91
|
+
return 1 if any(report.error or report.failed for report in reports) else 0
|
|
92
|
+
time.sleep(interval)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def admin_query(url: str, user: str, password: str) -> tuple[Query, Any]:
|
|
96
|
+
"""A :data:`~.provision.Query` over one admin driver, and the function that closes it."""
|
|
97
|
+
import neo4j
|
|
98
|
+
|
|
99
|
+
driver = neo4j.GraphDatabase.driver(url, auth=(user, password))
|
|
100
|
+
|
|
101
|
+
def query(statement: str, database: str | None, params: dict[str, Any] | None) -> list[dict[str, Any]]:
|
|
102
|
+
# Auth and multi-database DDL can't run in an explicit transaction, so each statement gets its own session.
|
|
103
|
+
with driver.session(database=database) as session:
|
|
104
|
+
# The driver's stubs want a LiteralString; admin DDL is built from names spec._NAME has validated.
|
|
105
|
+
return [record.data() for record in session.run(cast("Any", statement), params or {})]
|
|
106
|
+
|
|
107
|
+
return query, driver.close
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _print_plan(the_plan: Plan) -> None:
|
|
111
|
+
_print_steps(the_plan.steps, "Plan:" if the_plan.steps else "Nothing to change; Memgraph matches the spec.")
|
|
112
|
+
_print_kept(the_plan.kept)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _print_steps(steps: list[Step], heading: str) -> None:
|
|
116
|
+
print(heading)
|
|
117
|
+
for step in steps:
|
|
118
|
+
print(f" + {step.summary}")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _print_kept(kept: list[str]) -> None:
|
|
122
|
+
if kept:
|
|
123
|
+
print(f"Kept (pass --prune to drop): {', '.join(kept)}")
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
"""Provisioning: make Memgraph's databases, users, roles and grants match a :class:`~.spec.Spec`.
|
|
2
|
+
|
|
3
|
+
``plan`` reads Memgraph's current state and lists the changes; ``apply`` runs
|
|
4
|
+
them. Both are idempotent: once Memgraph matches the spec, ``plan`` is empty.
|
|
5
|
+
|
|
6
|
+
The shapes it maintains (map #501):
|
|
7
|
+
|
|
8
|
+
- Each member ``<login>`` has a personal database ``u_<login>`` as their main
|
|
9
|
+
database, reached through a role that may do anything inside it.
|
|
10
|
+
- Each shared space ``<name>`` is a database ``space_<name>``. Its members hold
|
|
11
|
+
a role, assigned on that database only, that may read everything there and
|
|
12
|
+
create or change only the spec's labels and edge types.
|
|
13
|
+
- ``cg_service`` holds a worker role on every shared database; it writes what
|
|
14
|
+
members can't, such as Chunks and entities.
|
|
15
|
+
- Each member's personal database holds ``(:SpaceMembership {space, database})``
|
|
16
|
+
nodes naming the shared spaces they belong to: an unprivileged login can't
|
|
17
|
+
list its own grants, and recall needs to know which spaces to read. Memgraph's
|
|
18
|
+
grants stay the authority; a stale node only costs a permission error.
|
|
19
|
+
|
|
20
|
+
Roles are named after a hash of what they grant, so changing a space's labels
|
|
21
|
+
creates a new role, moves members onto it (new grant before the old revoke, so
|
|
22
|
+
access never lapses), and drops the old one. That keeps ``plan`` a structural
|
|
23
|
+
diff: Memgraph can list label and property grants, but revoking them unevenly
|
|
24
|
+
(a label wildcard revoke clears specific label rules, a property wildcard revoke
|
|
25
|
+
doesn't) makes editing a role in place unsafe.
|
|
26
|
+
|
|
27
|
+
Only objects with the provisioner's prefixes (``u_``, ``space_``, ``cg_``) are
|
|
28
|
+
ever changed; an admin's other users, roles and databases are left alone.
|
|
29
|
+
Removing a member or space revokes access but keeps its data: databases and
|
|
30
|
+
users are dropped only with ``prune``.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import hashlib
|
|
36
|
+
import secrets
|
|
37
|
+
from collections.abc import Callable, Iterable
|
|
38
|
+
from dataclasses import dataclass, field
|
|
39
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
40
|
+
|
|
41
|
+
if TYPE_CHECKING:
|
|
42
|
+
from .spec import SharedSpace, Spec
|
|
43
|
+
|
|
44
|
+
SERVICE_ACCOUNT = "cg_service"
|
|
45
|
+
#: The label of a member's catalog of their shared spaces, in their personal database.
|
|
46
|
+
MEMBERSHIP_LABEL = "SpaceMembership"
|
|
47
|
+
_OWNED_PREFIXES = ("u_", "space_", "cg_")
|
|
48
|
+
_PRIVILEGES = "MATCH, CREATE, MERGE, SET, DELETE, REMOVE"
|
|
49
|
+
_OWNER_PRIVILEGES = _PRIVILEGES + ", INDEX, STATS, CONSTRAINT"
|
|
50
|
+
_EVERYTHING = (
|
|
51
|
+
"GRANT * ON NODES CONTAINING LABELS * TO ROLE {role}",
|
|
52
|
+
"GRANT * ON EDGES OF TYPE * TO ROLE {role}",
|
|
53
|
+
"GRANT READ, SET PROPERTY {{*}} ON NODES CONTAINING LABELS * TO ROLE {role}",
|
|
54
|
+
"GRANT READ, SET PROPERTY {{*}} ON EDGES OF TYPE * TO ROLE {role}",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def personal_database(login: str) -> str:
|
|
59
|
+
"""The database holding *login*'s personal space."""
|
|
60
|
+
return f"u_{login}"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def shared_database(space: str) -> str:
|
|
64
|
+
"""The database holding shared space *space*."""
|
|
65
|
+
return f"space_{space}"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _owned(name: str) -> bool:
|
|
69
|
+
return name.startswith(_OWNED_PREFIXES)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class Role:
|
|
74
|
+
"""A role the provisioner owns: the databases it reaches and the grants it carries."""
|
|
75
|
+
|
|
76
|
+
prefix: str
|
|
77
|
+
databases: tuple[str, ...]
|
|
78
|
+
grants: tuple[str, ...]
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def name(self) -> str:
|
|
82
|
+
"""``<prefix>_<hash of databases and grants>``: a different grant set is a different role."""
|
|
83
|
+
digest = hashlib.sha256("\n".join((*self.databases, *self.grants)).encode()).hexdigest()[:8]
|
|
84
|
+
return f"{self.prefix}_{digest}"
|
|
85
|
+
|
|
86
|
+
def statements(self) -> list[str]:
|
|
87
|
+
"""Create the role and give it everything it carries."""
|
|
88
|
+
return [
|
|
89
|
+
f"CREATE ROLE {self.name}",
|
|
90
|
+
*(f"GRANT DATABASE {database} TO ROLE {self.name}" for database in self.databases),
|
|
91
|
+
*(grant.format(role=self.name) for grant in self.grants),
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class Assignment:
|
|
97
|
+
"""*user* holds *role* on *database*."""
|
|
98
|
+
|
|
99
|
+
user: str
|
|
100
|
+
role: str
|
|
101
|
+
database: str
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass
|
|
105
|
+
class State:
|
|
106
|
+
"""The provisioner's objects, as desired from a spec or as observed in Memgraph."""
|
|
107
|
+
|
|
108
|
+
databases: set[str] = field(default_factory=set)
|
|
109
|
+
users: set[str] = field(default_factory=set)
|
|
110
|
+
roles: dict[str, Role | None] = field(default_factory=dict)
|
|
111
|
+
assignments: set[Assignment] = field(default_factory=set)
|
|
112
|
+
#: profile name -> (memory limit in MB, databases it's attached to)
|
|
113
|
+
profiles: dict[str, tuple[int | None, frozenset[str]]] = field(default_factory=dict)
|
|
114
|
+
#: login -> the shared spaces their catalog names, as (space, database)
|
|
115
|
+
catalogs: dict[str, frozenset[tuple[str, str]]] = field(default_factory=dict)
|
|
116
|
+
#: login -> the databases they can reach, directly or through a role (observed only)
|
|
117
|
+
access: dict[str, frozenset[str]] = field(default_factory=dict)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True)
|
|
121
|
+
class Step:
|
|
122
|
+
"""One change: what it does, in words, and the statements that do it, each with the database to run it on."""
|
|
123
|
+
|
|
124
|
+
summary: str
|
|
125
|
+
statements: tuple[tuple[str, str | None], ...] = ()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass
|
|
129
|
+
class Plan:
|
|
130
|
+
"""The changes that make Memgraph match the spec, in the order they must run."""
|
|
131
|
+
|
|
132
|
+
steps: list[Step] = field(default_factory=list)
|
|
133
|
+
#: Logins ``apply`` will create, which need a generated password.
|
|
134
|
+
new_users: list[str] = field(default_factory=list)
|
|
135
|
+
#: What a removal keeps because ``prune`` wasn't asked for.
|
|
136
|
+
kept: list[str] = field(default_factory=list)
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def empty(self) -> bool:
|
|
140
|
+
"""Whether Memgraph already matches the spec."""
|
|
141
|
+
return not self.steps
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def desired_state(spec: Spec) -> State:
|
|
145
|
+
"""What Memgraph should hold for *spec*."""
|
|
146
|
+
state = State()
|
|
147
|
+
for login in spec.members:
|
|
148
|
+
database = personal_database(login)
|
|
149
|
+
role = Role(
|
|
150
|
+
f"cg_u_{login}",
|
|
151
|
+
(database,),
|
|
152
|
+
(f"GRANT {_OWNER_PRIVILEGES} TO ROLE {{role}}", *_EVERYTHING),
|
|
153
|
+
)
|
|
154
|
+
state.databases.add(database)
|
|
155
|
+
state.users.add(login)
|
|
156
|
+
state.roles[role.name] = role
|
|
157
|
+
state.assignments.add(Assignment(login, role.name, database))
|
|
158
|
+
state.catalogs[login] = frozenset((space.name, shared_database(space.name)) for space in spec.spaces_of(login))
|
|
159
|
+
for space in spec.spaces:
|
|
160
|
+
database = shared_database(space.name)
|
|
161
|
+
role = _member_role(space)
|
|
162
|
+
state.databases.add(database)
|
|
163
|
+
state.roles[role.name] = role
|
|
164
|
+
state.assignments.update(Assignment(login, role.name, database) for login in space.members)
|
|
165
|
+
if spec.spaces:
|
|
166
|
+
worker = Role(
|
|
167
|
+
"cg_worker",
|
|
168
|
+
tuple(sorted(shared_database(space.name) for space in spec.spaces)),
|
|
169
|
+
(f"GRANT {_OWNER_PRIVILEGES} TO ROLE {{role}}", *_EVERYTHING),
|
|
170
|
+
)
|
|
171
|
+
state.users.add(SERVICE_ACCOUNT)
|
|
172
|
+
state.roles[worker.name] = worker
|
|
173
|
+
state.assignments.update(Assignment(SERVICE_ACCOUNT, worker.name, database) for database in worker.databases)
|
|
174
|
+
limits: dict[int, set[str]] = {}
|
|
175
|
+
if spec.personal_memory_limit_mb:
|
|
176
|
+
limits.setdefault(spec.personal_memory_limit_mb, set()).update(map(personal_database, spec.members))
|
|
177
|
+
for space in spec.spaces:
|
|
178
|
+
if space.memory_limit_mb:
|
|
179
|
+
limits.setdefault(space.memory_limit_mb, set()).add(shared_database(space.name))
|
|
180
|
+
state.profiles = {f"cg_{mb}mb": (mb, frozenset(databases)) for mb, databases in limits.items()}
|
|
181
|
+
return state
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _member_role(space: SharedSpace) -> Role:
|
|
185
|
+
grants = [
|
|
186
|
+
f"GRANT {_PRIVILEGES} TO ROLE {{role}}",
|
|
187
|
+
"GRANT READ ON NODES CONTAINING LABELS * TO ROLE {role}",
|
|
188
|
+
"GRANT READ ON EDGES OF TYPE * TO ROLE {role}",
|
|
189
|
+
"GRANT READ {{*}} ON NODES CONTAINING LABELS * TO ROLE {role}",
|
|
190
|
+
"GRANT READ {{*}} ON EDGES OF TYPE * TO ROLE {role}",
|
|
191
|
+
]
|
|
192
|
+
for label in space.labels:
|
|
193
|
+
grants += [
|
|
194
|
+
f"GRANT * ON NODES CONTAINING LABELS :{label} TO ROLE {{role}}",
|
|
195
|
+
f"GRANT READ, SET PROPERTY {{{{*}}}} ON NODES CONTAINING LABELS :{label} TO ROLE {{role}}",
|
|
196
|
+
]
|
|
197
|
+
for edge_type in space.edge_types:
|
|
198
|
+
grants += [
|
|
199
|
+
f"GRANT * ON EDGES OF TYPE :{edge_type} TO ROLE {{role}}",
|
|
200
|
+
f"GRANT READ, SET PROPERTY {{{{*}}}} ON EDGES OF TYPE :{edge_type} TO ROLE {{role}}",
|
|
201
|
+
]
|
|
202
|
+
return Role(f"space_{space.name}_member", (shared_database(space.name),), tuple(grants))
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
#: Runs one statement as the admin: (statement, database or None for the admin's main one, parameters).
|
|
206
|
+
Query = Callable[[str, str | None, dict[str, Any] | None], list[dict[str, Any]]]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def observed_state(query: Query, members: Iterable[str] = ()) -> State:
|
|
210
|
+
"""What Memgraph holds of the provisioner's objects.
|
|
211
|
+
|
|
212
|
+
*members* are logins to inspect even if they hold no role yet.
|
|
213
|
+
"""
|
|
214
|
+
state = State()
|
|
215
|
+
state.databases = {row["Name"] for row in query("SHOW DATABASES", None, None) if _owned(row["Name"])}
|
|
216
|
+
all_users = {row["user"] for row in query("SHOW USERS", None, None)}
|
|
217
|
+
state.roles = {
|
|
218
|
+
row["role"]: None for row in query("SHOW ROLES", None, None) if not row.get("builtin") and _owned(row["role"])
|
|
219
|
+
}
|
|
220
|
+
holders = {SERVICE_ACCOUNT} & all_users
|
|
221
|
+
for role in state.roles:
|
|
222
|
+
holders.update(row["users"] for row in query(f"SHOW USERS FOR ROLE {role}", None, None))
|
|
223
|
+
holders.update(set(members) & all_users)
|
|
224
|
+
# A removed member holds no role any more; their personal database still marks them as ours.
|
|
225
|
+
holders.update(user for user in all_users if personal_database(user) in state.databases)
|
|
226
|
+
state.users = holders
|
|
227
|
+
for user in holders:
|
|
228
|
+
rows = query(f"SHOW DATABASE PRIVILEGES FOR USER {user}", None, None)
|
|
229
|
+
state.access[user] = frozenset(name for row in rows for name in row.get("grants") or ())
|
|
230
|
+
for user in holders:
|
|
231
|
+
for database in state.databases:
|
|
232
|
+
for row in query(f"SHOW ROLE FOR {user} ON DATABASE {database}", None, None):
|
|
233
|
+
if row.get("role") in state.roles:
|
|
234
|
+
state.assignments.add(Assignment(user, row["role"], database))
|
|
235
|
+
for row in query("SHOW TENANT PROFILES", None, None):
|
|
236
|
+
if _owned(row["profile"]):
|
|
237
|
+
databases = frozenset(name.strip() for name in str(row.get("databases") or "").split(",") if name.strip())
|
|
238
|
+
state.profiles[row["profile"]] = (_megabytes(row.get("memory_limit")), databases)
|
|
239
|
+
for user in holders - {SERVICE_ACCOUNT}:
|
|
240
|
+
database = personal_database(user)
|
|
241
|
+
if database in state.databases:
|
|
242
|
+
rows = query(
|
|
243
|
+
f"MATCH (m:{MEMBERSHIP_LABEL}) RETURN m.space AS space, m.database AS database", database, None
|
|
244
|
+
)
|
|
245
|
+
state.catalogs[user] = frozenset((row["space"], row["database"]) for row in rows)
|
|
246
|
+
return state
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _megabytes(text: Any) -> int | None:
|
|
250
|
+
"""``"64.00MiB"`` -> 64; anything else (``"UNLIMITED"``) -> None."""
|
|
251
|
+
value = str(text or "")
|
|
252
|
+
if value.endswith("MiB"):
|
|
253
|
+
return round(float(value[:-3]))
|
|
254
|
+
if value.endswith("GiB"):
|
|
255
|
+
return round(float(value[:-3]) * 1024)
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def plan(spec: Spec, observed: State, *, prune: bool = False) -> Plan:
|
|
260
|
+
"""The steps that turn *observed* into *spec*'s desired state."""
|
|
261
|
+
desired = desired_state(spec)
|
|
262
|
+
result = Plan()
|
|
263
|
+
steps = result.steps
|
|
264
|
+
|
|
265
|
+
for database in sorted(desired.databases - observed.databases):
|
|
266
|
+
steps.append(Step(f"create database {database}", ((f"CREATE DATABASE {database}", None),)))
|
|
267
|
+
for name, (mb, databases) in sorted(desired.profiles.items()):
|
|
268
|
+
current = observed.profiles.get(name)
|
|
269
|
+
if current is None:
|
|
270
|
+
steps.append(
|
|
271
|
+
Step(
|
|
272
|
+
f"create tenant profile {name}",
|
|
273
|
+
((f"CREATE TENANT PROFILE {name} LIMIT memory_limit {mb} MB", None),),
|
|
274
|
+
)
|
|
275
|
+
)
|
|
276
|
+
for database in sorted(databases - (current[1] if current else frozenset())):
|
|
277
|
+
steps.append(
|
|
278
|
+
Step(f"limit {database} with {name}", ((f"SET TENANT PROFILE ON DATABASE {database} TO {name}", None),))
|
|
279
|
+
)
|
|
280
|
+
for name in sorted(set(desired.roles) - set(observed.roles)):
|
|
281
|
+
role = desired.roles[name]
|
|
282
|
+
assert role is not None
|
|
283
|
+
steps.append(Step(f"create role {name}", tuple((statement, None) for statement in role.statements())))
|
|
284
|
+
for login in sorted(desired.users - observed.users):
|
|
285
|
+
result.new_users.append(login)
|
|
286
|
+
# The password is generated at apply time, never shown by plan.
|
|
287
|
+
steps.append(
|
|
288
|
+
Step(f"create user {login}", (("CREATE USER IF NOT EXISTS {login} IDENTIFIED BY {password}", login),))
|
|
289
|
+
)
|
|
290
|
+
for login in sorted(desired.users - observed.users - {SERVICE_ACCOUNT}):
|
|
291
|
+
steps.append(Step(f"make {personal_database(login)} {login}'s main database", _main_database(login)))
|
|
292
|
+
for assignment in sorted(desired.assignments - observed.assignments, key=_order):
|
|
293
|
+
steps.append(
|
|
294
|
+
Step(
|
|
295
|
+
f"give {assignment.user} {assignment.role} on {assignment.database}",
|
|
296
|
+
((f"GRANT ROLE {assignment.role} TO {assignment.user} ON {assignment.database}", None),),
|
|
297
|
+
)
|
|
298
|
+
)
|
|
299
|
+
for login, spaces in sorted(desired.catalogs.items()):
|
|
300
|
+
if observed.catalogs.get(login, frozenset()) != spaces:
|
|
301
|
+
names = ", ".join(sorted(space for space, _ in spaces)) or "none"
|
|
302
|
+
steps.append(Step(f"record {login}'s shared spaces: {names}", ((_CATALOG, login),)))
|
|
303
|
+
|
|
304
|
+
for assignment in sorted(observed.assignments - desired.assignments, key=_order):
|
|
305
|
+
steps.append(
|
|
306
|
+
Step(
|
|
307
|
+
f"take {assignment.role} on {assignment.database} from {assignment.user}",
|
|
308
|
+
((f"REVOKE ROLE {assignment.role} FROM {assignment.user} ON {assignment.database}", None),),
|
|
309
|
+
)
|
|
310
|
+
)
|
|
311
|
+
for name in sorted(set(observed.roles) - set(desired.roles)):
|
|
312
|
+
steps.append(Step(f"drop role {name}", ((f"DROP ROLE {name}", None),)))
|
|
313
|
+
for login in sorted(observed.users - desired.users):
|
|
314
|
+
database = personal_database(login)
|
|
315
|
+
if database in observed.access.get(login, frozenset()):
|
|
316
|
+
steps.append(
|
|
317
|
+
Step(
|
|
318
|
+
f"revoke {login}'s access to {database}", ((f"REVOKE DATABASE {database} FROM USER {login}", None),)
|
|
319
|
+
)
|
|
320
|
+
)
|
|
321
|
+
if prune:
|
|
322
|
+
steps.append(Step(f"drop user {login}", ((f"DROP USER {login}", None),)))
|
|
323
|
+
else:
|
|
324
|
+
result.kept.append(f"user {login}")
|
|
325
|
+
for name, (_, databases) in sorted(observed.profiles.items()):
|
|
326
|
+
wanted = desired.profiles.get(name, (None, frozenset()))[1]
|
|
327
|
+
for database in sorted(databases - wanted):
|
|
328
|
+
steps.append(Step(f"unlimit {database}", ((f"REMOVE TENANT PROFILE FROM DATABASE {database}", None),)))
|
|
329
|
+
if name not in desired.profiles:
|
|
330
|
+
steps.append(Step(f"drop tenant profile {name}", ((f"DROP TENANT PROFILE {name}", None),)))
|
|
331
|
+
for database in sorted(observed.databases - desired.databases):
|
|
332
|
+
if prune:
|
|
333
|
+
steps.append(Step(f"drop database {database}", ((f"DROP DATABASE {database} FORCE", None),)))
|
|
334
|
+
else:
|
|
335
|
+
result.kept.append(f"database {database}")
|
|
336
|
+
return result
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _main_database(login: str) -> tuple[tuple[str, str | None], ...]:
|
|
340
|
+
# The direct grant is what SET MAIN DATABASE needs: revoking it clears the main database even when a role
|
|
341
|
+
# still reaches the database.
|
|
342
|
+
database = personal_database(login)
|
|
343
|
+
return ((f"GRANT DATABASE {database} TO USER {login}", None), (f"SET MAIN DATABASE {database} FOR {login}", None))
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
#: Catalog rewrite marker: run against a member's personal database with their shared spaces.
|
|
347
|
+
_CATALOG = "-- catalog --"
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _order(assignment: Assignment) -> tuple[str, str, str]:
|
|
351
|
+
return (assignment.user, assignment.database, assignment.role)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
@dataclass
|
|
355
|
+
class Applied:
|
|
356
|
+
"""What ``apply`` did: the steps run and the passwords of users it created (shown once, never stored)."""
|
|
357
|
+
|
|
358
|
+
steps: list[Step]
|
|
359
|
+
passwords: dict[str, str]
|
|
360
|
+
kept: list[str]
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def apply(spec: Spec, query: Query, *, prune: bool = False) -> Applied:
|
|
364
|
+
"""Make Memgraph match *spec*, then return what was done.
|
|
365
|
+
|
|
366
|
+
Raises:
|
|
367
|
+
neo4j.exceptions.Neo4jError: a statement Memgraph rejected; the steps before it stay applied, and
|
|
368
|
+
re-running ``apply`` continues from where it stopped.
|
|
369
|
+
"""
|
|
370
|
+
the_plan = plan(spec, observed_state(query, spec.members), prune=prune)
|
|
371
|
+
desired = desired_state(spec)
|
|
372
|
+
passwords = {login: secrets.token_urlsafe(18) for login in the_plan.new_users}
|
|
373
|
+
for step in the_plan.steps:
|
|
374
|
+
for statement, target in step.statements:
|
|
375
|
+
if statement == _CATALOG:
|
|
376
|
+
assert target is not None
|
|
377
|
+
_write_catalog(query, target, desired.catalogs[target])
|
|
378
|
+
elif "{password}" in statement:
|
|
379
|
+
assert target is not None
|
|
380
|
+
# token_urlsafe only yields [A-Za-z0-9_-], so the quoted literal can't be broken out of;
|
|
381
|
+
# Memgraph's CREATE USER takes no query parameters.
|
|
382
|
+
query(statement.format(login=target, password=f"'{passwords[target]}'"), None, None)
|
|
383
|
+
else:
|
|
384
|
+
query(statement, target, None)
|
|
385
|
+
# Memgraph can't report a user's main database, so plan can't see it drift (a revoke clears it); re-assert it.
|
|
386
|
+
for login in spec.members:
|
|
387
|
+
for statement, target in _main_database(login):
|
|
388
|
+
query(statement, target, None)
|
|
389
|
+
# Members can't create indexes or constraints in a shared space, so the memory store's schema is set up here.
|
|
390
|
+
for space in spec.spaces:
|
|
391
|
+
setup_shared_schema(query, shared_database(space.name))
|
|
392
|
+
return Applied(the_plan.steps, passwords, the_plan.kept)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def setup_shared_schema(query: Query, database: str) -> None:
|
|
396
|
+
"""Create sessions-graph's constraints and indexes in shared *database*, as the admin; idempotent."""
|
|
397
|
+
from sessions_graph import SessionsGraph
|
|
398
|
+
|
|
399
|
+
SessionsGraph(cast("Any", _OnDatabase(query, database))).setup()
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
class _OnDatabase:
|
|
403
|
+
"""The ``query`` half of memgraph-toolbox's client, pinned to one database over an admin :data:`Query`."""
|
|
404
|
+
|
|
405
|
+
def __init__(self, query: Query, database: str) -> None:
|
|
406
|
+
self._query = query
|
|
407
|
+
self._database = database
|
|
408
|
+
|
|
409
|
+
def query(self, statement: str, params: dict[str, Any] | None = None) -> list[dict[str, Any]]:
|
|
410
|
+
return self._query(statement, self._database, params)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _write_catalog(query: Query, login: str, spaces: frozenset[tuple[str, str]]) -> None:
|
|
414
|
+
rows = [{"space": space, "database": shared} for space, shared in sorted(spaces)]
|
|
415
|
+
query(
|
|
416
|
+
f"MATCH (m:{MEMBERSHIP_LABEL}) DETACH DELETE m "
|
|
417
|
+
f"WITH count(*) AS _ UNWIND $rows AS row "
|
|
418
|
+
f"CREATE (:{MEMBERSHIP_LABEL} {{space: row.space, database: row.database}})",
|
|
419
|
+
personal_database(login),
|
|
420
|
+
{"rows": rows},
|
|
421
|
+
)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""The Enterprise edition's space resolver: the Memgraph login is the user.
|
|
2
|
+
|
|
3
|
+
memgraph-context-graph finds :class:`EnterpriseSpaces` through the
|
|
4
|
+
``memgraph_context_graph.editions`` entry point when the config file says
|
|
5
|
+
``edition = "enterprise"``. Map #501's rules, as code:
|
|
6
|
+
|
|
7
|
+
- Identity is the login (``[memgraph] user``). Memgraph authenticates it on
|
|
8
|
+
every connection, so it can't be claimed. A configured
|
|
9
|
+
``identity.user_id`` or ``memgraph.database`` is refused rather than ignored,
|
|
10
|
+
because either would silently disagree with the login. A hook payload's
|
|
11
|
+
``user_id`` is ignored.
|
|
12
|
+
- The personal space is the login's main database, reached by naming no
|
|
13
|
+
database. Hooks therefore resolve their target without a single query.
|
|
14
|
+
- Shared spaces are read from the ``(:SpaceMembership)`` catalog that
|
|
15
|
+
``context-graph-spaces apply`` writes into the personal database. That is the
|
|
16
|
+
one query, made only by long-lived readers (the MCP server's recall and
|
|
17
|
+
memory tools), and cached until :meth:`EnterpriseSpaces.refresh`.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import threading
|
|
23
|
+
from typing import TYPE_CHECKING, Any
|
|
24
|
+
|
|
25
|
+
from memgraph_context_graph.spaces import EDITION_ENTERPRISE, PERSONAL, MemgraphTarget, Space, SpaceError
|
|
26
|
+
|
|
27
|
+
from .provision import MEMBERSHIP_LABEL
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from memgraph_context_graph.adapters._identity import HookConfig
|
|
31
|
+
|
|
32
|
+
#: The longest a connection outlives a revoked grant, in seconds. Memgraph checks grants when a connection
|
|
33
|
+
#: opens, so a long-lived reader (the MCP server) would otherwise keep a removed member's access until restart.
|
|
34
|
+
REVOCATION_DELAY_SECONDS = 60.0
|
|
35
|
+
|
|
36
|
+
#: Config keys the login decides in Enterprise; writing them is an error.
|
|
37
|
+
_REFUSED_KEYS = {
|
|
38
|
+
"identity.user_id": "the user is your Memgraph login",
|
|
39
|
+
"memgraph.database": "your personal space is your login's main database",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class EnterpriseSpaces:
|
|
44
|
+
"""One login's personal space and the shared spaces it belongs to."""
|
|
45
|
+
|
|
46
|
+
edition = EDITION_ENTERPRISE
|
|
47
|
+
|
|
48
|
+
def __init__(self, config: HookConfig) -> None:
|
|
49
|
+
"""Check *config* is a valid Enterprise config.
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
SpaceError: no ``[memgraph] user``, or a key the login decides is set.
|
|
53
|
+
"""
|
|
54
|
+
for key, reason in _REFUSED_KEYS.items():
|
|
55
|
+
if key in config.present:
|
|
56
|
+
raise SpaceError(f"Remove {key} from the config file: in the Enterprise edition {reason}.")
|
|
57
|
+
if not config.memgraph_user:
|
|
58
|
+
raise SpaceError("The Enterprise edition needs [memgraph] user: your Memgraph login.")
|
|
59
|
+
self._login = config.memgraph_user
|
|
60
|
+
self._personal = Space(
|
|
61
|
+
PERSONAL,
|
|
62
|
+
MemgraphTarget(
|
|
63
|
+
url=config.memgraph_url,
|
|
64
|
+
user=config.memgraph_user,
|
|
65
|
+
password=config.memgraph_password,
|
|
66
|
+
database=None,
|
|
67
|
+
max_connection_lifetime=REVOCATION_DELAY_SECONDS,
|
|
68
|
+
),
|
|
69
|
+
)
|
|
70
|
+
self._lock = threading.Lock()
|
|
71
|
+
self._shared: dict[str, Space] | None = None
|
|
72
|
+
|
|
73
|
+
def user_id(self, payload: dict[str, Any] | None = None) -> str | None:
|
|
74
|
+
"""The login; a payload can't name anyone else."""
|
|
75
|
+
return self._login
|
|
76
|
+
|
|
77
|
+
def personal(self) -> Space:
|
|
78
|
+
"""The login's main database."""
|
|
79
|
+
return self._personal
|
|
80
|
+
|
|
81
|
+
def readable(self) -> list[Space]:
|
|
82
|
+
"""The personal space, then the shared spaces the catalog lists, in name order."""
|
|
83
|
+
return [self._personal, *sorted(self._shared_spaces().values(), key=lambda space: space.name)]
|
|
84
|
+
|
|
85
|
+
def shared_space(self, name: str) -> Space:
|
|
86
|
+
"""The shared space *name*, if the login belongs to it.
|
|
87
|
+
|
|
88
|
+
Raises:
|
|
89
|
+
SpaceError: the catalog doesn't list *name*.
|
|
90
|
+
"""
|
|
91
|
+
spaces = self._shared_spaces()
|
|
92
|
+
if name not in spaces:
|
|
93
|
+
known = ", ".join(sorted(spaces)) or "none"
|
|
94
|
+
raise SpaceError(f"You aren't a member of a shared space named {name!r} (yours: {known}).")
|
|
95
|
+
return spaces[name]
|
|
96
|
+
|
|
97
|
+
def refresh(self) -> None:
|
|
98
|
+
"""Forget the cached catalog, e.g. after Memgraph refused a space it listed."""
|
|
99
|
+
with self._lock:
|
|
100
|
+
self._shared = None
|
|
101
|
+
|
|
102
|
+
def _shared_spaces(self) -> dict[str, Space]:
|
|
103
|
+
with self._lock:
|
|
104
|
+
if self._shared is None:
|
|
105
|
+
self._shared = self._read_catalog()
|
|
106
|
+
return self._shared
|
|
107
|
+
|
|
108
|
+
def _read_catalog(self) -> dict[str, Space]:
|
|
109
|
+
from memgraph_toolbox.api.memgraph import Memgraph
|
|
110
|
+
|
|
111
|
+
target = self._personal.target
|
|
112
|
+
client = Memgraph(**target.client_kwargs(), user_agent="context-graph-spaces")
|
|
113
|
+
try:
|
|
114
|
+
rows = client.query(f"MATCH (m:{MEMBERSHIP_LABEL}) RETURN m.space AS space, m.database AS database")
|
|
115
|
+
finally:
|
|
116
|
+
client.close()
|
|
117
|
+
return {
|
|
118
|
+
row["space"]: Space(
|
|
119
|
+
row["space"],
|
|
120
|
+
MemgraphTarget(
|
|
121
|
+
url=target.url,
|
|
122
|
+
user=target.user,
|
|
123
|
+
password=target.password,
|
|
124
|
+
database=row["database"],
|
|
125
|
+
max_connection_lifetime=target.max_connection_lifetime,
|
|
126
|
+
),
|
|
127
|
+
)
|
|
128
|
+
for row in rows
|
|
129
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""The spaces spec: who the members are and which shared spaces they belong to.
|
|
2
|
+
|
|
3
|
+
An admin keeps one TOML file, reviewed like code, and ``context-graph-spaces
|
|
4
|
+
apply`` makes Memgraph match it::
|
|
5
|
+
|
|
6
|
+
members = ["ante", "bob"]
|
|
7
|
+
personal_memory_limit_mb = 2048 # optional, per personal space
|
|
8
|
+
|
|
9
|
+
[spaces.payments]
|
|
10
|
+
members = ["ante", "bob"]
|
|
11
|
+
memory_limit_mb = 4096 # optional
|
|
12
|
+
labels = ["Memory", "MemoryVersion"] # optional; what members may write
|
|
13
|
+
edge_types = ["HAS_MEMORY"] # optional
|
|
14
|
+
|
|
15
|
+
Every member gets a personal space automatically; it isn't listed. A shared
|
|
16
|
+
space's ``labels``/``edge_types`` default to the curated memory the memory tool
|
|
17
|
+
writes (map #501: v1 shares curated memory only). They are data, not code, so
|
|
18
|
+
widening what a space holds is an edit here.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import re
|
|
24
|
+
import sys
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
if sys.version_info >= (3, 11):
|
|
30
|
+
import tomllib
|
|
31
|
+
else: # tomli is tomllib's backport
|
|
32
|
+
import tomli as tomllib
|
|
33
|
+
|
|
34
|
+
#: What members may create and change in a shared space by default: the
|
|
35
|
+
#: memory tool's files, their history, and what links them to the space and
|
|
36
|
+
#: project. Chunks and entities are left to the space's reconcile worker;
|
|
37
|
+
#: members may only drop a file's HAS_CHUNK links, which deleting the file does.
|
|
38
|
+
DEFAULT_SHARED_LABELS = ("Memory", "MemoryVersion", "User", "Project")
|
|
39
|
+
DEFAULT_SHARED_EDGE_TYPES = ("HAS_MEMORY", "HAS_MEMORY_VERSION", "PREVIOUS_VERSION", "ABOUT", "HAS_CHUNK")
|
|
40
|
+
|
|
41
|
+
# Logins and space names become database, role and user names in DDL, which
|
|
42
|
+
# Memgraph can't parameterize; this pattern is the guard that keeps them inert.
|
|
43
|
+
_NAME = re.compile(r"^[a-z][a-z0-9_]{0,39}$")
|
|
44
|
+
_LABEL = re.compile(r"^[A-Za-z][A-Za-z0-9_]{0,63}$")
|
|
45
|
+
#: Prefixes the provisioner owns; a member login can't take one.
|
|
46
|
+
RESERVED_PREFIXES = ("cg_", "space_", "u_")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class SpecError(ValueError):
|
|
50
|
+
"""The spec file is malformed or inconsistent."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class SharedSpace:
|
|
55
|
+
"""One shared space: its members and what they may write in it."""
|
|
56
|
+
|
|
57
|
+
name: str
|
|
58
|
+
members: tuple[str, ...]
|
|
59
|
+
labels: tuple[str, ...] = DEFAULT_SHARED_LABELS
|
|
60
|
+
edge_types: tuple[str, ...] = DEFAULT_SHARED_EDGE_TYPES
|
|
61
|
+
memory_limit_mb: int | None = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class Spec:
|
|
66
|
+
"""The whole desired state: members and shared spaces."""
|
|
67
|
+
|
|
68
|
+
members: tuple[str, ...]
|
|
69
|
+
spaces: tuple[SharedSpace, ...] = ()
|
|
70
|
+
personal_memory_limit_mb: int | None = None
|
|
71
|
+
|
|
72
|
+
def spaces_of(self, login: str) -> list[SharedSpace]:
|
|
73
|
+
"""The shared spaces *login* belongs to, in spec order."""
|
|
74
|
+
return [space for space in self.spaces if login in space.members]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def load_spec(path: str | Path) -> Spec:
|
|
78
|
+
"""Read and validate a spec file.
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
SpecError: the file isn't valid TOML or breaks a rule above.
|
|
82
|
+
"""
|
|
83
|
+
try:
|
|
84
|
+
data = tomllib.loads(Path(path).read_text(encoding="utf-8"))
|
|
85
|
+
except (OSError, tomllib.TOMLDecodeError) as exc:
|
|
86
|
+
raise SpecError(f"Can't read spec {path}: {exc}") from exc
|
|
87
|
+
return parse_spec(data)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def parse_spec(data: dict[str, Any]) -> Spec:
|
|
91
|
+
"""Validate a parsed spec mapping.
|
|
92
|
+
|
|
93
|
+
Raises:
|
|
94
|
+
SpecError: unknown keys, bad names, members of a space who aren't members, duplicate entries.
|
|
95
|
+
"""
|
|
96
|
+
_no_unknown_keys(data, {"members", "spaces", "personal_memory_limit_mb"}, "the spec")
|
|
97
|
+
members = _names(data.get("members", []), "members", login=True)
|
|
98
|
+
spaces = []
|
|
99
|
+
for name, body in (data.get("spaces") or {}).items():
|
|
100
|
+
where = f"[spaces.{name}]"
|
|
101
|
+
if not isinstance(body, dict):
|
|
102
|
+
raise SpecError(f"{where} must be a table")
|
|
103
|
+
_no_unknown_keys(body, {"members", "labels", "edge_types", "memory_limit_mb"}, where)
|
|
104
|
+
_check_name(name, where)
|
|
105
|
+
space_members = _names(body.get("members", []), f"{where} members", login=True)
|
|
106
|
+
if outsiders := sorted(set(space_members) - set(members)):
|
|
107
|
+
raise SpecError(f"{where} lists {', '.join(outsiders)}, who aren't in the top-level members")
|
|
108
|
+
spaces.append(
|
|
109
|
+
SharedSpace(
|
|
110
|
+
name=name,
|
|
111
|
+
members=space_members,
|
|
112
|
+
labels=_labels(body.get("labels", DEFAULT_SHARED_LABELS), f"{where} labels"),
|
|
113
|
+
edge_types=_labels(body.get("edge_types", DEFAULT_SHARED_EDGE_TYPES), f"{where} edge_types"),
|
|
114
|
+
memory_limit_mb=_limit(body.get("memory_limit_mb"), f"{where} memory_limit_mb"),
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
return Spec(
|
|
118
|
+
members=members,
|
|
119
|
+
spaces=tuple(spaces),
|
|
120
|
+
personal_memory_limit_mb=_limit(data.get("personal_memory_limit_mb"), "personal_memory_limit_mb"),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _no_unknown_keys(table: dict[str, Any], known: set[str], where: str) -> None:
|
|
125
|
+
if unknown := sorted(set(table) - known):
|
|
126
|
+
raise SpecError(f"Unknown key(s) in {where}: {', '.join(unknown)}")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _names(value: Any, where: str, *, login: bool) -> tuple[str, ...]:
|
|
130
|
+
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
|
|
131
|
+
raise SpecError(f"{where} must be a list of names")
|
|
132
|
+
for item in value:
|
|
133
|
+
_check_name(item, where)
|
|
134
|
+
if login and item.startswith(RESERVED_PREFIXES):
|
|
135
|
+
raise SpecError(f"{where}: {item!r} starts with a reserved prefix ({', '.join(RESERVED_PREFIXES)})")
|
|
136
|
+
if len(set(value)) != len(value):
|
|
137
|
+
raise SpecError(f"{where} lists a name twice")
|
|
138
|
+
return tuple(value)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _check_name(name: str, where: str) -> None:
|
|
142
|
+
if not _NAME.match(name):
|
|
143
|
+
raise SpecError(f"{where}: {name!r} must be lowercase letters, digits and _, starting with a letter")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _labels(value: Any, where: str) -> tuple[str, ...]:
|
|
147
|
+
if not isinstance(value, (list, tuple)) or not all(isinstance(item, str) and _LABEL.match(item) for item in value):
|
|
148
|
+
raise SpecError(f"{where} must be a list of label or edge-type names")
|
|
149
|
+
return tuple(value)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _limit(value: Any, where: str) -> int | None:
|
|
153
|
+
if value is None:
|
|
154
|
+
return None
|
|
155
|
+
if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
|
|
156
|
+
raise SpecError(f"{where} must be a positive whole number of megabytes")
|
|
157
|
+
return value
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""The shared-space reconcile worker: what members write, it extracts.
|
|
2
|
+
|
|
3
|
+
Members may only write curated memory into a shared space, so the derived
|
|
4
|
+
graph there (Chunks, entities, their relations, the memory passages recall
|
|
5
|
+
embeds) is written by one login, ``cg_service``, run by the org next to
|
|
6
|
+
``apply`` (map #501). Personal spaces keep reconciling locally, as before.
|
|
7
|
+
|
|
8
|
+
Each pass, per shared space in the spec:
|
|
9
|
+
|
|
10
|
+
1. Remove what deleted files left behind. A member's delete keeps its hands
|
|
11
|
+
off Chunks (it can't touch them), so their file's extraction outlives it.
|
|
12
|
+
2. Reconcile every file marked ``extraction_status = 'pending'`` with
|
|
13
|
+
sessions-graph's own per-file reconciliation (GLiNER2 under the space's
|
|
14
|
+
adopted ontology, no LLM).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from typing import TYPE_CHECKING, Any
|
|
22
|
+
|
|
23
|
+
from .provision import SERVICE_ACCOUNT, shared_database
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from sessions_graph import SessionsGraph
|
|
27
|
+
|
|
28
|
+
from memgraph_toolbox.api.memgraph import Memgraph
|
|
29
|
+
|
|
30
|
+
from .spec import Spec
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class SpaceReport:
|
|
35
|
+
"""One shared space's pass: files reconciled or failed, and leftovers removed."""
|
|
36
|
+
|
|
37
|
+
space: str
|
|
38
|
+
completed: list[str] = field(default_factory=list)
|
|
39
|
+
failed: list[tuple[str, str]] = field(default_factory=list)
|
|
40
|
+
pruned: int = 0
|
|
41
|
+
error: str | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def reconcile_spaces(
|
|
45
|
+
spec: Spec,
|
|
46
|
+
*,
|
|
47
|
+
url: str,
|
|
48
|
+
password: str,
|
|
49
|
+
extraction_backend: Any = None,
|
|
50
|
+
limit: int = 100,
|
|
51
|
+
) -> list[SpaceReport]:
|
|
52
|
+
"""One pass over every shared space in *spec*, as the service login.
|
|
53
|
+
|
|
54
|
+
A space that can't be reached is reported and skipped; the others still run.
|
|
55
|
+
"""
|
|
56
|
+
from sessions_graph import SessionsGraph
|
|
57
|
+
|
|
58
|
+
from memgraph_toolbox.api.memgraph import Memgraph
|
|
59
|
+
|
|
60
|
+
reports = []
|
|
61
|
+
for space in spec.spaces:
|
|
62
|
+
report = SpaceReport(space.name)
|
|
63
|
+
client = None
|
|
64
|
+
try:
|
|
65
|
+
client = Memgraph(
|
|
66
|
+
url=url,
|
|
67
|
+
username=SERVICE_ACCOUNT,
|
|
68
|
+
password=password,
|
|
69
|
+
database=shared_database(space.name),
|
|
70
|
+
user_agent="context-graph-spaces-worker",
|
|
71
|
+
)
|
|
72
|
+
report.pruned = prune_deleted_extractions(client)
|
|
73
|
+
asyncio.run(_reconcile_pending(SessionsGraph(client), report, extraction_backend, limit))
|
|
74
|
+
# One space's failure is reported, and must not stop the others.
|
|
75
|
+
except Exception as exc:
|
|
76
|
+
report.error = f"{type(exc).__name__}: {exc}"
|
|
77
|
+
finally:
|
|
78
|
+
if client is not None:
|
|
79
|
+
client.close()
|
|
80
|
+
reports.append(report)
|
|
81
|
+
return reports
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def _reconcile_pending(graph: SessionsGraph, report: SpaceReport, backend: Any, limit: int) -> None:
|
|
85
|
+
for memory_id in graph.get_pending_memory_reconciliations(limit=limit):
|
|
86
|
+
result = await graph.reconcile_memory(memory_id, extraction_backend=backend)
|
|
87
|
+
if result.status == "completed":
|
|
88
|
+
report.completed.append(result.path or memory_id)
|
|
89
|
+
else:
|
|
90
|
+
report.failed.append((result.path or memory_id, result.error or "unknown error"))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def prune_deleted_extractions(db: Memgraph) -> int:
|
|
94
|
+
"""Remove the extraction of memory files that no longer exist; returns how many relationships went.
|
|
95
|
+
|
|
96
|
+
The same cleanup :func:`sessions_graph.memory_store.forget_extraction` does for a live file, keyed on
|
|
97
|
+
the provenance ids that no longer name a ``(:Memory)``: in a shared space every source is a memory file.
|
|
98
|
+
"""
|
|
99
|
+
live = [row["id"] for row in db.query("MATCH (m:Memory) RETURN m.memory_id AS id")]
|
|
100
|
+
relations = db.query(
|
|
101
|
+
"MATCH ()-[r]->() WHERE r.source_id IS NOT NULL AND NOT r.source_id IN $live "
|
|
102
|
+
"WITH r DELETE r RETURN count(*) AS n",
|
|
103
|
+
{"live": live},
|
|
104
|
+
)
|
|
105
|
+
db.query(
|
|
106
|
+
"""
|
|
107
|
+
MATCH (c:Chunk)<-[mention:MENTIONED_IN]-(e)
|
|
108
|
+
WITH c, mention, e, [s IN coalesce(mention.sources, []) WHERE s IN $live] AS kept
|
|
109
|
+
SET mention.sources = kept
|
|
110
|
+
WITH c, mention, e, kept
|
|
111
|
+
WHERE size(kept) = 0
|
|
112
|
+
DELETE mention
|
|
113
|
+
WITH DISTINCT e
|
|
114
|
+
WHERE NOT EXISTS { MATCH (e)-[:MENTIONED_IN]->() }
|
|
115
|
+
DETACH DELETE e
|
|
116
|
+
""",
|
|
117
|
+
{"live": live},
|
|
118
|
+
)
|
|
119
|
+
db.query(
|
|
120
|
+
"""
|
|
121
|
+
MATCH (c:Chunk)
|
|
122
|
+
WHERE NOT EXISTS { MATCH (c)<-[:HAS_CHUNK]-() } AND NOT EXISTS { MATCH (c)<-[:MENTIONED_IN]-() }
|
|
123
|
+
DETACH DELETE c
|
|
124
|
+
"""
|
|
125
|
+
)
|
|
126
|
+
return relations[0]["n"] if relations else 0
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: context-graph-spaces
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The Enterprise edition of Context Graph: personal and shared memory spaces for many users on one Memgraph
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Requires-Dist: memgraph-context-graph>=0.5.0
|
|
13
|
+
Requires-Dist: memgraph-toolbox
|
|
14
|
+
Requires-Dist: neo4j>=5.0
|
|
15
|
+
Requires-Dist: sessions-graph[reconciliation]>=0.6.0
|
|
16
|
+
Requires-Dist: tomli>=2.0
|
|
17
|
+
Provides-Extra: test
|
|
18
|
+
Requires-Dist: pytest>=9.0.3; extra == 'test'
|
|
19
|
+
Requires-Dist: sessions-graph[memgraph-context-graph,reconciliation]>=0.8.0; extra == 'test'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# context-graph-spaces
|
|
23
|
+
|
|
24
|
+
The Enterprise edition of [Context Graph](../README.md): memory for many users on
|
|
25
|
+
one shared, licensed Memgraph. Each user gets a **personal space**, and teams get
|
|
26
|
+
**shared spaces**. Memgraph Enterprise enforces the isolation through
|
|
27
|
+
multi-tenancy, role-based access control and label- and property-based access
|
|
28
|
+
control. Our code doesn't filter.
|
|
29
|
+
|
|
30
|
+
The Community edition (one user, one database) doesn't need this package and
|
|
31
|
+
doesn't change when it's installed. A user's config file selects Enterprise
|
|
32
|
+
explicitly:
|
|
33
|
+
|
|
34
|
+
```toml
|
|
35
|
+
edition = "enterprise"
|
|
36
|
+
|
|
37
|
+
[memgraph]
|
|
38
|
+
url = "bolt://memgraph.corp:7687"
|
|
39
|
+
user = "ante" # your Memgraph login is your identity
|
|
40
|
+
password = "..."
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## For admins: provisioning
|
|
44
|
+
|
|
45
|
+
Spaces are declared in a spec file and applied idempotently:
|
|
46
|
+
|
|
47
|
+
```toml
|
|
48
|
+
members = ["ante", "bob"]
|
|
49
|
+
personal_memory_limit_mb = 2048
|
|
50
|
+
|
|
51
|
+
[spaces.payments]
|
|
52
|
+
members = ["ante", "bob"]
|
|
53
|
+
memory_limit_mb = 4096
|
|
54
|
+
# labels = [...] # what members may write; defaults to curated memory
|
|
55
|
+
# edge_types = [...]
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
export MEMGRAPH_URL=bolt://memgraph.corp:7687 MEMGRAPH_USER=<admin> MEMGRAPH_PASSWORD=...
|
|
60
|
+
context-graph-spaces plan spaces.toml # what would change
|
|
61
|
+
context-graph-spaces apply spaces.toml # make it so; prints one-time setup lines for new logins
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
What `apply` maintains:
|
|
65
|
+
|
|
66
|
+
| Object | Name | Who |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| Personal database | `u_<login>` | its owner (main database), plus nobody else |
|
|
69
|
+
| Shared database | `space_<name>` | members: read everything, write only the spec's labels |
|
|
70
|
+
| Roles | `cg_u_<login>_<hash>`, `space_<name>_member_<hash>`, `cg_worker_<hash>` | named after what they grant |
|
|
71
|
+
| Service login | `cg_service` | writes Chunks and entities in shared spaces |
|
|
72
|
+
| Tenant profiles | `cg_<mb>mb` | memory caps from the spec |
|
|
73
|
+
|
|
74
|
+
Removing someone from the spec revokes their access. Removing a space revokes
|
|
75
|
+
access to it. Users and databases are only dropped with `--prune`. Only objects
|
|
76
|
+
with the `u_`, `space_` and `cg_` prefixes are ever touched.
|
|
77
|
+
|
|
78
|
+
## The shared-space worker
|
|
79
|
+
|
|
80
|
+
Members can write only curated memory into a shared space. The derived graph
|
|
81
|
+
there (chunks, entities, relations, and the embeddings recall uses) is written by
|
|
82
|
+
one service login, `cg_service`. Run the worker next to `apply`, with the
|
|
83
|
+
password `apply` printed once:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
export CG_SERVICE_PASSWORD=...
|
|
87
|
+
context-graph-spaces reconcile spaces.toml --watch # one pass every 30s
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Each pass, for each shared space, it does two things:
|
|
91
|
+
|
|
92
|
+
1. It removes what deleted files left behind. A member's delete can't touch chunks.
|
|
93
|
+
2. It reconciles changed memory files with GLiNER2 under the space's ontology.
|
|
94
|
+
No LLM is involved.
|
|
95
|
+
|
|
96
|
+
Personal spaces keep reconciling locally, as in Community.
|
|
97
|
+
|
|
98
|
+
## Tests
|
|
99
|
+
|
|
100
|
+
The tests run end-to-end against a licensed Memgraph. The licence is read from
|
|
101
|
+
`MEMGRAPH_ENTERPRISE_LICENSE` and `MEMGRAPH_ORGANIZATION_NAME` (the repo's `.env`
|
|
102
|
+
locally, secrets in CI):
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
./scripts/dev-memgraph.sh test-enterprise
|
|
106
|
+
```
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
context_graph_spaces/__init__.py,sha256=v4yE-LH5HO9_67ndC0Ld_yqq7Kn3pXpHG9zMQZy0Vas,629
|
|
2
|
+
context_graph_spaces/cli.py,sha256=-nwfVKm4nsIjjN7HKo3apP4PBIxfVRR_fWWM3WYRDno,5649
|
|
3
|
+
context_graph_spaces/provision.py,sha256=v4wMb30m_OXb4aJ2aSzCTqZWfgIolsWCQbTpoz5WHf4,18790
|
|
4
|
+
context_graph_spaces/resolver.py,sha256=T_1_HJjfE3cn1H2osGZZjwagCgQOl9FuUD-yvVxcGSQ,5164
|
|
5
|
+
context_graph_spaces/spec.py,sha256=gdXn8QUHl22qFsbhewXUM7B8-LEzq4sz0nmjBMbIuYU,6231
|
|
6
|
+
context_graph_spaces/worker.py,sha256=Z3M6r8dgnVCPn8AVz55cT4g83jVYSoOioaqrFbAeg4w,4477
|
|
7
|
+
context_graph_spaces-0.1.0.dist-info/METADATA,sha256=y3sFG1CMEQuA1RxaNr__efT6tgwaW-x7IsZrPxCs0Qc,3793
|
|
8
|
+
context_graph_spaces-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
9
|
+
context_graph_spaces-0.1.0.dist-info/entry_points.txt,sha256=O85B0thfaYurm7g6lovSMNNusOHd2x9BYycoNNUGopI,166
|
|
10
|
+
context_graph_spaces-0.1.0.dist-info/licenses/LICENSE,sha256=QFsrXi0IKm1YS_3TeJb28R7alTFl6_f4B-JzRlnqcTg,1065
|
|
11
|
+
context_graph_spaces-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Memgraph
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|