soliplex 0.87__tar.gz → 0.88__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 (129) hide show
  1. {soliplex-0.87/src/soliplex.egg-info → soliplex-0.88}/PKG-INFO +1 -1
  2. {soliplex-0.87 → soliplex-0.88}/pyproject.toml +1 -1
  3. {soliplex-0.87 → soliplex-0.88}/src/soliplex/agui/__init__.py +47 -0
  4. {soliplex-0.87 → soliplex-0.88}/src/soliplex/agui/persistence.py +89 -0
  5. {soliplex-0.87 → soliplex-0.88}/src/soliplex/authn.py +30 -0
  6. {soliplex-0.87 → soliplex-0.88}/src/soliplex/authz/__init__.py +47 -6
  7. {soliplex-0.87 → soliplex-0.88}/src/soliplex/authz/persistence.py +40 -7
  8. {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/main.py +2 -0
  9. soliplex-0.88/src/soliplex/cli/service_token.py +268 -0
  10. {soliplex-0.87 → soliplex-0.88}/src/soliplex/config/installation.py +1 -1
  11. {soliplex-0.87 → soliplex-0.88}/src/soliplex/loggers.py +5 -1
  12. soliplex-0.88/src/soliplex/mcp_auth.py +50 -0
  13. {soliplex-0.87 → soliplex-0.88}/src/soliplex/models.py +12 -0
  14. {soliplex-0.87 → soliplex-0.88}/src/soliplex/skills/bwrap_sandbox/__init__.py +60 -21
  15. soliplex-0.88/src/soliplex/token_mint.py +159 -0
  16. {soliplex-0.87 → soliplex-0.88}/src/soliplex/views/agui.py +107 -0
  17. {soliplex-0.87 → soliplex-0.88}/src/soliplex/views/authz.py +1 -5
  18. {soliplex-0.87 → soliplex-0.88}/src/soliplex/views/room_file_uploads.py +59 -38
  19. {soliplex-0.87 → soliplex-0.88}/src/soliplex/views/rooms.py +29 -4
  20. {soliplex-0.87 → soliplex-0.88}/src/soliplex/views/sandbox_workdirs.py +22 -35
  21. {soliplex-0.87 → soliplex-0.88/src/soliplex.egg-info}/PKG-INFO +1 -1
  22. {soliplex-0.87 → soliplex-0.88}/src/soliplex.egg-info/SOURCES.txt +2 -0
  23. soliplex-0.87/src/soliplex/mcp_auth.py +0 -104
  24. {soliplex-0.87 → soliplex-0.88}/LICENSE +0 -0
  25. {soliplex-0.87 → soliplex-0.88}/MANIFEST.in +0 -0
  26. {soliplex-0.87 → soliplex-0.88}/README.md +0 -0
  27. {soliplex-0.87 → soliplex-0.88}/example/skills/bare-bones/SKILL.md +0 -0
  28. {soliplex-0.87 → soliplex-0.88}/example/skills/stanzas/SKILL.md +0 -0
  29. {soliplex-0.87 → soliplex-0.88}/setup.cfg +0 -0
  30. {soliplex-0.87 → soliplex-0.88}/skills/soliplex-docs/SKILL.md +0 -0
  31. {soliplex-0.87 → soliplex-0.88}/src/soliplex/agents.py +0 -0
  32. {soliplex-0.87 → soliplex-0.88}/src/soliplex/agui/parser.py +0 -0
  33. {soliplex-0.87 → soliplex-0.88}/src/soliplex/agui/schema.py +0 -0
  34. {soliplex-0.87 → soliplex-0.88}/src/soliplex/agui/sse_delivery.py +0 -0
  35. {soliplex-0.87 → soliplex-0.88}/src/soliplex/agui/util.py +0 -0
  36. {soliplex-0.87 → soliplex-0.88}/src/soliplex/alembic_migrations/__init__.py +0 -0
  37. {soliplex-0.87 → soliplex-0.88}/src/soliplex/alembic_migrations/env.py +0 -0
  38. {soliplex-0.87 → soliplex-0.88}/src/soliplex/alembic_migrations/versions/216d48e1e2e5_soliplex_v0_53.py +0 -0
  39. {soliplex-0.87 → soliplex-0.88}/src/soliplex/alembic_migrations/versions/4b63e5f3f39e_soliplex_v0_51_1.py +0 -0
  40. {soliplex-0.87 → soliplex-0.88}/src/soliplex/alembic_migrations/versions/63edaa5987f6_soliplex_v0_67.py +0 -0
  41. {soliplex-0.87 → soliplex-0.88}/src/soliplex/alembic_migrations/versions/a1c7d3e90b42_soliplex_v0_80.py +0 -0
  42. {soliplex-0.87 → soliplex-0.88}/src/soliplex/alembic_migrations/versions/b7e2f41c9d05_soliplex_v0_82.py +0 -0
  43. {soliplex-0.87 → soliplex-0.88}/src/soliplex/alembic_migrations/versions/d5009d4f9874_soliplex_v0_44.py +0 -0
  44. {soliplex-0.87 → soliplex-0.88}/src/soliplex/alembic_migrations/versions/f13fea6c1953_soliplex_v0_86.py +0 -0
  45. {soliplex-0.87 → soliplex-0.88}/src/soliplex/authz/schema.py +0 -0
  46. {soliplex-0.87 → soliplex-0.88}/src/soliplex/capabilities/filesystem.py +0 -0
  47. {soliplex-0.87 → soliplex-0.88}/src/soliplex/capabilities/rag_audit.py +0 -0
  48. {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/admin_users.py +0 -0
  49. {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/ask.py +0 -0
  50. {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/audit/__init__.py +0 -0
  51. {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/audit/_common.py +0 -0
  52. {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/audit/admin_users.py +0 -0
  53. {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/audit/completions.py +0 -0
  54. {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/audit/databases.py +0 -0
  55. {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/audit/environment.py +0 -0
  56. {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/audit/installation.py +0 -0
  57. {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/audit/interpolation.py +0 -0
  58. {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/audit/logfire.py +0 -0
  59. {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/audit/logging.py +0 -0
  60. {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/audit/oidc.py +0 -0
  61. {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/audit/ollama.py +0 -0
  62. {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/audit/quizzes.py +0 -0
  63. {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/audit/room_authz.py +0 -0
  64. {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/audit/rooms.py +0 -0
  65. {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/audit/secrets.py +0 -0
  66. {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/audit/skills.py +0 -0
  67. {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/cli_util.py +0 -0
  68. {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/database.py +0 -0
  69. {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/ollama.py +0 -0
  70. {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/room_authz.py +0 -0
  71. {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/serve.py +0 -0
  72. {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/types.py +0 -0
  73. {soliplex-0.87 → soliplex-0.88}/src/soliplex/completions.py +0 -0
  74. {soliplex-0.87 → soliplex-0.88}/src/soliplex/config/__init__.py +0 -0
  75. {soliplex-0.87 → soliplex-0.88}/src/soliplex/config/_utils.py +0 -0
  76. {soliplex-0.87 → soliplex-0.88}/src/soliplex/config/agents.py +0 -0
  77. {soliplex-0.87 → soliplex-0.88}/src/soliplex/config/agui.py +0 -0
  78. {soliplex-0.87 → soliplex-0.88}/src/soliplex/config/authsystem.py +0 -0
  79. {soliplex-0.87 → soliplex-0.88}/src/soliplex/config/completions.py +0 -0
  80. {soliplex-0.87 → soliplex-0.88}/src/soliplex/config/exceptions.py +0 -0
  81. {soliplex-0.87 → soliplex-0.88}/src/soliplex/config/interpolation.py +0 -0
  82. {soliplex-0.87 → soliplex-0.88}/src/soliplex/config/logfire.py +0 -0
  83. {soliplex-0.87 → soliplex-0.88}/src/soliplex/config/meta.py +0 -0
  84. {soliplex-0.87 → soliplex-0.88}/src/soliplex/config/middleware.py +0 -0
  85. {soliplex-0.87 → soliplex-0.88}/src/soliplex/config/quizzes.py +0 -0
  86. {soliplex-0.87 → soliplex-0.88}/src/soliplex/config/rag.py +0 -0
  87. {soliplex-0.87 → soliplex-0.88}/src/soliplex/config/rooms.py +0 -0
  88. {soliplex-0.87 → soliplex-0.88}/src/soliplex/config/routing.py +0 -0
  89. {soliplex-0.87 → soliplex-0.88}/src/soliplex/config/secrets.py +0 -0
  90. {soliplex-0.87 → soliplex-0.88}/src/soliplex/config/skills.py +0 -0
  91. {soliplex-0.87 → soliplex-0.88}/src/soliplex/config/sse_delivery.py +0 -0
  92. {soliplex-0.87 → soliplex-0.88}/src/soliplex/config/tools.py +0 -0
  93. {soliplex-0.87 → soliplex-0.88}/src/soliplex/examples.py +0 -0
  94. {soliplex-0.87 → soliplex-0.88}/src/soliplex/installation.py +0 -0
  95. {soliplex-0.87 → soliplex-0.88}/src/soliplex/log_ingest.py +0 -0
  96. {soliplex-0.87 → soliplex-0.88}/src/soliplex/main.py +0 -0
  97. {soliplex-0.87 → soliplex-0.88}/src/soliplex/mcp_client.py +0 -0
  98. {soliplex-0.87 → soliplex-0.88}/src/soliplex/mcp_server.py +0 -0
  99. {soliplex-0.87 → soliplex-0.88}/src/soliplex/ollama.py +0 -0
  100. {soliplex-0.87 → soliplex-0.88}/src/soliplex/quizzes.py +0 -0
  101. {soliplex-0.87 → soliplex-0.88}/src/soliplex/rag_audit.py +0 -0
  102. {soliplex-0.87 → soliplex-0.88}/src/soliplex/sandbox_audit.py +0 -0
  103. {soliplex-0.87 → soliplex-0.88}/src/soliplex/secrets.py +0 -0
  104. {soliplex-0.87 → soliplex-0.88}/src/soliplex/skills/bwrap_sandbox/SKILL.md +0 -0
  105. {soliplex-0.87 → soliplex-0.88}/src/soliplex/titles.py +0 -0
  106. {soliplex-0.87 → soliplex-0.88}/src/soliplex/tools/__init__.py +0 -0
  107. {soliplex-0.87 → soliplex-0.88}/src/soliplex/tools/agui_run_feedback.py +0 -0
  108. {soliplex-0.87 → soliplex-0.88}/src/soliplex/tools/file_uploads.py +0 -0
  109. {soliplex-0.87 → soliplex-0.88}/src/soliplex/tools/rag.py +0 -0
  110. {soliplex-0.87 → soliplex-0.88}/src/soliplex/tui/cli.py +0 -0
  111. {soliplex-0.87 → soliplex-0.88}/src/soliplex/tui/main.py +0 -0
  112. {soliplex-0.87 → soliplex-0.88}/src/soliplex/tui/rest_api.py +0 -0
  113. {soliplex-0.87 → soliplex-0.88}/src/soliplex/tui/serve.py +0 -0
  114. {soliplex-0.87 → soliplex-0.88}/src/soliplex/util.py +0 -0
  115. {soliplex-0.87 → soliplex-0.88}/src/soliplex/views/__init__.py +0 -0
  116. {soliplex-0.87 → soliplex-0.88}/src/soliplex/views/authn.py +0 -0
  117. {soliplex-0.87 → soliplex-0.88}/src/soliplex/views/completions.py +0 -0
  118. {soliplex-0.87 → soliplex-0.88}/src/soliplex/views/installation.py +0 -0
  119. {soliplex-0.87 → soliplex-0.88}/src/soliplex/views/log_ingest.py +0 -0
  120. {soliplex-0.87 → soliplex-0.88}/src/soliplex/views/quizzes.py +0 -0
  121. {soliplex-0.87 → soliplex-0.88}/src/soliplex/views/stats.py +0 -0
  122. {soliplex-0.87 → soliplex-0.88}/src/soliplex/views/streaming.py +0 -0
  123. {soliplex-0.87 → soliplex-0.88}/src/soliplex/views/templates/consent.html.mako +0 -0
  124. {soliplex-0.87 → soliplex-0.88}/src/soliplex/views/templates/cookie-too-large.html.mako +0 -0
  125. {soliplex-0.87 → soliplex-0.88}/src/soliplex/views/thread_file_uploads.py +0 -0
  126. {soliplex-0.87 → soliplex-0.88}/src/soliplex.egg-info/dependency_links.txt +0 -0
  127. {soliplex-0.87 → soliplex-0.88}/src/soliplex.egg-info/entry_points.txt +0 -0
  128. {soliplex-0.87 → soliplex-0.88}/src/soliplex.egg-info/requires.txt +0 -0
  129. {soliplex-0.87 → soliplex-0.88}/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.87
3
+ Version: 0.88
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "soliplex"
7
- version = "0.87"
7
+ version = "0.88"
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"
@@ -261,6 +261,20 @@ class Thread(abc.ABC):
261
261
  """Return runs for this thread"""
262
262
 
263
263
 
264
+ class ThreadPagePosition(typing.NamedTuple):
265
+ """Where one page of a cross-room thread listing ended.
266
+
267
+ The listing's own sort key, so the next page resumes strictly after
268
+ it no matter what was created, deleted or reordered in between.
269
+ """
270
+
271
+ last_activity: datetime.datetime
272
+ """The last thread's activity time"""
273
+
274
+ thread_id: str
275
+ """The last thread's ID, ordering threads sharing that time"""
276
+
277
+
264
278
  class ThreadStorage(abc.ABC):
265
279
  @abc.abstractmethod
266
280
  async def list_user_threads(
@@ -275,6 +289,39 @@ class ThreadStorage(abc.ABC):
275
289
  in that room.
276
290
  """
277
291
 
292
+ @abc.abstractmethod
293
+ async def list_user_threads_page(
294
+ self,
295
+ *,
296
+ user_name: str,
297
+ room_ids: list[str],
298
+ limit: int,
299
+ after: ThreadPagePosition | None = None,
300
+ ) -> tuple[
301
+ list[tuple[Thread, datetime.datetime]],
302
+ ThreadPagePosition | None,
303
+ ]:
304
+ """Return one page of the user's threads across several rooms.
305
+
306
+ Unlike 'list_user_threads', which is scoped to a single room,
307
+ this spans every room in 'room_ids' -- the caller is responsible
308
+ for having already filtered those to the rooms the user may see.
309
+ An empty 'room_ids' therefore yields an empty page rather than
310
+ every room's threads.
311
+
312
+ Threads are ordered by their own last activity, most recent
313
+ first: the latest of each run's finish (or, while unfinished, its
314
+ start), falling back to the thread's creation for a thread with
315
+ no runs. Each thread comes back paired with that time.
316
+
317
+ Paging is by position rather than by offset: 'after' is the
318
+ position of the last thread on the previous page, and the second
319
+ element returned is the position to pass for the next page, or
320
+ 'None' when this page is the last. A thread created, deleted or
321
+ moved to the top by new activity between two requests therefore
322
+ never shifts the rest of the listing under the reader.
323
+ """
324
+
278
325
  @abc.abstractmethod
279
326
  async def get_thread(
280
327
  self,
@@ -125,6 +125,95 @@ class ThreadStorage(agui.ThreadStorage):
125
125
  result = await session.scalars(query)
126
126
  return result
127
127
 
128
+ async def list_user_threads_page(
129
+ self,
130
+ *,
131
+ user_name: str,
132
+ room_ids: list[str],
133
+ limit: int,
134
+ after: agui.ThreadPagePosition | None = None,
135
+ ) -> tuple[
136
+ list[tuple[agui_schema.Thread, datetime.datetime]],
137
+ agui.ThreadPagePosition | None,
138
+ ]:
139
+ room_ids = list(room_ids)
140
+
141
+ if not room_ids:
142
+ # No accessible rooms: short-circuit rather than emitting an
143
+ # 'IN ()', which SQLAlchemy warns about and which reads as
144
+ # "unfiltered" to anyone skimming the SQL.
145
+ return [], None
146
+
147
+ async with self.session as session:
148
+ # One row per thread: a grouped subquery rather than joining
149
+ # runs directly, which would multiply each thread by its run
150
+ # count and corrupt LIMIT.
151
+ thread_activity = (
152
+ sqla_sql.select(
153
+ agui_schema.Run.thread_id_.label("thread_id_"),
154
+ _LAST_ACTIVITY.label("last_activity"),
155
+ )
156
+ .join(agui_schema.Run.thread)
157
+ .where(agui_schema.Thread.user_name == user_name)
158
+ .group_by(agui_schema.Run.thread_id_)
159
+ .subquery()
160
+ )
161
+
162
+ # A thread with no runs yet is as recent as its creation.
163
+ activity = sqla_sql.func.coalesce(
164
+ thread_activity.c.last_activity,
165
+ agui_schema.Thread.created,
166
+ )
167
+
168
+ query = (
169
+ sqla_sql.select(agui_schema.Thread, activity)
170
+ .outerjoin(
171
+ thread_activity,
172
+ thread_activity.c.thread_id_ == agui_schema.Thread.id_,
173
+ )
174
+ .where(agui_schema.Thread.user_name == user_name)
175
+ .where(agui_schema.Thread.room_id.in_(room_ids))
176
+ )
177
+
178
+ if after is not None:
179
+ # Strictly past the previous page's last thread in the
180
+ # same order the page is sorted in; spelled out rather
181
+ # than as a row-value comparison, which not every
182
+ # backend supports.
183
+ query = query.where(
184
+ sqla_sql.or_(
185
+ activity < after.last_activity,
186
+ sqla_sql.and_(
187
+ activity == after.last_activity,
188
+ agui_schema.Thread.thread_id < after.thread_id,
189
+ ),
190
+ )
191
+ )
192
+
193
+ # One row past the page says whether another page follows,
194
+ # without a separate count.
195
+ query = query.order_by(
196
+ activity.desc(),
197
+ agui_schema.Thread.thread_id.desc(),
198
+ ).limit(limit + 1)
199
+
200
+ rows = [
201
+ (a_thread, _as_utc(last_activity))
202
+ for a_thread, last_activity in (
203
+ await session.execute(query)
204
+ ).all()
205
+ ]
206
+
207
+ if len(rows) <= limit:
208
+ return rows, None
209
+
210
+ rows = rows[:limit]
211
+ last_thread, last_activity = rows[-1]
212
+ return rows, agui.ThreadPagePosition(
213
+ last_activity=last_activity,
214
+ thread_id=last_thread.thread_id,
215
+ )
216
+
128
217
  async def get_thread(
129
218
  self,
130
219
  *,
@@ -13,6 +13,7 @@ from fastapi import security
13
13
 
14
14
  from soliplex import installation
15
15
  from soliplex import loggers
16
+ from soliplex import token_mint
16
17
  from soliplex.config import authsystem as config_authsystem
17
18
 
18
19
  UserClaims = dict[str, typing.Any]
@@ -28,6 +29,9 @@ _oauth = None
28
29
 
29
30
  JWT_VALIDATION_NO_TOKEN = "JWT validation failed (no token)"
30
31
  JWT_VALIDATION_INVALID_TOKEN = "JWT validation failed (invalid token)"
32
+ SERVICE_TOKEN_VALIDATION_INVALID_TOKEN = (
33
+ "Service token validation failed (invalid token)"
34
+ )
31
35
 
32
36
  INSECURE_COOKIES_ENV_VAR = "_SOLIPLEX_INSECURE_SESSION_COOKIE"
33
37
 
@@ -41,6 +45,8 @@ AUTHN_COOKIE_ALWAYS_CLAIMS = ("sub",)
41
45
  # as the budget for the serialized value alone.
42
46
  AUTHN_COOKIE_MAX_SIZE = 4000
43
47
 
48
+ SERVICE_TOKEN_SECRET_NAME = "URL_SAFE_TOKEN_SECRET"
49
+
44
50
 
45
51
  def _get_authsystem_config(
46
52
  *,
@@ -113,6 +119,25 @@ def authenticate(
113
119
 
114
120
  errors[auth_system.id] = msg
115
121
 
122
+ claims, service_token_reason = token_mint.load_service_token(
123
+ the_installation.get_secret(SERVICE_TOKEN_SECRET_NAME),
124
+ token,
125
+ )
126
+ if claims is not None:
127
+ return claims
128
+
129
+ if service_token_reason != loggers.AUTHN_SERVICE_TOKEN_BAD_SIGNATURE:
130
+ # The signature verified, so the token is a service token:
131
+ # report the error accordingly.
132
+ logger.error(
133
+ loggers.AUTHN_SERVICE_TOKEN_INVALID,
134
+ reason=service_token_reason,
135
+ )
136
+ raise fastapi.HTTPException(
137
+ status_code=401,
138
+ detail=SERVICE_TOKEN_VALIDATION_INVALID_TOKEN,
139
+ )
140
+
116
141
  for oidc_system_id, msg in errors.items():
117
142
  logger.error(
118
143
  loggers.AUTHN_JWT_INVALID,
@@ -120,6 +145,11 @@ def authenticate(
120
145
  reason=msg,
121
146
  )
122
147
 
148
+ logger.error(
149
+ loggers.AUTHN_SERVICE_TOKEN_INVALID,
150
+ reason=service_token_reason,
151
+ )
152
+
123
153
  raise fastapi.HTTPException(
124
154
  status_code=401,
125
155
  detail=JWT_VALIDATION_INVALID_TOKEN,
@@ -218,6 +218,22 @@ class AdminUserPolicy(abc.ABC):
218
218
  async def clear_admin_user_discriminators(self):
219
219
  """Remove all admin user discriminators from the admin users table."""
220
220
 
221
+ @abc.abstractmethod
222
+ async def is_admin(
223
+ self,
224
+ user_token: UserToken,
225
+ ) -> bool:
226
+ """Is the user represented by 'user_token' an admin user?
227
+
228
+ Matches 'user_token' against each admin entry's JSONPath query;
229
+ the user is an admin when any query matches.
230
+
231
+ This test is for *non-audit* checks, e.g. to support whether
232
+ the frontend offers an admin-only option: it is not intended
233
+ for use where an actual admin operation is performed (see
234
+ 'check_admin_access' for that case).
235
+ """
236
+
221
237
  @abc.abstractmethod
222
238
  async def check_admin_access(
223
239
  self,
@@ -226,11 +242,13 @@ class AdminUserPolicy(abc.ABC):
226
242
  resource: str,
227
243
  action: str,
228
244
  ) -> bool:
229
- """Is the user represented by 'user_token' an admin user?
245
+ """Validate that the user represented by 'user_token' is an admin user
230
246
 
231
- Matches 'user_token' against each admin entry's JSONPath query;
232
- the user is an admin when any query matches. 'resource' / 'action'
233
- name the privileged operation being gated (for the audit record).
247
+ This check is called at the point that an auditable admin action
248
+ is being performed. It calls the non-audit 'is_admin' check, and then
249
+ creates the audit record, identifying the privileged operation being
250
+ gated using 'resource' / 'action'. Finally, it returns the result
251
+ from 'is_admin'.
234
252
  """
235
253
 
236
254
 
@@ -238,14 +256,14 @@ class RoomAuthorizationPolicy(abc.ABC):
238
256
  """Protocol for checking / managing room authorization policies."""
239
257
 
240
258
  @abc.abstractmethod
241
- async def check_room_access(
259
+ async def has_room_permission(
242
260
  self,
243
261
  room_id: str,
244
262
  user_token: UserToken | None,
245
263
  *,
246
264
  permission: Permission,
247
265
  ) -> bool:
248
- """Can the user represented by 'user_token' access a room?
266
+ """Does the user have a 'permission' on a given room?
249
267
 
250
268
  For a room with an authorization policy, return whether the result
251
269
  of its ``check_token`` for the user token and permission is ALLOW.
@@ -255,6 +273,29 @@ class RoomAuthorizationPolicy(abc.ABC):
255
273
  - Return True for READ (such rooms are public for read).
256
274
 
257
275
  - Return False for WRITE and ADMIN.
276
+
277
+ This test is for *non-audit* checks, e.g. to support whether
278
+ the frontend offers an option which would require the permission:
279
+ it is not intended for use where the actual gated operation is
280
+ performed (see 'check_room_access' for that case).
281
+ """
282
+
283
+ @abc.abstractmethod
284
+ async def check_room_access(
285
+ self,
286
+ room_id: str,
287
+ user_token: UserToken | None,
288
+ *,
289
+ permission: Permission,
290
+ ) -> bool:
291
+ """Validate that the user has the required permission for a room.
292
+
293
+ Calls the non-audit 'has_room_permission' check first.
294
+
295
+ This check is called at the point that an auditable action
296
+ is being performed. It calls the non-audit 'has_room_permission'
297
+ check, and then creates the audit record, identifying the required
298
+ permission. Finally, it returns the result from 'has_room_permission'.
258
299
  """
259
300
 
260
301
  @abc.abstractmethod
@@ -217,6 +217,17 @@ class AdminUserPolicy(_SessionPolicy, authz.AdminUserPolicy):
217
217
  await session.delete(admin_user)
218
218
  self._audit.admin_users_cleared()
219
219
 
220
+ async def is_admin(
221
+ self,
222
+ user_token: authz.UserToken,
223
+ ) -> bool:
224
+ """Is the user represented by 'user_token' an admin user?
225
+
226
+ This test does not create an audit record.
227
+ """
228
+ async with self.session as session:
229
+ return await _user_is_admin(user_token, session)
230
+
220
231
  async def check_admin_access(
221
232
  self,
222
233
  user_token: authz.UserToken,
@@ -224,13 +235,14 @@ class AdminUserPolicy(_SessionPolicy, authz.AdminUserPolicy):
224
235
  resource: str,
225
236
  action: str,
226
237
  ) -> bool:
227
- """Is the user represented by 'user_token' an admin user?
238
+ """Validate admin access at point of privileged use.
239
+
240
+ Calls the non-audit 'is_admin' query first.
228
241
 
229
242
  'resource' / 'action' name the privileged operation being gated;
230
243
  they are folded into the emitted 'admin access' audit record.
231
244
  """
232
- async with self.session as session:
233
- is_admin = await _user_is_admin(user_token, session)
245
+ is_admin = await self.is_admin(user_token)
234
246
  if is_admin:
235
247
  self._audit.admin_access_allowed(resource=resource, action=action)
236
248
  else:
@@ -248,14 +260,14 @@ class RoomAuthorizationPolicy(_SessionPolicy, authz.RoomAuthorizationPolicy):
248
260
  self._audit = loggers.RoomAuthzAuditLog(claims=claims)
249
261
  self._access_audit = loggers.RoomAccessAuditLog(claims=claims)
250
262
 
251
- async def check_room_access(
263
+ async def has_room_permission(
252
264
  self,
253
265
  room_id: str,
254
266
  user_token: authz.UserToken | None,
255
267
  *,
256
268
  permission: authz.Permission,
257
269
  ) -> bool:
258
- """Can the user represented by 'user_token' access a room?
270
+ """Does the user have a 'permission' on a given room?
259
271
 
260
272
  For a room with an authorization policy, return whether the result
261
273
  of its ``check_token`` for the user token and permission is ALLOW.
@@ -265,6 +277,8 @@ class RoomAuthorizationPolicy(_SessionPolicy, authz.RoomAuthorizationPolicy):
265
277
  - Return True for READ (such rooms are public for read).
266
278
 
267
279
  - Return False for WRITE and ADMIN.
280
+
281
+ This test does *not* create an audit record.
268
282
  """
269
283
  async with self.session as session:
270
284
  policy = await _find_room_policy(room_id, session)
@@ -274,9 +288,28 @@ class RoomAuthorizationPolicy(_SessionPolicy, authz.RoomAuthorizationPolicy):
274
288
  allow_deny = policy.check_token(
275
289
  user_token, permission=permission
276
290
  )
277
- result = allow_deny == authz.AllowDeny.ALLOW
291
+ return allow_deny == authz.AllowDeny.ALLOW
278
292
  else:
279
- result = permission is authz.Permission.READ
293
+ return permission is authz.Permission.READ
294
+
295
+ async def check_room_access(
296
+ self,
297
+ room_id: str,
298
+ user_token: authz.UserToken | None,
299
+ *,
300
+ permission: authz.Permission,
301
+ ) -> bool:
302
+ """Validate that the user has the required permission for a room.
303
+
304
+ Calls the non-audit 'has_room_permission' check first.
305
+
306
+ Creates an audit record.
307
+ """
308
+ result = await self.has_room_permission(
309
+ room_id,
310
+ user_token,
311
+ permission=permission,
312
+ )
280
313
 
281
314
  if result:
282
315
  self._access_audit.room_access_allowed(
@@ -21,6 +21,7 @@ from soliplex.cli import database
21
21
  from soliplex.cli import ollama
22
22
  from soliplex.cli import room_authz
23
23
  from soliplex.cli import serve
24
+ from soliplex.cli import service_token
24
25
  from soliplex.cli import types
25
26
  from soliplex.config import installation as config_installation
26
27
 
@@ -101,6 +102,7 @@ the_cli.add_typer(database.app)
101
102
  the_cli.add_typer(admin_users.app)
102
103
  the_cli.add_typer(room_authz.app)
103
104
  the_cli.add_typer(ollama.app)
105
+ the_cli.add_typer(service_token.app)
104
106
 
105
107
 
106
108
  misc_app = typer.Typer()