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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,5 @@
1
+ [console_scripts]
2
+ context-graph-spaces = context_graph_spaces.cli:main
3
+
4
+ [memgraph_context_graph.editions]
5
+ enterprise = context_graph_spaces.resolver:EnterpriseSpaces
@@ -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.