soliplex 0.70.2__tar.gz → 0.71__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. {soliplex-0.70.2/src/soliplex.egg-info → soliplex-0.71}/PKG-INFO +2 -2
  2. {soliplex-0.70.2 → soliplex-0.71}/pyproject.toml +2 -3
  3. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/agents.py +3 -3
  4. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/agui/__init__.py +5 -1
  5. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/agui/parser.py +4 -4
  6. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/agui/persistence.py +17 -31
  7. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/agui/schema.py +4 -4
  8. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/authz/__init__.py +129 -21
  9. soliplex-0.71/src/soliplex/authz/persistence.py +491 -0
  10. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/authz/schema.py +47 -24
  11. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/cli/admin_users.py +80 -117
  12. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/cli/audit.py +48 -44
  13. soliplex-0.71/src/soliplex/cli/cli_util.py +243 -0
  14. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/cli/main.py +0 -2
  15. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/cli/room_authz.py +185 -397
  16. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/cli/serve.py +18 -3
  17. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/config/exceptions.py +8 -0
  18. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/config/installation.py +65 -22
  19. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/config/meta.py +3 -7
  20. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/config/routing.py +3 -1
  21. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/config/skills.py +18 -36
  22. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/installation.py +22 -14
  23. soliplex-0.71/src/soliplex/loggers.py +640 -0
  24. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/main.py +15 -37
  25. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/models.py +53 -18
  26. soliplex-0.71/src/soliplex/rag_audit.py +125 -0
  27. soliplex-0.71/src/soliplex/sandbox_audit.py +65 -0
  28. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/skills/bwrap_sandbox/__init__.py +106 -19
  29. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/titles.py +9 -6
  30. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/tools/agui_run_feedback.py +3 -3
  31. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/tools/rag.py +18 -9
  32. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/views/__init__.py +40 -3
  33. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/views/agui.py +156 -101
  34. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/views/authz.py +22 -21
  35. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/views/installation.py +56 -9
  36. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/views/quizzes.py +19 -16
  37. soliplex-0.71/src/soliplex/views/room_file_uploads.py +206 -0
  38. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/views/rooms.py +51 -32
  39. soliplex-0.71/src/soliplex/views/sandbox_workdirs.py +139 -0
  40. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/views/stats.py +12 -12
  41. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/views/streaming.py +43 -23
  42. soliplex-0.71/src/soliplex/views/thread_file_uploads.py +202 -0
  43. {soliplex-0.70.2 → soliplex-0.71/src/soliplex.egg-info}/PKG-INFO +2 -2
  44. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex.egg-info/SOURCES.txt +6 -3
  45. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex.egg-info/requires.txt +1 -1
  46. soliplex-0.70.2/src/soliplex/auth.py +0 -10
  47. soliplex-0.70.2/src/soliplex/authz/persistence.py +0 -232
  48. soliplex-0.70.2/src/soliplex/cli/cli_util.py +0 -127
  49. soliplex-0.70.2/src/soliplex/loggers.py +0 -172
  50. soliplex-0.70.2/src/soliplex/views/file_uploads.py +0 -486
  51. {soliplex-0.70.2 → soliplex-0.71}/LICENSE +0 -0
  52. {soliplex-0.70.2 → soliplex-0.71}/MANIFEST.in +0 -0
  53. {soliplex-0.70.2 → soliplex-0.71}/README.md +0 -0
  54. {soliplex-0.70.2 → soliplex-0.71}/example/skills/bare-bones/SKILL.md +0 -0
  55. {soliplex-0.70.2 → soliplex-0.71}/setup.cfg +0 -0
  56. {soliplex-0.70.2 → soliplex-0.71}/skills/soliplex-docs/SKILL.md +0 -0
  57. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/agui/util.py +0 -0
  58. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/authn.py +0 -0
  59. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/cli/ollama.py +0 -0
  60. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/cli/types.py +0 -0
  61. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/completions.py +0 -0
  62. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/config/__init__.py +0 -0
  63. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/config/_utils.py +0 -0
  64. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/config/agents.py +0 -0
  65. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/config/agui.py +0 -0
  66. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/config/authsystem.py +0 -0
  67. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/config/completions.py +0 -0
  68. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/config/logfire.py +0 -0
  69. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/config/quizzes.py +0 -0
  70. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/config/rag.py +0 -0
  71. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/config/rooms.py +0 -0
  72. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/config/secrets.py +0 -0
  73. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/config/tools.py +0 -0
  74. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/examples.py +0 -0
  75. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/log_ingest.py +0 -0
  76. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/mcp_auth.py +0 -0
  77. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/mcp_client.py +0 -0
  78. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/mcp_server.py +0 -0
  79. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/ollama.py +0 -0
  80. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/quizzes.py +0 -0
  81. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/secrets.py +0 -0
  82. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/skills/bwrap_sandbox/SKILL.md +0 -0
  83. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/tools/__init__.py +0 -0
  84. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/tools/file_uploads.py +0 -0
  85. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/tui/cli.py +0 -0
  86. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/tui/main.py +0 -0
  87. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/tui/rest_api.py +0 -0
  88. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/tui/serve.py +0 -0
  89. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/util.py +0 -0
  90. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/views/authn.py +0 -0
  91. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/views/completions.py +0 -0
  92. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex/views/log_ingest.py +0 -0
  93. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex.egg-info/dependency_links.txt +0 -0
  94. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex.egg-info/entry_points.txt +0 -0
  95. {soliplex-0.70.2 → soliplex-0.71}/src/soliplex.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: soliplex
3
- Version: 0.70.2
3
+ Version: 0.71
4
4
  Summary: An AI-powered Retrieval-Augmented Generation (RAG) system with a modern web interface.
5
5
  Author-email: Enfold <info@enfoldsystems.net>
6
6
  Classifier: Programming Language :: Python :: 3
@@ -19,7 +19,7 @@ Requires-Dist: fastapi
19
19
  Requires-Dist: fastmcp<3.4,>=3.3.0
20
20
  Requires-Dist: fakeredis!=2.35.0
21
21
  Requires-Dist: greenlet
22
- Requires-Dist: haiku.rag-slim<0.64,>=0.63.1
22
+ Requires-Dist: haiku.rag-slim<0.65,>=0.64
23
23
  Requires-Dist: haiku-skills<0.19,>=0.18.0
24
24
  Requires-Dist: idna>=3.15
25
25
  Requires-Dist: itsdangerous
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "soliplex"
7
- version = "0.70.2"
7
+ version = "0.71"
8
8
  description = "An AI-powered Retrieval-Augmented Generation (RAG) system with a modern web interface."
9
9
  authors = [{ name = "Enfold", email = "info@enfoldsystems.net" }]
10
10
  readme = "README.md"
@@ -25,7 +25,7 @@ dependencies = [
25
25
  "fastmcp >= 3.3.0, < 3.4",
26
26
  "fakeredis != 2.35.0", # brownbag
27
27
  "greenlet",
28
- "haiku.rag-slim >= 0.63.1, < 0.64",
28
+ "haiku.rag-slim >= 0.64, < 0.65",
29
29
  "haiku-skills >= 0.18.0, < 0.19",
30
30
  "idna >= 3.15",
31
31
  "itsdangerous",
@@ -92,7 +92,6 @@ markers = [
92
92
  [tool.coverage.run]
93
93
  omit = [
94
94
  "src/soliplex/cli/__init__.py",
95
- "src/soliplex/cli/serve.py",
96
95
 
97
96
  "src/soliplex/examples.py",
98
97
  "src/soliplex/tui.py",
@@ -11,7 +11,7 @@ from pydantic_ai import agent as ai_agent
11
11
  from pydantic_ai import tools as ai_tools
12
12
  from pydantic_ai import toolsets as ai_toolsets
13
13
 
14
- from soliplex import agui as agui_package
14
+ from soliplex import agui
15
15
  from soliplex import mcp_client
16
16
  from soliplex import models
17
17
  from soliplex.config import agents as config_agents
@@ -41,8 +41,8 @@ class AgentDependencies:
41
41
  """
42
42
 
43
43
  the_installation: typing.Any # installation.Installation
44
- the_threads: agui_package.ThreadStorage = None
45
- state: agui_package.AGUI_State = dataclasses.field(default_factory=dict)
44
+ the_threads: agui.ThreadStorage = None
45
+ state: agui.AGUI_State = dataclasses.field(default_factory=dict)
46
46
  room_id: str | None = None
47
47
  thread_id: str | None = None
48
48
  run_id: str | None = None
@@ -587,7 +587,11 @@ async def get_the_threads(request: fastapi.Request) -> ThreadStorage:
587
587
 
588
588
  engine = request.state.threads_engine
589
589
  async with sqla_asyncio.AsyncSession(bind=engine) as session:
590
- yield persistence.ThreadStorage(session)
590
+ # One transaction per request: the storage methods no longer
591
+ # commit, so the request owns the unit of work -- committed on a
592
+ # clean response, rolled back if the handler raises.
593
+ async with session.begin():
594
+ yield persistence.ThreadStorage(session)
591
595
 
592
596
 
593
597
  depend_the_threads = fastapi.Depends(get_the_threads)
@@ -10,7 +10,7 @@ from collections import abc
10
10
  import jsonpatch
11
11
  from ag_ui import core as agui_core
12
12
 
13
- from soliplex import agui as agui_package
13
+ from soliplex import agui
14
14
 
15
15
  AGUI_EventIterator = abc.AsyncIterator[agui_core.Event]
16
16
  AGUI_EventDictIterator = abc.AsyncIterator[agui_core.Event]
@@ -230,12 +230,12 @@ DEFAULT_STRIPPED_MESSAGE_TYPES: StrippedMessageTypes = None
230
230
  @dataclasses.dataclass
231
231
  class EventStreamParser:
232
232
  run_agent_input: dataclasses.InitVar[agui_core.RunAgentInput] = None
233
- run: dataclasses.InitVar[agui_package.Run] = None
233
+ run: dataclasses.InitVar[agui.Run] = None
234
234
 
235
235
  _ = dataclasses.KW_ONLY
236
236
 
237
237
  _run_input: agui_core.RunAgentInput = None
238
- _the_run: agui_package.Run = None
238
+ _the_run: agui.Run = None
239
239
 
240
240
  run_status: RunStatus = RunStatus.INITIALIZED
241
241
  active_steps: set[str] = dataclasses.field(default_factory=set)
@@ -282,7 +282,7 @@ class EventStreamParser:
282
282
  self.messages_by_id = {msg.id: msg for msg in value.messages}
283
283
 
284
284
  @property
285
- def the_run(self) -> agui_package.Run:
285
+ def the_run(self) -> agui.Run:
286
286
  return self._the_run
287
287
 
288
288
  def _assert_running(self, event):
@@ -8,7 +8,7 @@ from ag_ui import core as agui_core
8
8
  from sqlalchemy import sql as sqla_sql
9
9
  from sqlalchemy.ext import asyncio as sqla_asyncio
10
10
 
11
- from soliplex import agui as agui_package
11
+ from soliplex import agui
12
12
  from soliplex.agui import persistence as agui_persistence
13
13
  from soliplex.agui import schema as agui_schema
14
14
  from soliplex.agui import util as agui_util
@@ -16,7 +16,7 @@ from soliplex.agui import util as agui_util
16
16
  # Temporary backward-compatibility: to be removed in 'v0.45'
17
17
  from soliplex.agui.schema import * # noqa F403
18
18
 
19
- FeedbackReviewStatus = agui_package.FeedbackReviewStatus
19
+ FeedbackReviewStatus = agui.FeedbackReviewStatus
20
20
 
21
21
 
22
22
  class NoFeedbackFound(ValueError):
@@ -48,15 +48,19 @@ _LAST_ACTIVITY = sqla_sql.func.max(
48
48
  )
49
49
 
50
50
 
51
- class ThreadStorage(agui_package.ThreadStorage):
51
+ class ThreadStorage(agui.ThreadStorage):
52
52
  def __init__(self, session: sqla_asyncio.AsyncSession):
53
53
  self._session = session
54
54
 
55
55
  @property
56
56
  @contextlib.asynccontextmanager
57
57
  async def session(self):
58
- async with self._session.begin():
59
- yield self._session
58
+ # Yield the caller-owned session as-is: the session owner (a
59
+ # FastAPI dependency, CLI context manager, or streaming helper)
60
+ # owns the transaction boundary and commits the unit of work
61
+ # exactly once. Methods here never open their own transaction or
62
+ # commit, so callers -- and tests -- need no interleaved commits.
63
+ yield self._session
60
64
 
61
65
  async def _find_user_thread(
62
66
  self,
@@ -74,12 +78,12 @@ class ThreadStorage(agui_package.ThreadStorage):
74
78
  thread = (await session.scalars(query)).first()
75
79
 
76
80
  if thread is None:
77
- raise agui_package.UnknownThread(user_name, thread_id)
81
+ raise agui.UnknownThread(user_name, thread_id)
78
82
 
79
83
  t_room_id = await thread.awaitable_attrs.room_id
80
84
 
81
85
  if t_room_id != room_id:
82
- raise agui_package.ThreadRoomMismatch(room_id, t_room_id)
86
+ raise agui.ThreadRoomMismatch(room_id, t_room_id)
83
87
 
84
88
  return thread
85
89
 
@@ -91,7 +95,7 @@ class ThreadStorage(agui_package.ThreadStorage):
91
95
  thread_id: str,
92
96
  run_id: str,
93
97
  session,
94
- exc_type=agui_package.UnknownRun,
98
+ exc_type=agui.UnknownRun,
95
99
  ):
96
100
  thread = await self._find_user_thread(
97
101
  user_name=user_name,
@@ -294,7 +298,7 @@ class ThreadStorage(agui_package.ThreadStorage):
294
298
  thread_id=thread_id,
295
299
  run_id=parent_run_id,
296
300
  session=session,
297
- exc_type=agui_package.MissingParentRun,
301
+ exc_type=agui.MissingParentRun,
298
302
  )
299
303
  else:
300
304
  parent = None
@@ -362,7 +366,7 @@ class ThreadStorage(agui_package.ThreadStorage):
362
366
  already = await run.awaitable_attrs.run_agent_input
363
367
 
364
368
  if already is not None:
365
- raise agui_package.RunAlreadyStarted(
369
+ raise agui.RunAlreadyStarted(
366
370
  user_name,
367
371
  thread_id,
368
372
  run_id,
@@ -420,8 +424,6 @@ class ThreadStorage(agui_package.ThreadStorage):
420
424
  event: agui_core.Event,
421
425
  ) -> None:
422
426
  """Save a single event for a run (incremental persistence)"""
423
- await self._session.commit()
424
-
425
427
  async with self.session as session:
426
428
  run = await self._find_thread_run(
427
429
  user_name=user_name,
@@ -448,8 +450,6 @@ class ThreadStorage(agui_package.ThreadStorage):
448
450
  run_id: str,
449
451
  ) -> None:
450
452
  """Mark a run as finished"""
451
- await self._session.commit()
452
-
453
453
  async with self.session as session:
454
454
  run = await self._find_thread_run(
455
455
  user_name=user_name,
@@ -475,8 +475,6 @@ class ThreadStorage(agui_package.ThreadStorage):
475
475
 
476
476
  Each element is a (event_index, event) tuple.
477
477
  """
478
- await self._session.commit()
479
-
480
478
  async with self.session as session:
481
479
  run = await self._find_thread_run(
482
480
  user_name=user_name,
@@ -504,8 +502,6 @@ class ThreadStorage(agui_package.ThreadStorage):
504
502
  run_id: str,
505
503
  ) -> bool:
506
504
  """Return True if the run has a 'finished' timestamp"""
507
- await self._session.commit()
508
-
509
505
  async with self.session as session:
510
506
  run = await self._find_thread_run(
511
507
  user_name=user_name,
@@ -524,11 +520,9 @@ class ThreadStorage(agui_package.ThreadStorage):
524
520
  room_id: str,
525
521
  thread_id: str,
526
522
  run_id: str,
527
- events: agui_package.AGUI_Events,
528
- ) -> agui_package.AGUI_Events:
523
+ events: agui.AGUI_Events,
524
+ ) -> agui.AGUI_Events:
529
525
  """Save the events for a gven run"""
530
- await self._session.commit()
531
-
532
526
  async with self.session as session:
533
527
  run = await self._find_thread_run(
534
528
  user_name=user_name,
@@ -560,8 +554,6 @@ class ThreadStorage(agui_package.ThreadStorage):
560
554
  tool_calls: int,
561
555
  ):
562
556
  """Save the run usage statistics"""
563
- await self._session.commit()
564
-
565
557
  async with self.session as session:
566
558
  run = await self._find_thread_run(
567
559
  user_name=user_name,
@@ -591,8 +583,6 @@ class ThreadStorage(agui_package.ThreadStorage):
591
583
  reason: str,
592
584
  ):
593
585
  """Save the run feedback"""
594
- await self._session.commit()
595
-
596
586
  async with self.session as session:
597
587
  run = await self._find_thread_run(
598
588
  user_name=user_name,
@@ -624,10 +614,8 @@ class ThreadStorage(agui_package.ThreadStorage):
624
614
  room_id: str,
625
615
  thread_id: str,
626
616
  run_id: str,
627
- ) -> agui_package.RunFeedbackType | None:
617
+ ) -> agui.RunFeedbackType | None:
628
618
  """Get the run feedback"""
629
- await self._session.commit()
630
-
631
619
  async with self.session as session:
632
620
  run = await self._find_thread_run(
633
621
  user_name=user_name,
@@ -658,8 +646,6 @@ class ThreadStorage(agui_package.ThreadStorage):
658
646
  if limit is None and since is None:
659
647
  limit = 20
660
648
 
661
- await self._session.commit()
662
-
663
649
  async with self.session as session:
664
650
  query = (
665
651
  sqla_sql.Select(
@@ -12,7 +12,7 @@ from sqlalchemy import schema as sqla_schema
12
12
  from sqlalchemy.ext import asyncio as sqla_asyncio
13
13
  from sqlalchemy.sql import sqltypes as sqla_sqltypes
14
14
 
15
- from soliplex import agui as agui_package
15
+ from soliplex import agui
16
16
  from soliplex import util
17
17
  from soliplex.agui import util as agui_util
18
18
  from soliplex.config import installation as config_installation
@@ -42,7 +42,7 @@ JSON_Mapped_From = dict[str, typing.Any]
42
42
 
43
43
  metadata = sqla_schema.MetaData(naming_convention=NAMING_CONVENTION)
44
44
 
45
- FeedbackReviewStatus = agui_package.FeedbackReviewStatus
45
+ FeedbackReviewStatus = agui.FeedbackReviewStatus
46
46
 
47
47
 
48
48
  class Base(AsyncAttrs, DeclarativeBase):
@@ -447,8 +447,8 @@ class RunUsage(Base):
447
447
  requests: Mapped[int] = mapped_column()
448
448
  tool_calls: Mapped[int] = mapped_column()
449
449
 
450
- def as_tuple(self) -> agui_package.RunUsageStats:
451
- return agui_package.RunUsageStats(
450
+ def as_tuple(self) -> agui.RunUsageStats:
451
+ return agui.RunUsageStats(
452
452
  self.input_tokens,
453
453
  self.output_tokens,
454
454
  self.requests,
@@ -6,9 +6,7 @@ import json
6
6
  import re
7
7
  import typing
8
8
 
9
- import fastapi
10
9
  import jsonpath
11
- from sqlalchemy.ext import asyncio as sqla_asyncio
12
10
 
13
11
  # Avoid circular import when only used for typing
14
12
  # from soliplex import models
@@ -85,6 +83,40 @@ class ExactlyOneDiscriminator(ValueError):
85
83
  )
86
84
 
87
85
 
86
+ class NoSuchAdminUser(ValueError):
87
+ """Raised when no admin user matches a given JSONPath discriminator."""
88
+
89
+ def __init__(self, json_path):
90
+ self.json_path = json_path
91
+ super().__init__(f"No admin user exists with json_path: {json_path}")
92
+
93
+
94
+ class AdminUserExists(ValueError):
95
+ """Raised when an admin user already exists for a discriminator."""
96
+
97
+ def __init__(self, json_path):
98
+ self.json_path = json_path
99
+ super().__init__(
100
+ f"Admin user already exists with json_path: {json_path}"
101
+ )
102
+
103
+
104
+ class NoSuchRoomPolicy(ValueError):
105
+ """Raised when no authorization policy exists for a room."""
106
+
107
+ def __init__(self, room_id):
108
+ self.room_id = room_id
109
+ super().__init__(f"No policy exists for room: {room_id}")
110
+
111
+
112
+ class NoSuchACLEntry(ValueError):
113
+ """Raised when no stored ACL entry matches a room operation."""
114
+
115
+ def __init__(self, room_id):
116
+ self.room_id = room_id
117
+ super().__init__(f"No matching ACL entry in room: {room_id}")
118
+
119
+
88
120
  def validate_json_path(value: str | None) -> str | None:
89
121
  """Validate a JSONPath query string.
90
122
 
@@ -144,24 +176,59 @@ class AllowDeny(enum.Enum):
144
176
  DENY = "deny"
145
177
 
146
178
 
147
- class AuthorizationPolicy(abc.ABC):
148
- """Protocol for checking / managing authorization policies"""
179
+ class AdminUserPolicy(abc.ABC):
180
+ """Protocol for checking / managing admin-user discriminators."""
149
181
 
150
182
  @abc.abstractmethod
151
- async def list_admin_users(self) -> list[str]:
152
- """List admin users in the admin users table.
183
+ async def list_admin_user_discriminators(self) -> list[str]:
184
+ """List JSONPath discriminators which identify admin users.
185
+
186
+ Discriminators may be tied to individuals (e.g., via their
187
+ email addresses), or to claims which identify groups or
188
+ roles attached to the user.
189
+ """
190
+
191
+ @abc.abstractmethod
192
+ async def add_admin_user_discriminator(self, json_path: str):
193
+ """Add a user discriminator to the admin users table.
194
+
195
+ Discriminators may be tied to individuals (e.g., via their
196
+ email addresses), or to claims which identify groups or
197
+ roles attached to the user.
198
+ """
153
199
 
154
- Email-keyed admins surface as their email; admins keyed by a
155
- non-email JSONPath query surface as the raw query string.
200
+ @abc.abstractmethod
201
+ async def remove_admin_user_discriminator(self, json_path: str):
202
+ """Remove a user discriminator from the admin users table.
203
+
204
+ Discriminators may be tied to individuals (e.g., via their
205
+ email addresses), or to claims which identify groups or
206
+ roles attached to the user.
156
207
  """
157
208
 
209
+ @abc.abstractmethod
210
+ async def clear_admin_user_discriminators(self):
211
+ """Remove all admin user discriminators from the admin users table."""
212
+
213
+ @abc.abstractmethod
214
+ async def list_admin_users(self) -> list[str]:
215
+ """Deprecated alias for 'list_admin_user_discriminators'."""
216
+
158
217
  @abc.abstractmethod
159
218
  async def add_admin_user(self, email: str):
160
- """Add a user to the admin users table."""
219
+ """Deprecated alias for 'add_admin_user_discriminator'.
220
+
221
+ Translates 'email' to the equivalent JSONPath expression,
222
+ '$[?$.email == "..."]', then delegates.
223
+ """
161
224
 
162
225
  @abc.abstractmethod
163
226
  async def remove_admin_user(self, email: str):
164
- """Remove a user from the admin users table."""
227
+ """Deprecated alias for 'remove_admin_user_discriminator'.
228
+
229
+ Translates 'email' to the equivalent JSONPath expression,
230
+ '$[?$.email == "..."]', then delegates.
231
+ """
165
232
 
166
233
  @abc.abstractmethod
167
234
  async def check_admin_access(self, user_token: UserToken) -> bool:
@@ -171,6 +238,10 @@ class AuthorizationPolicy(abc.ABC):
171
238
  the user is an admin when any query matches.
172
239
  """
173
240
 
241
+
242
+ class RoomAuthorizationPolicy(abc.ABC):
243
+ """Protocol for checking / managing room authorization policies."""
244
+
174
245
  @abc.abstractmethod
175
246
  async def check_room_access(
176
247
  self,
@@ -191,7 +262,6 @@ class AuthorizationPolicy(abc.ABC):
191
262
  async def get_room_policy(
192
263
  self,
193
264
  room_id: str,
194
- user_token: UserToken,
195
265
  ) -> models.RoomPolicy | None: # noqa: F821
196
266
  """Return the authorization policy for the room"""
197
267
 
@@ -200,7 +270,6 @@ class AuthorizationPolicy(abc.ABC):
200
270
  self,
201
271
  room_id: str,
202
272
  room_policy: models.RoomPolicy, # noqa: F821
203
- user_token: UserToken,
204
273
  ) -> None:
205
274
  """Update the authorization policy for the room"""
206
275
 
@@ -208,19 +277,58 @@ class AuthorizationPolicy(abc.ABC):
208
277
  async def delete_room_policy(
209
278
  self,
210
279
  room_id: str,
211
- user_token: UserToken,
212
280
  ) -> None:
213
281
  """Delete the authorization policy for the room"""
214
282
 
283
+ @abc.abstractmethod
284
+ async def get_room_policy_unchecked(
285
+ self,
286
+ room_id: str,
287
+ ) -> models.RoomPolicyUnchecked | None: # noqa: F821
288
+ """Return the room policy, tolerating non-compiling 'json_path's."""
289
+
290
+ @abc.abstractmethod
291
+ async def list_room_policies(
292
+ self,
293
+ ) -> list[models.RoomPolicyUnchecked]: # noqa: F821
294
+ """List every stored room policy as an unchecked model.
295
+
296
+ Each policy is returned as the unchecked model -- the analogue
297
+ of 'get_room_policy_unchecked' over the whole table -- so a
298
+ stored ACL entry whose 'json_path' no longer compiles is
299
+ surfaced rather than raising, exactly what an audit needs. The
300
+ order is unspecified.
301
+ """
302
+
303
+ @abc.abstractmethod
304
+ async def clear_room_acl(self, room_id: str) -> None:
305
+ """Remove all ACL entries from the room's policy.
215
306
 
216
- async def get_the_authz_policy(
217
- request: fastapi.Request,
218
- ) -> AuthorizationPolicy:
219
- from . import persistence
307
+ The policy row and its 'default_allow_deny' are preserved.
308
+ """
220
309
 
221
- engine = request.state.authorization_engine
222
- async with sqla_asyncio.AsyncSession(bind=engine) as session:
223
- yield persistence.AuthorizationPolicy(session)
310
+ @abc.abstractmethod
311
+ async def add_acl_entry(
312
+ self,
313
+ room_id: str,
314
+ entry: models.ACLEntry, # noqa: F821
315
+ ) -> None:
316
+ """Add an ACL entry to the room's policy, replacing any entry with
317
+ the same discriminator."""
224
318
 
319
+ @abc.abstractmethod
320
+ async def remove_acl_entry(
321
+ self,
322
+ room_id: str,
323
+ entry: models.ACLEntryUnchecked, # noqa: F821
324
+ ) -> None:
325
+ """Remove the matching ACL entry from the room's policy."""
225
326
 
226
- depend_the_authz_policy = fastapi.Depends(get_the_authz_policy)
327
+ @abc.abstractmethod
328
+ async def set_room_default(
329
+ self,
330
+ room_id: str,
331
+ allow_deny: AllowDeny,
332
+ ) -> None:
333
+ """Set the room policy's 'default_allow_deny', creating an empty
334
+ policy if none exists."""