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/__init__.py +3 -0
- curu_memory/__main__.py +1032 -0
- curu_memory/access.py +562 -0
- curu_memory/audit.py +63 -0
- curu_memory/auth.py +41 -0
- curu_memory/browser.py +396 -0
- curu_memory/config.py +308 -0
- curu_memory/embedders.py +118 -0
- curu_memory/errors.py +96 -0
- curu_memory/indexing.py +440 -0
- curu_memory/media.py +199 -0
- curu_memory/oidc.py +437 -0
- curu_memory/okf.py +271 -0
- curu_memory/ports.py +344 -0
- curu_memory/render.py +61 -0
- curu_memory/retrieval.py +570 -0
- curu_memory/server.py +272 -0
- curu_memory/store_sqlite.py +551 -0
- curu_memory-0.2.0.dist-info/METADATA +723 -0
- curu_memory-0.2.0.dist-info/RECORD +23 -0
- curu_memory-0.2.0.dist-info/WHEEL +4 -0
- curu_memory-0.2.0.dist-info/entry_points.txt +2 -0
- curu_memory-0.2.0.dist-info/licenses/LICENSE +21 -0
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)
|