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.
- {soliplex-0.67/src/soliplex.egg-info → soliplex-0.67.2}/PKG-INFO +1 -1
- {soliplex-0.67 → soliplex-0.67.2}/pyproject.toml +1 -6
- soliplex-0.67.2/src/soliplex/cli/admin_users.py +557 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/cli/audit.py +152 -2
- soliplex-0.67.2/src/soliplex/cli/cli_util.py +108 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/cli/room_authz.py +161 -110
- {soliplex-0.67 → soliplex-0.67.2/src/soliplex.egg-info}/PKG-INFO +1 -1
- soliplex-0.67/src/soliplex/cli/admin_users.py +0 -276
- soliplex-0.67/src/soliplex/cli/cli_util.py +0 -36
- {soliplex-0.67 → soliplex-0.67.2}/LICENSE +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/MANIFEST.in +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/README.md +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/example/skills/bare-bones/SKILL.md +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/setup.cfg +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/agents.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/agui/__init__.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/agui/parser.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/agui/persistence.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/agui/schema.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/agui/util.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/auth.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/authn.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/authz/__init__.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/authz/persistence.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/authz/schema.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/cli/main.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/cli/ollama.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/cli/serve.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/cli/types.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/completions.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/config/__init__.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/config/_utils.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/config/agents.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/config/agui.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/config/authsystem.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/config/completions.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/config/exceptions.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/config/installation.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/config/logfire.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/config/meta.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/config/quizzes.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/config/rag.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/config/rooms.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/config/routing.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/config/secrets.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/config/skills.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/config/tools.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/examples.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/installation.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/log_ingest.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/loggers.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/main.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/mcp_auth.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/mcp_client.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/mcp_server.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/models.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/ollama.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/quizzes.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/secrets.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/skills/bwrap_sandbox/SKILL.md +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/skills/bwrap_sandbox/__init__.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/titles.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/tools/__init__.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/tools/agui_run_feedback.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/tools/file_uploads.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/tools/rag.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/tui/cli.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/tui/main.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/tui/rest_api.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/tui/serve.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/util.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/views/__init__.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/views/agui.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/views/authn.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/views/authz.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/views/completions.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/views/file_uploads.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/views/installation.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/views/log_ingest.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/views/quizzes.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/views/rooms.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex/views/streaming.py +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex.egg-info/SOURCES.txt +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex.egg-info/dependency_links.txt +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex.egg-info/entry_points.txt +0 -0
- {soliplex-0.67 → soliplex-0.67.2}/src/soliplex.egg-info/requires.txt +0 -0
- {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()
|