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.
- {soliplex-0.87/src/soliplex.egg-info → soliplex-0.88}/PKG-INFO +1 -1
- {soliplex-0.87 → soliplex-0.88}/pyproject.toml +1 -1
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/agui/__init__.py +47 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/agui/persistence.py +89 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/authn.py +30 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/authz/__init__.py +47 -6
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/authz/persistence.py +40 -7
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/main.py +2 -0
- soliplex-0.88/src/soliplex/cli/service_token.py +268 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/config/installation.py +1 -1
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/loggers.py +5 -1
- soliplex-0.88/src/soliplex/mcp_auth.py +50 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/models.py +12 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/skills/bwrap_sandbox/__init__.py +60 -21
- soliplex-0.88/src/soliplex/token_mint.py +159 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/views/agui.py +107 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/views/authz.py +1 -5
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/views/room_file_uploads.py +59 -38
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/views/rooms.py +29 -4
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/views/sandbox_workdirs.py +22 -35
- {soliplex-0.87 → soliplex-0.88/src/soliplex.egg-info}/PKG-INFO +1 -1
- {soliplex-0.87 → soliplex-0.88}/src/soliplex.egg-info/SOURCES.txt +2 -0
- soliplex-0.87/src/soliplex/mcp_auth.py +0 -104
- {soliplex-0.87 → soliplex-0.88}/LICENSE +0 -0
- {soliplex-0.87 → soliplex-0.88}/MANIFEST.in +0 -0
- {soliplex-0.87 → soliplex-0.88}/README.md +0 -0
- {soliplex-0.87 → soliplex-0.88}/example/skills/bare-bones/SKILL.md +0 -0
- {soliplex-0.87 → soliplex-0.88}/example/skills/stanzas/SKILL.md +0 -0
- {soliplex-0.87 → soliplex-0.88}/setup.cfg +0 -0
- {soliplex-0.87 → soliplex-0.88}/skills/soliplex-docs/SKILL.md +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/agents.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/agui/parser.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/agui/schema.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/agui/sse_delivery.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/agui/util.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/alembic_migrations/__init__.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/alembic_migrations/env.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/alembic_migrations/versions/216d48e1e2e5_soliplex_v0_53.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/alembic_migrations/versions/4b63e5f3f39e_soliplex_v0_51_1.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/alembic_migrations/versions/63edaa5987f6_soliplex_v0_67.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/alembic_migrations/versions/a1c7d3e90b42_soliplex_v0_80.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/alembic_migrations/versions/b7e2f41c9d05_soliplex_v0_82.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/alembic_migrations/versions/d5009d4f9874_soliplex_v0_44.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/alembic_migrations/versions/f13fea6c1953_soliplex_v0_86.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/authz/schema.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/capabilities/filesystem.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/capabilities/rag_audit.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/admin_users.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/ask.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/audit/__init__.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/audit/_common.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/audit/admin_users.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/audit/completions.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/audit/databases.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/audit/environment.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/audit/installation.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/audit/interpolation.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/audit/logfire.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/audit/logging.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/audit/oidc.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/audit/ollama.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/audit/quizzes.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/audit/room_authz.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/audit/rooms.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/audit/secrets.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/audit/skills.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/cli_util.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/database.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/ollama.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/room_authz.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/serve.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/cli/types.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/completions.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/config/__init__.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/config/_utils.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/config/agents.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/config/agui.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/config/authsystem.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/config/completions.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/config/exceptions.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/config/interpolation.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/config/logfire.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/config/meta.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/config/middleware.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/config/quizzes.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/config/rag.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/config/rooms.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/config/routing.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/config/secrets.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/config/skills.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/config/sse_delivery.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/config/tools.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/examples.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/installation.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/log_ingest.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/main.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/mcp_client.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/mcp_server.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/ollama.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/quizzes.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/rag_audit.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/sandbox_audit.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/secrets.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/skills/bwrap_sandbox/SKILL.md +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/titles.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/tools/__init__.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/tools/agui_run_feedback.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/tools/file_uploads.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/tools/rag.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/tui/cli.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/tui/main.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/tui/rest_api.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/tui/serve.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/util.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/views/__init__.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/views/authn.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/views/completions.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/views/installation.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/views/log_ingest.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/views/quizzes.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/views/stats.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/views/streaming.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/views/templates/consent.html.mako +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/views/templates/cookie-too-large.html.mako +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex/views/thread_file_uploads.py +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex.egg-info/dependency_links.txt +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex.egg-info/entry_points.txt +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex.egg-info/requires.txt +0 -0
- {soliplex-0.87 → soliplex-0.88}/src/soliplex.egg-info/top_level.txt +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "soliplex"
|
|
7
|
-
version = "0.
|
|
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
|
-
"""
|
|
245
|
+
"""Validate that the user represented by 'user_token' is an admin user
|
|
230
246
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
|
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
|
-
"""
|
|
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
|
-
|
|
291
|
+
return allow_deny == authz.AllowDeny.ALLOW
|
|
278
292
|
else:
|
|
279
|
-
|
|
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()
|