soliplex 0.67__tar.gz → 0.67.2__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 (87) hide show
  1. {soliplex-0.67/src/soliplex.egg-info → soliplex-0.67.2}/PKG-INFO +1 -1
  2. {soliplex-0.67 → soliplex-0.67.2}/pyproject.toml +1 -6
  3. soliplex-0.67.2/src/soliplex/cli/admin_users.py +557 -0
  4. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/cli/audit.py +152 -2
  5. soliplex-0.67.2/src/soliplex/cli/cli_util.py +108 -0
  6. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/cli/room_authz.py +161 -110
  7. {soliplex-0.67 → soliplex-0.67.2/src/soliplex.egg-info}/PKG-INFO +1 -1
  8. soliplex-0.67/src/soliplex/cli/admin_users.py +0 -276
  9. soliplex-0.67/src/soliplex/cli/cli_util.py +0 -36
  10. {soliplex-0.67 → soliplex-0.67.2}/LICENSE +0 -0
  11. {soliplex-0.67 → soliplex-0.67.2}/MANIFEST.in +0 -0
  12. {soliplex-0.67 → soliplex-0.67.2}/README.md +0 -0
  13. {soliplex-0.67 → soliplex-0.67.2}/example/skills/bare-bones/SKILL.md +0 -0
  14. {soliplex-0.67 → soliplex-0.67.2}/setup.cfg +0 -0
  15. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/agents.py +0 -0
  16. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/agui/__init__.py +0 -0
  17. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/agui/parser.py +0 -0
  18. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/agui/persistence.py +0 -0
  19. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/agui/schema.py +0 -0
  20. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/agui/util.py +0 -0
  21. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/auth.py +0 -0
  22. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/authn.py +0 -0
  23. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/authz/__init__.py +0 -0
  24. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/authz/persistence.py +0 -0
  25. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/authz/schema.py +0 -0
  26. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/cli/main.py +0 -0
  27. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/cli/ollama.py +0 -0
  28. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/cli/serve.py +0 -0
  29. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/cli/types.py +0 -0
  30. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/completions.py +0 -0
  31. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/config/__init__.py +0 -0
  32. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/config/_utils.py +0 -0
  33. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/config/agents.py +0 -0
  34. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/config/agui.py +0 -0
  35. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/config/authsystem.py +0 -0
  36. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/config/completions.py +0 -0
  37. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/config/exceptions.py +0 -0
  38. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/config/installation.py +0 -0
  39. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/config/logfire.py +0 -0
  40. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/config/meta.py +0 -0
  41. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/config/quizzes.py +0 -0
  42. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/config/rag.py +0 -0
  43. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/config/rooms.py +0 -0
  44. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/config/routing.py +0 -0
  45. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/config/secrets.py +0 -0
  46. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/config/skills.py +0 -0
  47. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/config/tools.py +0 -0
  48. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/examples.py +0 -0
  49. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/installation.py +0 -0
  50. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/log_ingest.py +0 -0
  51. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/loggers.py +0 -0
  52. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/main.py +0 -0
  53. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/mcp_auth.py +0 -0
  54. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/mcp_client.py +0 -0
  55. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/mcp_server.py +0 -0
  56. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/models.py +0 -0
  57. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/ollama.py +0 -0
  58. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/quizzes.py +0 -0
  59. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/secrets.py +0 -0
  60. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/skills/bwrap_sandbox/SKILL.md +0 -0
  61. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/skills/bwrap_sandbox/__init__.py +0 -0
  62. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/titles.py +0 -0
  63. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/tools/__init__.py +0 -0
  64. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/tools/agui_run_feedback.py +0 -0
  65. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/tools/file_uploads.py +0 -0
  66. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/tools/rag.py +0 -0
  67. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/tui/cli.py +0 -0
  68. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/tui/main.py +0 -0
  69. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/tui/rest_api.py +0 -0
  70. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/tui/serve.py +0 -0
  71. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/util.py +0 -0
  72. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/views/__init__.py +0 -0
  73. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/views/agui.py +0 -0
  74. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/views/authn.py +0 -0
  75. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/views/authz.py +0 -0
  76. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/views/completions.py +0 -0
  77. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/views/file_uploads.py +0 -0
  78. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/views/installation.py +0 -0
  79. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/views/log_ingest.py +0 -0
  80. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/views/quizzes.py +0 -0
  81. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/views/rooms.py +0 -0
  82. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/views/streaming.py +0 -0
  83. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex.egg-info/SOURCES.txt +0 -0
  84. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex.egg-info/dependency_links.txt +0 -0
  85. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex.egg-info/entry_points.txt +0 -0
  86. {soliplex-0.67 → soliplex-0.67.2}/src/soliplex.egg-info/requires.txt +0 -0
  87. {soliplex-0.67 → soliplex-0.67.2}/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.67
3
+ Version: 0.67.2
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.67"
7
+ version = "0.67.2"
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"
@@ -146,11 +146,6 @@ plugins.code-block-style.enabled = false # md046
146
146
 
147
147
  [tool.alembic]
148
148
 
149
- # path to migration scripts.
150
- # this is typically a path given in POSIX (e.g. forward slashes)
151
- # format, relative to the token %(here)s which refers to the location of this
152
- # ini file
153
- script_location = "%(here)s/alembic"
154
149
 
155
150
  # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
156
151
  # Uncomment the line below if you want the files to be prepended with date and time
@@ -0,0 +1,557 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import pathlib
5
+ import sys
6
+ import typing
7
+
8
+ import typer
9
+ import yaml
10
+
11
+ from soliplex import authz as authz_package
12
+ from soliplex.authz import schema as authz_schema
13
+ from soliplex.cli import cli_util
14
+ from soliplex.cli import types
15
+
16
+ the_console = cli_util.the_console
17
+
18
+
19
+ app = typer.Typer(
20
+ name="admin-users",
21
+ help="Manage admin users",
22
+ no_args_is_help=True,
23
+ )
24
+
25
+
26
+ # Flip this to True to make human-focused output the default.
27
+ # '--quiet' will still force JSON.
28
+ _DEFAULT_VERBOSE = False
29
+
30
+
31
+ @app.callback()
32
+ def _admin_users_callback(
33
+ ctx: typer.Context,
34
+ verbose: bool = typer.Option(
35
+ False,
36
+ "-v",
37
+ "--verbose",
38
+ help=(
39
+ "Print human-focused output instead of a JSON dump. "
40
+ "Overridden by '--quiet'."
41
+ ),
42
+ ),
43
+ quiet: bool = typer.Option(
44
+ False,
45
+ "-q",
46
+ "--quiet",
47
+ help=(
48
+ "Force JSON output, overriding '--verbose' and any "
49
+ "verbose-by-default setting."
50
+ ),
51
+ ),
52
+ ):
53
+ if quiet:
54
+ effective = False
55
+ elif verbose:
56
+ effective = True
57
+ else:
58
+ effective = _DEFAULT_VERBOSE
59
+ ctx.obj = {"verbose": effective}
60
+
61
+
62
+ _ADMIN_DISCRIMINATORS_SUMMARY = (
63
+ "EMAIL, '--preferred-username', or '--json-path'"
64
+ )
65
+
66
+
67
+ def _check_admin_discriminator(
68
+ email: str | None,
69
+ preferred_username: str | None,
70
+ json_path: str | None,
71
+ ) -> None:
72
+ """Require exactly one of EMAIL / --preferred-username / --json-path."""
73
+ cli_util._check_exactly_one_discriminator(
74
+ [
75
+ ("EMAIL", email is not None),
76
+ ("--preferred-username", preferred_username is not None),
77
+ ("--json-path", json_path is not None),
78
+ ],
79
+ _ADMIN_DISCRIMINATORS_SUMMARY,
80
+ )
81
+
82
+
83
+ def _describe_admin(json_path) -> str:
84
+ """Human-friendly descriptor for an admin entry's JSONPath."""
85
+ parsed = authz_package.parse_token_field_json_path(json_path)
86
+ if parsed is not None:
87
+ field, value = parsed
88
+ return f"{field}={value}"
89
+ return f"json_path={json_path}"
90
+
91
+
92
+ def _admin_display(json_path) -> str:
93
+ """Value to show for an admin entry in the JSON dump.
94
+
95
+ Email-keyed admins show their email; others show the raw query.
96
+ """
97
+ parsed = authz_package.parse_token_field_json_path(json_path)
98
+ if parsed is not None and parsed[0] == "email":
99
+ return parsed[1]
100
+ return json_path
101
+
102
+
103
+ def _admin_user_as_jsonable(json_path: str) -> dict:
104
+ """JSON-serializable view of an admin row, tolerant of invalid paths.
105
+
106
+ Mirrors the per-entry shape used by 'room-authz as-yaml': surfaces
107
+ the canonical 'email' / 'preferred_username' query as the matching
108
+ shortcut field; other queries (including ones that no longer
109
+ compile) come back as 'json_path'.
110
+ """
111
+ preferred_username = None
112
+ email = None
113
+ other_json_path = None
114
+ parsed = authz_package.parse_token_field_json_path(json_path)
115
+ if parsed is not None and parsed[0] == "preferred_username":
116
+ preferred_username = parsed[1]
117
+ elif parsed is not None and parsed[0] == "email":
118
+ email = parsed[1]
119
+ else:
120
+ other_json_path = json_path
121
+ return {
122
+ "preferred_username": preferred_username,
123
+ "email": email,
124
+ "json_path": other_json_path,
125
+ }
126
+
127
+
128
+ def _admin_users_as_jsonable(admin_users) -> dict:
129
+ """Render an iterable of AdminUser rows as a JSON-serializable dict.
130
+
131
+ Wraps the per-row dicts in a top-level 'admin_users' key, matching
132
+ the shape used by 'admin-users list' (but with each row as a
133
+ structured dict rather than a bare string).
134
+ """
135
+ return {
136
+ "admin_users": [
137
+ _admin_user_as_jsonable(admin_user.json_path)
138
+ for admin_user in admin_users
139
+ ],
140
+ }
141
+
142
+
143
+ def _admin_users_as_yaml(admin_users) -> str:
144
+ """Render an iterable of AdminUser rows as a YAML document."""
145
+ to_dump = _admin_users_as_jsonable(admin_users)
146
+ return yaml.safe_dump(to_dump, sort_keys=False, default_flow_style=False)
147
+
148
+
149
+ def _admin_user_from_jsonable(entry: dict) -> str:
150
+ """Resolve a from-yaml entry back to a canonical 'json_path' string.
151
+
152
+ Each entry must specify exactly one of 'email',
153
+ 'preferred_username', or 'json_path'. Raises 'typer.Exit(1)' on a
154
+ malformed entry. The validity of a literal 'json_path' is not
155
+ checked here; the storage layer's '@validates' hook enforces it
156
+ on insert.
157
+ """
158
+ email = entry.get("email")
159
+ preferred_username = entry.get("preferred_username")
160
+ json_path = entry.get("json_path")
161
+ cli_util._check_exactly_one_discriminator(
162
+ [
163
+ ("email", email is not None),
164
+ ("preferred_username", preferred_username is not None),
165
+ ("json_path", json_path is not None),
166
+ ],
167
+ "'email', 'preferred_username', or 'json_path'",
168
+ )
169
+ if email is not None:
170
+ return authz_package.token_field_json_path("email", email)
171
+ if preferred_username is not None:
172
+ return authz_package.token_field_json_path(
173
+ "preferred_username", preferred_username
174
+ )
175
+ return json_path
176
+
177
+
178
+ def _admin_users_from_jsonable(data) -> list[str]:
179
+ """Translate the JSON-serializable shape back to a list of json_paths.
180
+
181
+ Returns an empty list for a 'null' document or one with no entries.
182
+ """
183
+ if data is None:
184
+ return []
185
+ return [
186
+ _admin_user_from_jsonable(entry)
187
+ for entry in data.get("admin_users", ())
188
+ ]
189
+
190
+
191
+ def _check_existing_admin(session, json_path):
192
+ """Reject 'admin-users add' for a JSONPath that is already an admin.
193
+
194
+ Inserting a second row for the same query would violate the
195
+ 'AdminUser.json_path' uniqueness constraint; rather than surface
196
+ that as an opaque IntegrityError traceback (exit code depending on
197
+ the backend), report it cleanly and exit non-zero.
198
+ """
199
+ existing = (
200
+ session.query(
201
+ authz_schema.AdminUser,
202
+ )
203
+ .where(
204
+ authz_schema.AdminUser.json_path == json_path,
205
+ )
206
+ .first()
207
+ )
208
+ if existing is None:
209
+ return
210
+
211
+ the_console.rule(f"{_describe_admin(json_path)} is already an admin")
212
+ the_console.print("Nothing to do.")
213
+ raise typer.Exit(1)
214
+
215
+
216
+ def _check_admin_user_args(
217
+ installation_path: pathlib.Path,
218
+ admin_user_email: str | None,
219
+ preferred_username: str | None,
220
+ json_path: str | None,
221
+ command: str,
222
+ allow_invalid_json_path: bool = False,
223
+ ) -> tuple[str, str]:
224
+ """Run the validation prolog shared by 'admin-users' add/delete.
225
+
226
+ Loads the installation, validates the discriminator selection,
227
+ resolves and validates the JSONPath, and rejects a RAM-based
228
+ authorization DB.
229
+
230
+ Pass 'allow_invalid_json_path=True' to skip the JSONPath compile
231
+ check -- intended for 'admin-users delete --allow-invalid-json-path',
232
+ so a stored entry whose 'json_path' no longer compiles can still
233
+ be matched and removed.
234
+
235
+ Returns '(dburi, json_path)' -- the values both commands need to
236
+ perform their database update.
237
+
238
+ Raises 'typer.Exit(1)' on any validation failure.
239
+ """
240
+ the_installation = cli_util.get_installation(installation_path)
241
+
242
+ _check_admin_discriminator(admin_user_email, preferred_username, json_path)
243
+
244
+ resolved = cli_util._resolve_json_path(
245
+ the_installation,
246
+ json_path,
247
+ preferred_username,
248
+ admin_user_email,
249
+ allow_invalid=allow_invalid_json_path,
250
+ )
251
+
252
+ dburi = the_installation.authorization_dburi_sync
253
+ cli_util._check_ram_dburi(dburi, command)
254
+
255
+ return dburi, resolved
256
+
257
+
258
+ def _dump(ctx, session):
259
+ if ctx.obj and ctx.obj.get("verbose"):
260
+ _human_dump_admin_users(session)
261
+ else:
262
+ _dump_admin_users(session)
263
+
264
+
265
+ def _human_dump_admin_users(session): # pragma NO COVER UI ONLY
266
+ with session:
267
+ json_paths = [
268
+ admin_user.json_path
269
+ for admin_user in session.query(
270
+ authz_schema.AdminUser,
271
+ )
272
+ ]
273
+
274
+ the_console.rule("Admin users")
275
+
276
+ if not json_paths:
277
+ the_console.print("No admin users configured.")
278
+ return
279
+
280
+ the_console.print(f"Admin users ({len(json_paths)}):")
281
+ for index, json_path in enumerate(json_paths, 1):
282
+ the_console.print(f" {index}. {_describe_admin(json_path)}")
283
+
284
+
285
+ def _dump_admin_users(session): # pragma NO COVER UI ONLY
286
+ with session:
287
+ admin_users = [
288
+ _admin_display(admin_user.json_path)
289
+ for admin_user in session.query(
290
+ authz_schema.AdminUser,
291
+ )
292
+ ]
293
+ print(json.dumps({"admin_users": admin_users}))
294
+
295
+
296
+ @app.command("list")
297
+ def list_admin_users(
298
+ ctx: typer.Context,
299
+ installation_path: types.installation_path_type,
300
+ ): # pragma NO COVER command
301
+ """Show admin users defined in the installation's authz database."""
302
+ the_installation = cli_util.get_installation(installation_path)
303
+ dburi = the_installation.authorization_dburi_sync
304
+
305
+ cli_util._check_ram_dburi(dburi, "admin-users list")
306
+
307
+ session = authz_schema.get_session(engine_url=dburi, init_schema=True)
308
+ _dump(ctx, session)
309
+
310
+
311
+ @app.command("clear")
312
+ def clear_admin_users(
313
+ ctx: typer.Context,
314
+ installation_path: types.installation_path_type,
315
+ ): # pragma NO COVER command
316
+ """Clear admin users from the installation's authz database."""
317
+ the_installation = cli_util.get_installation(installation_path)
318
+ dburi = the_installation.authorization_dburi_sync
319
+
320
+ cli_util._check_ram_dburi(dburi, "admin-users clear")
321
+
322
+ session = authz_schema.get_session(engine_url=dburi, init_schema=True)
323
+
324
+ with session:
325
+ for admin_user in session.query(authz_schema.AdminUser):
326
+ session.delete(admin_user)
327
+ session.commit()
328
+
329
+ _dump(ctx, session)
330
+
331
+
332
+ @app.command("add")
333
+ def add_admin_user(
334
+ ctx: typer.Context,
335
+ installation_path: types.installation_path_type,
336
+ admin_user_email: typing.Annotated[
337
+ str | None,
338
+ typer.Argument(
339
+ metavar="EMAIL",
340
+ help=(
341
+ "Email address to grant admin (the common case). "
342
+ "Mutually exclusive with '--preferred-username' / "
343
+ "'--json-path'."
344
+ ),
345
+ ),
346
+ ] = None,
347
+ preferred_username: str | None = typer.Option(
348
+ None,
349
+ "--preferred-username",
350
+ help="Grant admin by OIDC preferred_username claim.",
351
+ ),
352
+ json_path: str | None = typer.Option(
353
+ None,
354
+ "--json-path",
355
+ help=(
356
+ "Grant admin to any user token matched by this RFC 9535 "
357
+ "JSONPath query."
358
+ ),
359
+ ),
360
+ ): # pragma NO COVER command
361
+ """Add an admin user to the installation's authz database.
362
+
363
+ Exactly one discriminator must be supplied: the positional EMAIL
364
+ (shorthand for the common case), '--preferred-username', or
365
+ '--json-path'. If the resolved entry is already an admin, the
366
+ command reports that and exits non-zero without inserting a
367
+ duplicate row.
368
+ """
369
+ dburi, resolved = _check_admin_user_args(
370
+ installation_path,
371
+ admin_user_email,
372
+ preferred_username,
373
+ json_path,
374
+ "admin-users add",
375
+ )
376
+
377
+ session = authz_schema.get_session(engine_url=dburi, init_schema=True)
378
+
379
+ with session:
380
+ _check_existing_admin(session, resolved)
381
+ admin_user = authz_schema.AdminUser(json_path=resolved)
382
+ session.add(admin_user)
383
+ session.commit()
384
+
385
+ _dump(ctx, session)
386
+
387
+
388
+ @app.command("delete")
389
+ def delete_admin_user(
390
+ ctx: typer.Context,
391
+ installation_path: types.installation_path_type,
392
+ admin_user_email: typing.Annotated[
393
+ str | None,
394
+ typer.Argument(
395
+ metavar="EMAIL",
396
+ help=(
397
+ "Email address to revoke admin from (the common case). "
398
+ "Mutually exclusive with '--preferred-username' / "
399
+ "'--json-path'."
400
+ ),
401
+ ),
402
+ ] = None,
403
+ preferred_username: str | None = typer.Option(
404
+ None,
405
+ "--preferred-username",
406
+ help="Revoke admin by OIDC preferred_username claim.",
407
+ ),
408
+ json_path: str | None = typer.Option(
409
+ None,
410
+ "--json-path",
411
+ help=(
412
+ "Revoke admin from any user token matched by this RFC 9535 "
413
+ "JSONPath query."
414
+ ),
415
+ ),
416
+ allow_invalid_json_path: bool = typer.Option(
417
+ False,
418
+ "--allow-invalid-json-path",
419
+ help=(
420
+ "Skip JSONPath compile-validation of '--json-path' so an "
421
+ "admin entry whose stored 'json_path' no longer compiles "
422
+ "(e.g. because the meta-config filter function it "
423
+ "referenced has been removed) can still be matched and "
424
+ "removed."
425
+ ),
426
+ ),
427
+ ): # pragma NO COVER command
428
+ """Remove an admin user from the installation's authz database.
429
+
430
+ Exactly one discriminator must be supplied: the positional EMAIL
431
+ (shorthand for the common case), '--preferred-username', or
432
+ '--json-path'. The entry is matched by string equality against the
433
+ stored 'json_path'. If no admin entry matches the resolved
434
+ JSONPath, the command reports that and exits non-zero.
435
+ """
436
+ dburi, resolved = _check_admin_user_args(
437
+ installation_path,
438
+ admin_user_email,
439
+ preferred_username,
440
+ json_path,
441
+ "admin-users delete",
442
+ allow_invalid_json_path=allow_invalid_json_path,
443
+ )
444
+
445
+ session = authz_schema.get_session(engine_url=dburi, init_schema=True)
446
+
447
+ with session:
448
+ existing = (
449
+ session.query(
450
+ authz_schema.AdminUser,
451
+ )
452
+ .where(
453
+ authz_schema.AdminUser.json_path == resolved,
454
+ )
455
+ .first()
456
+ )
457
+
458
+ if existing is None:
459
+ the_console.rule(f"{_describe_admin(resolved)} is not an admin")
460
+ the_console.print("Nothing to do.")
461
+ raise typer.Exit(1)
462
+
463
+ session.delete(existing)
464
+ session.commit()
465
+
466
+ _dump(ctx, session)
467
+
468
+
469
+ @app.command("as-yaml")
470
+ def admin_users_as_yaml(
471
+ installation_path: types.installation_path_type,
472
+ output: typing.Annotated[
473
+ pathlib.Path | None,
474
+ typer.Option(
475
+ "--output",
476
+ "-o",
477
+ help=(
478
+ "Write the YAML to this file path. If omitted, the YAML "
479
+ "is written to standard output with no other decoration."
480
+ ),
481
+ ),
482
+ ] = None,
483
+ ): # pragma NO COVER command
484
+ """Dump every admin user as YAML.
485
+
486
+ With '--output', the YAML is written to the given file path;
487
+ otherwise it is written to standard output with no console or rule
488
+ decoration, suitable for piping or redirection.
489
+
490
+ An installation with no admins dumps as 'admin_users: []'. Entries
491
+ whose stored 'json_path' no longer compiles are still rendered
492
+ (they round-trip via 'from-yaml').
493
+ """
494
+ the_installation = cli_util.get_installation(installation_path)
495
+ dburi = the_installation.authorization_dburi_sync
496
+
497
+ cli_util._check_ram_dburi(dburi, "admin-users as-yaml")
498
+
499
+ session = authz_schema.get_session(engine_url=dburi, init_schema=True)
500
+
501
+ with session:
502
+ admin_users = session.query(authz_schema.AdminUser).all()
503
+ yaml_text = _admin_users_as_yaml(admin_users)
504
+
505
+ if output is not None:
506
+ output.write_text(yaml_text)
507
+ else:
508
+ print(yaml_text, end="")
509
+
510
+
511
+ @app.command("from-yaml")
512
+ def admin_users_from_yaml(
513
+ installation_path: types.installation_path_type,
514
+ input_: typing.Annotated[
515
+ pathlib.Path | None,
516
+ typer.Option(
517
+ "--input",
518
+ "-i",
519
+ help=(
520
+ "Read the YAML from this file path. If omitted, the YAML "
521
+ "is read from standard input."
522
+ ),
523
+ ),
524
+ ] = None,
525
+ ): # pragma NO COVER command
526
+ """Replace admin users with the entries in a YAML document.
527
+
528
+ The YAML uses the same shape produced by 'admin-users as-yaml'.
529
+ With '--input', the YAML is read from the given file path;
530
+ otherwise it is read from standard input.
531
+
532
+ All existing admin entries are removed; the YAML's admins (if any)
533
+ are inserted. A 'null' document or an empty 'admin_users' list
534
+ removes every admin entry.
535
+ """
536
+ the_installation = cli_util.get_installation(installation_path)
537
+ dburi = the_installation.authorization_dburi_sync
538
+
539
+ cli_util._check_ram_dburi(dburi, "admin-users from-yaml")
540
+
541
+ if input_ is not None:
542
+ yaml_text = input_.read_text()
543
+ else:
544
+ yaml_text = sys.stdin.read()
545
+
546
+ json_paths = _admin_users_from_jsonable(yaml.safe_load(yaml_text))
547
+
548
+ session = authz_schema.get_session(engine_url=dburi, init_schema=True)
549
+
550
+ with session:
551
+ for admin_user in session.query(authz_schema.AdminUser):
552
+ session.delete(admin_user)
553
+ session.commit()
554
+
555
+ for json_path in json_paths:
556
+ session.add(authz_schema.AdminUser(json_path=json_path))
557
+ session.commit()