curu-memory 0.2.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.
curu_memory/access.py ADDED
@@ -0,0 +1,562 @@
1
+ """The access store, the roles and the readable spaces of a principal (ADR-016).
2
+
3
+ `access.sqlite` in the data directory holds the persons, the teams and the members. It
4
+ holds no token and no secret. `role_in` is the one place that gives a role, so each check
5
+ of each transport agrees.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import contextlib
11
+ import sqlite3
12
+ import threading
13
+ from collections.abc import Generator, Sequence
14
+ from dataclasses import dataclass
15
+ from datetime import UTC, datetime, timedelta
16
+ from pathlib import Path
17
+
18
+ from curu_memory.ports import GLOBAL, IndexStore, parse_space
19
+
20
+ READ = 1
21
+ WRITE = 2
22
+ ADMIN = 3
23
+ ROLE_NAMES = {READ: "read", WRITE: "write", ADMIN: "admin"}
24
+
25
+ _SCHEMA = """
26
+ CREATE TABLE IF NOT EXISTS persons (handle TEXT PRIMARY KEY, owner INTEGER NOT NULL);
27
+ CREATE TABLE IF NOT EXISTS teams (slug TEXT PRIMARY KEY);
28
+ CREATE TABLE IF NOT EXISTS members (
29
+ slug TEXT NOT NULL REFERENCES teams (slug) ON DELETE CASCADE,
30
+ handle TEXT NOT NULL REFERENCES persons (handle) ON DELETE CASCADE,
31
+ PRIMARY KEY (slug, handle)
32
+ );
33
+ CREATE TABLE IF NOT EXISTS agents (
34
+ name TEXT PRIMARY KEY,
35
+ person TEXT NOT NULL REFERENCES persons (handle) ON DELETE CASCADE,
36
+ issuer TEXT NOT NULL,
37
+ subject TEXT,
38
+ client_id TEXT
39
+ );
40
+ CREATE UNIQUE INDEX IF NOT EXISTS agents_by_subject ON agents (issuer, subject);
41
+ CREATE UNIQUE INDEX IF NOT EXISTS agents_by_client ON agents (issuer, client_id);
42
+ CREATE TABLE IF NOT EXISTS grants (
43
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
44
+ agent TEXT NOT NULL REFERENCES agents (name) ON DELETE CASCADE,
45
+ space TEXT NOT NULL,
46
+ role INTEGER NOT NULL,
47
+ starts TEXT NOT NULL,
48
+ ends TEXT NOT NULL,
49
+ revoked TEXT
50
+ );
51
+ """
52
+
53
+ #: The default and the longest life of a grant, in days (ADR-016).
54
+ DEFAULT_GRANT_DAYS = 90
55
+ MAX_GRANT_DAYS = 366
56
+
57
+
58
+ class AccessRefused(Exception):
59
+ """The rules refuse a change of the access store. The message says why."""
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class Person:
64
+ handle: str
65
+ owner: bool
66
+ #: The OIDC issuer and subject of the person, or None (spec 012).
67
+ issuer: str | None = None
68
+ subject: str | None = None
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class Agent:
73
+ """A program that acts for one person, with grants that the person issues (ADR-018)."""
74
+
75
+ name: str
76
+ person: str
77
+ issuer: str
78
+ subject: str | None = None
79
+ client_id: str | None = None
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class Grant:
84
+ id: int
85
+ agent: str
86
+ space: str
87
+ role: int
88
+ starts: datetime
89
+ ends: datetime
90
+ revoked: datetime | None = None
91
+
92
+ def live(self, now: datetime) -> bool:
93
+ return self.revoked is None and self.starts <= now < self.ends
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class Principal:
98
+ """The caller of a request: a person, an agent, or anonymous with no handle.
99
+
100
+ For an agent, `handle` is the person that it acts for, and `agent` is its name.
101
+ """
102
+
103
+ handle: str | None
104
+ agent: str | None = None
105
+
106
+ @classmethod
107
+ def person(cls, handle: str) -> Principal:
108
+ return cls(handle)
109
+
110
+ @classmethod
111
+ def for_agent(cls, name: str, acts_for: str) -> Principal:
112
+ return cls(acts_for, name)
113
+
114
+ @property
115
+ def label(self) -> str:
116
+ """The name of the principal in the audit log."""
117
+ if self.agent:
118
+ return f"agent:{self.agent}"
119
+ return f"person:{self.handle}" if self.handle else "anonymous"
120
+
121
+ @property
122
+ def acts_for(self) -> str | None:
123
+ """The person that an agent acts for. None for a person and for anonymous."""
124
+ return self.handle if self.agent else None
125
+
126
+
127
+ ANONYMOUS = Principal(None)
128
+
129
+
130
+ def check_handle(handle: str, kind: str = "handle") -> str:
131
+ """A handle or a team slug: 1 to 40 characters from a to z, 0 to 9 and -, first a letter."""
132
+ try:
133
+ parse_space(f"personal/{handle}")
134
+ except ValueError:
135
+ raise ValueError(
136
+ f"the {kind} {handle!r} is not valid. A {kind} has 1 to 40 characters "
137
+ "from a to z, 0 to 9 and -, and the first character is a letter."
138
+ ) from None
139
+ return handle
140
+
141
+
142
+ def role_in(person: Person | None, space: str, teams: Sequence[str]) -> int | None:
143
+ """The role of a person in a space, from the table of ADR-016, or None.
144
+
145
+ `teams` holds the slugs of the teams of the person.
146
+ """
147
+ if person is None:
148
+ return None
149
+ if space == GLOBAL:
150
+ return ADMIN if person.owner else READ
151
+ tier, _, name = space.partition("/")
152
+ if tier == "team":
153
+ if person.owner:
154
+ return ADMIN
155
+ return WRITE if name in teams else None
156
+ if tier == "personal":
157
+ # Only the person reads a personal space. An owner does not (ADR-016).
158
+ return ADMIN if name == person.handle else None
159
+ return None
160
+
161
+
162
+ def readable_spaces_of(
163
+ principal: Principal, access: AccessStore, index: IndexStore
164
+ ) -> tuple[str, ...]:
165
+ """The spaces where the principal has a role, in name order.
166
+
167
+ With no principal, the caller reads `global` only (research R-3). A handle that is
168
+ not registered, for example a removed person, reads nothing.
169
+ """
170
+ if principal.handle is None:
171
+ return (GLOBAL,)
172
+ if principal.agent is not None:
173
+ return _agent_spaces(principal.agent, access)
174
+ person = access.person(principal.handle)
175
+ if person is None:
176
+ return ()
177
+ teams = access.teams_of(person.handle)
178
+ candidates = {GLOBAL, f"personal/{person.handle}", *index.spaces()}
179
+ candidates.update(f"team/{slug}" for slug in access.teams())
180
+ return tuple(sorted(s for s in candidates if role_in(person, s, teams) is not None))
181
+
182
+
183
+ def _agent_spaces(name: str, access: AccessStore) -> tuple[str, ...]:
184
+ agent = access.agent(name)
185
+ if agent is None:
186
+ return ()
187
+ now = _now()
188
+ spaces = {g.space for g in access.grants(agent=name) if g.live(now)}
189
+ return tuple(sorted(s for s in spaces if role_of_agent(agent, s, access, now=now)))
190
+
191
+
192
+ def role_of_agent(
193
+ agent: Agent, space: str, access: AccessStore, *, now: datetime | None = None
194
+ ) -> int | None:
195
+ """The role of an agent in a space: the weaker of its strongest live grant there and
196
+ the current role of its person (ADR-016, ADR-018)."""
197
+ now = now or _now()
198
+ roles = [g.role for g in access.grants(agent=agent.name) if g.space == space and g.live(now)]
199
+ if not roles:
200
+ return None
201
+ person = access.person(agent.person)
202
+ own = role_in(person, space, access.teams_of(agent.person)) if person else None
203
+ if own is None:
204
+ return None
205
+ return min(max(roles), own)
206
+
207
+
208
+ def _now() -> datetime:
209
+ return datetime.now(UTC)
210
+
211
+
212
+ def hidden_space(
213
+ index: IndexStore,
214
+ readable: Sequence[str],
215
+ *,
216
+ concept_id: str | None = None,
217
+ sha256: str | None = None,
218
+ ) -> str | None:
219
+ """The space that holds a concept or a media file that the caller cannot read, or None.
220
+
221
+ Only the audit log uses it. The caller gets the answer for a missing concept.
222
+ """
223
+ for space in index.spaces():
224
+ if space in readable:
225
+ continue
226
+ if concept_id is not None and index.get([space], concept_id) is not None:
227
+ return space
228
+ if sha256 is not None and index.media_bytes([space], sha256) is not None:
229
+ return space
230
+ return None
231
+
232
+
233
+ class AccessStore:
234
+ """The persons, the teams and the members, in `access.sqlite` of the data directory."""
235
+
236
+ def __init__(self, data_dir: Path) -> None:
237
+ self._path = Path(data_dir) / "access.sqlite"
238
+ self._lock = threading.Lock()
239
+
240
+ @property
241
+ def path(self) -> Path:
242
+ return self._path
243
+
244
+ def _connect(self) -> sqlite3.Connection:
245
+ self._path.parent.mkdir(parents=True, exist_ok=True)
246
+ connection = sqlite3.connect(self._path)
247
+ connection.execute("PRAGMA foreign_keys = ON")
248
+ connection.executescript(_SCHEMA)
249
+ columns = {row[1] for row in connection.execute("PRAGMA table_info(persons)")}
250
+ if "issuer" not in columns:
251
+ # A store of spec 011 gets the subject link of spec 012 at its next open.
252
+ connection.executescript(
253
+ "ALTER TABLE persons ADD COLUMN issuer TEXT;"
254
+ "ALTER TABLE persons ADD COLUMN subject TEXT;"
255
+ )
256
+ connection.execute(
257
+ "CREATE UNIQUE INDEX IF NOT EXISTS persons_by_subject ON persons (issuer, subject)"
258
+ )
259
+ return connection
260
+
261
+ def _read(self, sql: str, parameters: Sequence[object] = ()) -> list[tuple]:
262
+ # A store that does not exist has no person, so a read makes no file.
263
+ if not self._path.exists():
264
+ return []
265
+ with self._lock:
266
+ connection = self._connect()
267
+ try:
268
+ return connection.execute(sql, parameters).fetchall()
269
+ finally:
270
+ connection.close()
271
+
272
+ @contextlib.contextmanager
273
+ def _transaction(self) -> Generator[sqlite3.Connection]:
274
+ """One write transaction. BEGIN IMMEDIATE holds the write lock from the first check,
275
+ so the checks and the change of two processes do not mix (review of spec 011)."""
276
+ with self._lock:
277
+ connection = self._connect()
278
+ connection.isolation_level = None
279
+ try:
280
+ connection.execute("BEGIN IMMEDIATE")
281
+ try:
282
+ yield connection
283
+ except BaseException:
284
+ connection.execute("ROLLBACK")
285
+ raise
286
+ connection.execute("COMMIT")
287
+ except sqlite3.IntegrityError as error:
288
+ raise AccessRefused(
289
+ f"The change conflicts with the access store: {error}."
290
+ ) from None
291
+ finally:
292
+ connection.close()
293
+
294
+ # -- reads ---------------------------------------------------------------
295
+
296
+ def persons(self) -> list[Person]:
297
+ rows = self._read(f"SELECT {_PERSON} FROM persons ORDER BY handle")
298
+ return [_person(row) for row in rows]
299
+
300
+ def person(self, handle: str) -> Person | None:
301
+ rows = self._read(f"SELECT {_PERSON} FROM persons WHERE handle = ?", (handle,))
302
+ return _person(rows[0]) if rows else None
303
+
304
+ def person_by_subject(self, issuer: str, subject: str) -> Person | None:
305
+ """The person of a token: its `iss` and `sub` (ADR-015)."""
306
+ rows = self._read(
307
+ f"SELECT {_PERSON} FROM persons WHERE issuer = ? AND subject = ?", (issuer, subject)
308
+ )
309
+ return _person(rows[0]) if rows else None
310
+
311
+ def teams(self) -> tuple[str, ...]:
312
+ return tuple(row[0] for row in self._read("SELECT slug FROM teams ORDER BY slug"))
313
+
314
+ def members(self, slug: str) -> tuple[str, ...]:
315
+ rows = self._read("SELECT handle FROM members WHERE slug = ? ORDER BY handle", (slug,))
316
+ return tuple(row[0] for row in rows)
317
+
318
+ def teams_of(self, handle: str) -> tuple[str, ...]:
319
+ rows = self._read("SELECT slug FROM members WHERE handle = ? ORDER BY slug", (handle,))
320
+ return tuple(row[0] for row in rows)
321
+
322
+ # -- changes -------------------------------------------------------------
323
+
324
+ def add_person(
325
+ self,
326
+ handle: str,
327
+ *,
328
+ owner: bool = False,
329
+ issuer: str | None = None,
330
+ subject: str | None = None,
331
+ ) -> Person:
332
+ check_handle(handle)
333
+ with self._transaction() as db:
334
+ if _one(db, "SELECT 1 FROM persons WHERE handle = ?", handle):
335
+ raise AccessRefused(f"The person {handle!r} exists.")
336
+ _check_subject_free(db, issuer, subject)
337
+ # The first person is an owner, so the instance always has one (ADR-016).
338
+ owner = owner or not _one(db, "SELECT 1 FROM persons")
339
+ db.execute(
340
+ "INSERT INTO persons (handle, owner, issuer, subject) VALUES (?, ?, ?, ?)",
341
+ (handle, int(owner), issuer, subject),
342
+ )
343
+ return Person(handle, owner, issuer, subject)
344
+
345
+ def link_person(self, handle: str, issuer: str, subject: str) -> None:
346
+ """Give a person the `iss` and `sub` of their tokens. It replaces an old link."""
347
+ with self._transaction() as db:
348
+ if not _one(db, "SELECT 1 FROM persons WHERE handle = ?", handle):
349
+ raise AccessRefused(f"No person has the handle {handle!r}.")
350
+ _check_subject_free(db, issuer, subject, handle)
351
+ db.execute(
352
+ "UPDATE persons SET issuer = ?, subject = ? WHERE handle = ?",
353
+ (issuer, subject, handle),
354
+ )
355
+
356
+ def remove_person(self, handle: str) -> None:
357
+ with self._transaction() as db:
358
+ row = _one(db, "SELECT owner FROM persons WHERE handle = ?", handle)
359
+ if row is None:
360
+ raise AccessRefused(f"No person has the handle {handle!r}.")
361
+ owners = _one(db, "SELECT count(*) FROM persons WHERE owner = 1")
362
+ if row[0] and owners is not None and owners[0] == 1:
363
+ raise AccessRefused(
364
+ f"{handle!r} is the last instance owner. Add another owner first."
365
+ )
366
+ db.execute("DELETE FROM persons WHERE handle = ?", (handle,))
367
+
368
+ def add_team(self, slug: str) -> None:
369
+ check_handle(slug, "slug")
370
+ with self._transaction() as db:
371
+ if _one(db, "SELECT 1 FROM teams WHERE slug = ?", slug):
372
+ raise AccessRefused(f"The team {slug!r} exists.")
373
+ db.execute("INSERT INTO teams VALUES (?)", (slug,))
374
+
375
+ def remove_team(self, slug: str) -> None:
376
+ with self._transaction() as db:
377
+ if not _one(db, "SELECT 1 FROM teams WHERE slug = ?", slug):
378
+ raise AccessRefused(f"No team has the slug {slug!r}.")
379
+ db.execute("DELETE FROM teams WHERE slug = ?", (slug,))
380
+
381
+ def add_member(self, slug: str, handle: str) -> None:
382
+ with self._transaction() as db:
383
+ if not _one(db, "SELECT 1 FROM teams WHERE slug = ?", slug):
384
+ raise AccessRefused(f"No team has the slug {slug!r}. Add the team first.")
385
+ if not _one(db, "SELECT 1 FROM persons WHERE handle = ?", handle):
386
+ raise AccessRefused(f"No person has the handle {handle!r}. Add the person first.")
387
+ if _one(db, "SELECT 1 FROM members WHERE slug = ? AND handle = ?", slug, handle):
388
+ raise AccessRefused(f"{handle!r} is already a member of {slug!r}.")
389
+ db.execute("INSERT INTO members VALUES (?, ?)", (slug, handle))
390
+
391
+ # -- agents and grants (spec 013) -------------------------------------------
392
+
393
+ def agents(self) -> list[Agent]:
394
+ rows = self._read(f"SELECT {_AGENT} FROM agents ORDER BY name")
395
+ return [Agent(*row) for row in rows]
396
+
397
+ def agent(self, name: str) -> Agent | None:
398
+ rows = self._read(f"SELECT {_AGENT} FROM agents WHERE name = ?", (name,))
399
+ return Agent(*rows[0]) if rows else None
400
+
401
+ def agent_by_subject(self, issuer: str, subject: str) -> Agent | None:
402
+ rows = self._read(
403
+ f"SELECT {_AGENT} FROM agents WHERE issuer = ? AND subject = ?", (issuer, subject)
404
+ )
405
+ return Agent(*rows[0]) if rows else None
406
+
407
+ def agent_by_client(self, issuer: str, client_id: str) -> Agent | None:
408
+ rows = self._read(
409
+ f"SELECT {_AGENT} FROM agents WHERE issuer = ? AND client_id = ?", (issuer, client_id)
410
+ )
411
+ return Agent(*rows[0]) if rows else None
412
+
413
+ def add_agent(
414
+ self,
415
+ name: str,
416
+ *,
417
+ person: str,
418
+ issuer: str,
419
+ subject: str | None = None,
420
+ client_id: str | None = None,
421
+ ) -> Agent:
422
+ check_handle(name, "agent name")
423
+ if (subject is None) == (client_id is None):
424
+ raise AccessRefused("Give the agent a subject or a client identifier, not both.")
425
+ with self._transaction() as db:
426
+ if _one(db, "SELECT 1 FROM agents WHERE name = ?", name):
427
+ raise AccessRefused(f"The agent {name!r} exists.")
428
+ if not _one(db, "SELECT 1 FROM persons WHERE handle = ?", person):
429
+ raise AccessRefused(f"No person has the handle {person!r}. Add the person first.")
430
+ if subject is not None:
431
+ _check_subject_free(db, issuer, subject)
432
+ if client_id is not None and _one(
433
+ db, "SELECT 1 FROM agents WHERE issuer = ? AND client_id = ?", issuer, client_id
434
+ ):
435
+ raise AccessRefused(f"Another agent has the client {client_id!r} of this issuer.")
436
+ db.execute(
437
+ "INSERT INTO agents (name, person, issuer, subject, client_id) "
438
+ "VALUES (?, ?, ?, ?, ?)",
439
+ (name, person, issuer, subject, client_id),
440
+ )
441
+ return Agent(name, person, issuer, subject, client_id)
442
+
443
+ def remove_agent(self, name: str) -> None:
444
+ with self._transaction() as db:
445
+ if not _one(db, "SELECT 1 FROM agents WHERE name = ?", name):
446
+ raise AccessRefused(f"No agent has the name {name!r}.")
447
+ db.execute("DELETE FROM agents WHERE name = ?", (name,))
448
+
449
+ def grants(self, *, agent: str | None = None) -> list[Grant]:
450
+ sql = "SELECT id, agent, space, role, starts, ends, revoked FROM grants"
451
+ rows = (
452
+ self._read(sql + " WHERE agent = ? ORDER BY id", (agent,))
453
+ if agent is not None
454
+ else self._read(sql + " ORDER BY id")
455
+ )
456
+ return [_grant(row) for row in rows]
457
+
458
+ def add_grant(
459
+ self,
460
+ agent: str,
461
+ *,
462
+ space: str,
463
+ role: int,
464
+ days: int = DEFAULT_GRANT_DAYS,
465
+ now: datetime | None = None,
466
+ ) -> Grant:
467
+ """Give an agent one role in one space. It cannot be stronger than its person."""
468
+ if not 1 <= days <= MAX_GRANT_DAYS:
469
+ raise ValueError(f"a grant lasts from 1 to {MAX_GRANT_DAYS} days, not {days}")
470
+ if role not in ROLE_NAMES:
471
+ raise ValueError(f"the role {role!r} is not read, write or admin")
472
+ parse_space(space)
473
+ found = self.agent(agent)
474
+ if found is None:
475
+ raise AccessRefused(f"No agent has the name {agent!r}.")
476
+ person = self.person(found.person)
477
+ own = role_in(person, space, self.teams_of(found.person)) if person else None
478
+ if own is None or role > own:
479
+ raise AccessRefused(
480
+ f"The person {found.person!r} has no role {ROLE_NAMES[role]!r} in {space}, "
481
+ "and a grant cannot give more than its person has."
482
+ )
483
+ starts = now or _now()
484
+ ends = starts + timedelta(days=days)
485
+ with self._transaction() as db:
486
+ cursor = db.execute(
487
+ "INSERT INTO grants (agent, space, role, starts, ends) VALUES (?, ?, ?, ?, ?)",
488
+ (agent, space, role, starts.isoformat(), ends.isoformat()),
489
+ )
490
+ grant_id = int(cursor.lastrowid or 0)
491
+ return Grant(grant_id, agent, space, role, starts, ends)
492
+
493
+ def revoke_grant(self, grant_id: int, *, now: datetime | None = None) -> Grant:
494
+ with self._transaction() as db:
495
+ row = _one(
496
+ db,
497
+ "SELECT id, agent, space, role, starts, ends, revoked FROM grants WHERE id = ?",
498
+ grant_id,
499
+ )
500
+ if row is None:
501
+ raise AccessRefused(f"No grant has the identifier {grant_id}.")
502
+ when = (now or _now()).isoformat()
503
+ db.execute(
504
+ "UPDATE grants SET revoked = COALESCE(revoked, ?) WHERE id = ?", (when, grant_id)
505
+ )
506
+ return _grant(row)
507
+
508
+ def remove_member(self, slug: str, handle: str) -> None:
509
+ with self._transaction() as db:
510
+ if not _one(db, "SELECT 1 FROM members WHERE slug = ? AND handle = ?", slug, handle):
511
+ raise AccessRefused(f"{handle!r} is not a member of {slug!r}.")
512
+ db.execute("DELETE FROM members WHERE slug = ? AND handle = ?", (slug, handle))
513
+
514
+
515
+ _PERSON = "handle, owner, issuer, subject"
516
+
517
+
518
+ _AGENT = "name, person, issuer, subject, client_id"
519
+
520
+
521
+ def _grant(row: Sequence[object]) -> Grant:
522
+ grant_id, agent, space, role, starts, ends, revoked = row
523
+ return Grant(
524
+ int(str(grant_id)),
525
+ str(agent),
526
+ str(space),
527
+ int(str(role)),
528
+ datetime.fromisoformat(str(starts)),
529
+ datetime.fromisoformat(str(ends)),
530
+ datetime.fromisoformat(str(revoked)) if revoked else None,
531
+ )
532
+
533
+
534
+ def _person(row: Sequence[object]) -> Person:
535
+ handle, owner, issuer, subject = row
536
+ return Person(
537
+ str(handle),
538
+ bool(owner),
539
+ None if issuer is None else str(issuer),
540
+ None if subject is None else str(subject),
541
+ )
542
+
543
+
544
+ def _check_subject_free(
545
+ db: sqlite3.Connection, issuer: str | None, subject: str | None, handle: str | None = None
546
+ ) -> None:
547
+ """Refuse a pair of issuer and subject that another person has. `handle` is the person
548
+ that gets the pair: a pair that this person already has is free for it."""
549
+ if (issuer is None) != (subject is None):
550
+ raise AccessRefused("Give the issuer and the subject together.")
551
+ owner = _one(db, "SELECT handle FROM persons WHERE issuer = ? AND subject = ?", issuer, subject)
552
+ if issuer is not None and owner is not None and owner[0] != handle:
553
+ raise AccessRefused(f"Another person has the subject {subject!r} of this issuer.")
554
+ # A subject of an agent is not free for a person either (ADR-017, ADR-018).
555
+ if issuer is not None and _one(
556
+ db, "SELECT 1 FROM agents WHERE issuer = ? AND subject = ?", issuer, subject
557
+ ):
558
+ raise AccessRefused(f"An agent has the subject {subject!r} of this issuer.")
559
+
560
+
561
+ def _one(connection: sqlite3.Connection, sql: str, *parameters: object) -> tuple | None:
562
+ return connection.execute(sql, parameters).fetchone()
curu_memory/audit.py ADDED
@@ -0,0 +1,63 @@
1
+ """The audit log: one JSON line for each change of access and each refused read (ADR-016).
2
+
3
+ A line never holds a token, a question, a concept identifier, a title or media. Each
4
+ line goes to the file with one write in append mode, so the lines of two processes do
5
+ not mix (research R-5).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ import os
13
+ from datetime import UTC, datetime
14
+ from pathlib import Path
15
+
16
+ _LOG = logging.getLogger(__name__)
17
+ FILE_NAME = "audit.jsonl"
18
+ CHANGED = "changed"
19
+ REFUSED = "refused"
20
+
21
+
22
+ class AuditLog:
23
+ def __init__(self, data_dir: Path) -> None:
24
+ self._path = Path(data_dir) / FILE_NAME
25
+
26
+ @property
27
+ def path(self) -> Path:
28
+ return self._path
29
+
30
+ def write(
31
+ self,
32
+ principal: str,
33
+ action: str,
34
+ space: str | None,
35
+ outcome: str,
36
+ reason: str,
37
+ *,
38
+ subject: str | None = None,
39
+ acts_for: str | None = None,
40
+ ) -> None:
41
+ """Add one line. `subject` is the handle or the slug of a change, never content."""
42
+ entry = {
43
+ "time": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
44
+ "principal": principal,
45
+ "acts_for": acts_for,
46
+ "action": action,
47
+ "space": space,
48
+ "outcome": outcome,
49
+ "reason": reason,
50
+ "subject": subject,
51
+ }
52
+ line = (json.dumps(entry, sort_keys=True, ensure_ascii=False) + "\n").encode()
53
+ try:
54
+ self._path.parent.mkdir(parents=True, exist_ok=True)
55
+ descriptor = os.open(self._path, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600)
56
+ try:
57
+ os.write(descriptor, line)
58
+ finally:
59
+ os.close(descriptor)
60
+ except OSError as error:
61
+ # A failed write must not change the answer of the caller, because the
62
+ # answer for a hidden concept must be the answer for a missing one.
63
+ _LOG.error("curu-memory cannot write the audit log %s: %s", self._path, error)
curu_memory/auth.py ADDED
@@ -0,0 +1,41 @@
1
+ """The inbound bearer token of the HTTP transport (research R-9).
2
+
3
+ The SDK calls `StaticTokenVerifier.verify_token` for each HTTP request. When it
4
+ gives None, the SDK sends HTTP 401 with `WWW-Authenticate: Bearer`, and no tool runs.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hmac
10
+
11
+ from mcp.server.auth.provider import AccessToken
12
+ from mcp.server.auth.settings import AuthSettings
13
+
14
+ MIN_TOKEN_LENGTH = 32
15
+ CLIENT_ID = "curu-memory-operator"
16
+
17
+ # AuthSettings requires an issuer, but a static token has none. With no
18
+ # resource_server_url, the SDK publishes no metadata, so nobody reads this URL.
19
+ PLACEHOLDER_ISSUER = "http://localhost/curu-memory-static-token"
20
+
21
+
22
+ class StaticTokenVerifier:
23
+ """Accept one token, compared in constant time. The token never leaves this object."""
24
+
25
+ def __init__(self, token: str) -> None:
26
+ if len(token) < MIN_TOKEN_LENGTH:
27
+ raise ValueError(f"The inbound token must have {MIN_TOKEN_LENGTH} characters or more.")
28
+ self._token = token.encode()
29
+
30
+ def __repr__(self) -> str:
31
+ return "StaticTokenVerifier(token=<hidden>)"
32
+
33
+ async def verify_token(self, token: str) -> AccessToken | None:
34
+ if not hmac.compare_digest(token.encode(), self._token):
35
+ return None
36
+ # The SDK puts this object in the request context, so it holds no secret.
37
+ return AccessToken(token="static", client_id=CLIENT_ID, scopes=[])
38
+
39
+
40
+ def auth_settings() -> AuthSettings:
41
+ return AuthSettings(issuer_url=PLACEHOLDER_ISSUER, resource_server_url=None)