paskia 0.9.1__tar.gz → 0.10.0__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.
- {paskia-0.9.1 → paskia-0.10.0}/PKG-INFO +1 -1
- {paskia-0.9.1 → paskia-0.10.0}/paskia/_version.py +2 -2
- {paskia-0.9.1 → paskia-0.10.0}/paskia/db/jsonl.py +2 -2
- {paskia-0.9.1 → paskia-0.10.0}/paskia/db/logging.py +130 -45
- {paskia-0.9.1 → paskia-0.10.0}/paskia/db/operations.py +8 -4
- {paskia-0.9.1 → paskia-0.10.0}/paskia/fastapi/__main__.py +12 -6
- {paskia-0.9.1 → paskia-0.10.0}/paskia/fastapi/admin.py +2 -2
- {paskia-0.9.1 → paskia-0.10.0}/paskia/fastapi/api.py +7 -3
- {paskia-0.9.1 → paskia-0.10.0}/paskia/fastapi/authz.py +3 -8
- {paskia-0.9.1 → paskia-0.10.0}/paskia/fastapi/logging.py +64 -21
- {paskia-0.9.1 → paskia-0.10.0}/paskia/fastapi/mainapp.py +2 -1
- {paskia-0.9.1 → paskia-0.10.0}/paskia/fastapi/remote.py +11 -37
- {paskia-0.9.1 → paskia-0.10.0}/paskia/fastapi/ws.py +12 -35
- paskia-0.10.0/paskia/fastapi/wschat.py +115 -0
- {paskia-0.9.1 → paskia-0.10.0}/paskia/fastapi/wsutil.py +2 -7
- {paskia-0.9.1 → paskia-0.10.0}/paskia/frontend-build/auth/admin/index.html +6 -6
- paskia-0.10.0/paskia/frontend-build/auth/assets/AccessDenied-C29NZI95.css +1 -0
- paskia-0.10.0/paskia/frontend-build/auth/assets/AccessDenied-DAdzg_MJ.js +12 -0
- paskia-0.9.1/paskia/frontend-build/auth/assets/RestrictedAuth-CvR33_Z0.css → paskia-0.10.0/paskia/frontend-build/auth/assets/RestrictedAuth-BOdNrlQB.css +1 -1
- paskia-0.9.1/paskia/frontend-build/auth/assets/RestrictedAuth-DsJXicIw.js → paskia-0.10.0/paskia/frontend-build/auth/assets/RestrictedAuth-BSusdAfp.js +1 -1
- paskia-0.10.0/paskia/frontend-build/auth/assets/_plugin-vue_export-helper-D2l53SUz.js +49 -0
- paskia-0.10.0/paskia/frontend-build/auth/assets/_plugin-vue_export-helper-DYJ24FZK.css +1 -0
- paskia-0.10.0/paskia/frontend-build/auth/assets/admin-BeFvGyD6.js +1 -0
- paskia-0.9.1/paskia/frontend-build/auth/assets/admin-DzzjSg72.css → paskia-0.10.0/paskia/frontend-build/auth/assets/admin-CmNtuH3s.css +1 -1
- paskia-0.9.1/paskia/frontend-build/auth/assets/auth-C7k64Wad.css → paskia-0.10.0/paskia/frontend-build/auth/assets/auth-BKq4T2K2.css +1 -1
- paskia-0.10.0/paskia/frontend-build/auth/assets/auth-DvHf8hgy.js +1 -0
- paskia-0.9.1/paskia/frontend-build/auth/assets/forward-DmqVHZ7e.js → paskia-0.10.0/paskia/frontend-build/auth/assets/forward-C86Jm_Uq.js +1 -1
- paskia-0.10.0/paskia/frontend-build/auth/assets/reset-B8PlNXuP.css +1 -0
- paskia-0.10.0/paskia/frontend-build/auth/assets/reset-D71FG0VL.js +1 -0
- paskia-0.9.1/paskia/frontend-build/auth/assets/restricted-D3AJx3_6.js → paskia-0.10.0/paskia/frontend-build/auth/assets/restricted-CW0drE_k.js +1 -1
- {paskia-0.9.1 → paskia-0.10.0}/paskia/frontend-build/auth/index.html +6 -6
- {paskia-0.9.1 → paskia-0.10.0}/paskia/frontend-build/auth/restricted/index.html +5 -5
- {paskia-0.9.1 → paskia-0.10.0}/paskia/frontend-build/int/forward/index.html +5 -5
- {paskia-0.9.1 → paskia-0.10.0}/paskia/frontend-build/int/reset/index.html +4 -4
- paskia-0.9.1/paskia/fastapi/wschat.py +0 -62
- paskia-0.9.1/paskia/frontend-build/auth/assets/AccessDenied-DPkUS8LZ.css +0 -1
- paskia-0.9.1/paskia/frontend-build/auth/assets/AccessDenied-Fmeb6EtF.js +0 -8
- paskia-0.9.1/paskia/frontend-build/auth/assets/_plugin-vue_export-helper-BTzJAQlS.css +0 -1
- paskia-0.9.1/paskia/frontend-build/auth/assets/_plugin-vue_export-helper-nhjnO_bd.js +0 -2
- paskia-0.9.1/paskia/frontend-build/auth/assets/admin-CPE1pLMm.js +0 -1
- paskia-0.9.1/paskia/frontend-build/auth/assets/auth-YIZvPlW_.js +0 -1
- paskia-0.9.1/paskia/frontend-build/auth/assets/reset-Chtv69AT.css +0 -1
- paskia-0.9.1/paskia/frontend-build/auth/assets/reset-s20PATTN.js +0 -1
- {paskia-0.9.1 → paskia-0.10.0}/.gitignore +0 -0
- {paskia-0.9.1 → paskia-0.10.0}/README.md +0 -0
- {paskia-0.9.1 → paskia-0.10.0}/paskia/__init__.py +0 -0
- {paskia-0.9.1 → paskia-0.10.0}/paskia/aaguid/__init__.py +0 -0
- {paskia-0.9.1 → paskia-0.10.0}/paskia/aaguid/combined_aaguid.json +0 -0
- {paskia-0.9.1 → paskia-0.10.0}/paskia/authsession.py +0 -0
- {paskia-0.9.1 → paskia-0.10.0}/paskia/bootstrap.py +0 -0
- {paskia-0.9.1 → paskia-0.10.0}/paskia/config.py +0 -0
- {paskia-0.9.1 → paskia-0.10.0}/paskia/db/__init__.py +0 -0
- {paskia-0.9.1 → paskia-0.10.0}/paskia/db/background.py +0 -0
- {paskia-0.9.1 → paskia-0.10.0}/paskia/db/migrations.py +0 -0
- {paskia-0.9.1 → paskia-0.10.0}/paskia/db/structs.py +0 -0
- {paskia-0.9.1 → paskia-0.10.0}/paskia/fastapi/__init__.py +0 -0
- {paskia-0.9.1 → paskia-0.10.0}/paskia/fastapi/auth_host.py +0 -0
- {paskia-0.9.1 → paskia-0.10.0}/paskia/fastapi/reset.py +0 -0
- {paskia-0.9.1 → paskia-0.10.0}/paskia/fastapi/response.py +0 -0
- {paskia-0.9.1 → paskia-0.10.0}/paskia/fastapi/session.py +0 -0
- {paskia-0.9.1 → paskia-0.10.0}/paskia/fastapi/user.py +0 -0
- {paskia-0.9.1 → paskia-0.10.0}/paskia/frontend-build/auth/assets/helpers-DzjFIx78.js +0 -0
- {paskia-0.9.1 → paskia-0.10.0}/paskia/frontend-build/auth/assets/pow-2N9bxgAo.js +0 -0
- {paskia-0.9.1 → paskia-0.10.0}/paskia/globals.py +0 -0
- {paskia-0.9.1 → paskia-0.10.0}/paskia/migrate/__init__.py +0 -0
- {paskia-0.9.1 → paskia-0.10.0}/paskia/migrate/sql.py +0 -0
- {paskia-0.9.1 → paskia-0.10.0}/paskia/remoteauth.py +0 -0
- {paskia-0.9.1 → paskia-0.10.0}/paskia/sansio.py +0 -0
- {paskia-0.9.1 → paskia-0.10.0}/pyproject.toml +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: paskia
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.0
|
|
4
4
|
Summary: Passkey Auth made easy: all sites and APIs can be guarded even without any changes on the protected site.
|
|
5
5
|
Project-URL: Homepage, https://git.zi.fi/LeoVasanko/paskia
|
|
6
6
|
Project-URL: Repository, https://github.com/LeoVasanko/paskia
|
|
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
|
|
|
28
28
|
commit_id: COMMIT_ID
|
|
29
29
|
__commit_id__: COMMIT_ID
|
|
30
30
|
|
|
31
|
-
__version__ = version = '0.
|
|
32
|
-
__version_tuple__ = version_tuple = (0,
|
|
31
|
+
__version__ = version = '0.10.0'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 10, 0)
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|
|
@@ -198,7 +198,6 @@ class JsonlStore:
|
|
|
198
198
|
if not diff:
|
|
199
199
|
return
|
|
200
200
|
self._pending_changes.append(create_change_record(action, version, diff, user))
|
|
201
|
-
self._previous_builtins = copy.deepcopy(current)
|
|
202
201
|
|
|
203
202
|
# Log the change with user display name if available
|
|
204
203
|
user_display = None
|
|
@@ -210,7 +209,8 @@ class JsonlStore:
|
|
|
210
209
|
except (ValueError, KeyError):
|
|
211
210
|
user_display = user
|
|
212
211
|
|
|
213
|
-
log_change(action, diff, user_display)
|
|
212
|
+
log_change(action, diff, user_display, self._previous_builtins)
|
|
213
|
+
self._previous_builtins = copy.deepcopy(current)
|
|
214
214
|
|
|
215
215
|
@contextmanager
|
|
216
216
|
def transaction(
|
|
@@ -26,8 +26,7 @@ _RESET = "\033[0m"
|
|
|
26
26
|
_DIM = "\033[2m"
|
|
27
27
|
_PATH_PREFIX = "\033[1;30m" # Dark grey for path prefix (like host in access log)
|
|
28
28
|
_PATH_FINAL = "\033[0m" # Default for final element (like path in access log)
|
|
29
|
-
|
|
30
|
-
_DELETE = "\033[0;31m" # Red for deletions
|
|
29
|
+
_DELETE = "\033[1;31m" # Red for deletions
|
|
31
30
|
_ADD = "\033[0;32m" # Green for additions
|
|
32
31
|
_ACTION = "\033[1;34m" # Bold blue for action name
|
|
33
32
|
_USER = "\033[0;34m" # Blue for user display
|
|
@@ -93,18 +92,34 @@ def _format_path(path: list[str], use_color: bool) -> str:
|
|
|
93
92
|
return f"{_PATH_PREFIX}{prefix}.{_RESET}{_PATH_FINAL}{final}{_RESET}"
|
|
94
93
|
|
|
95
94
|
|
|
95
|
+
def _get_nested(data: dict | None, path: list[str]) -> Any:
|
|
96
|
+
"""Get a nested value from a dict by path, or None if not found."""
|
|
97
|
+
if data is None:
|
|
98
|
+
return None
|
|
99
|
+
current = data
|
|
100
|
+
for key in path:
|
|
101
|
+
if not isinstance(current, dict) or key not in current:
|
|
102
|
+
return None
|
|
103
|
+
current = current[key]
|
|
104
|
+
return current
|
|
105
|
+
|
|
106
|
+
|
|
96
107
|
def _collect_changes(
|
|
97
|
-
diff: dict,
|
|
108
|
+
diff: dict,
|
|
109
|
+
path: list[str],
|
|
110
|
+
changes: list[tuple[str, list[str], Any]],
|
|
111
|
+
previous: dict | None,
|
|
98
112
|
) -> None:
|
|
99
113
|
"""
|
|
100
114
|
Recursively collect changes from a diff into a flat list.
|
|
101
115
|
|
|
102
|
-
Each change is a tuple of (change_type, path, new_value
|
|
103
|
-
change_type is one of: '
|
|
116
|
+
Each change is a tuple of (change_type, path, new_value).
|
|
117
|
+
change_type is one of: 'add', 'update', 'delete'
|
|
104
118
|
"""
|
|
105
119
|
if not isinstance(diff, dict):
|
|
106
|
-
# Leaf value -
|
|
107
|
-
|
|
120
|
+
# Leaf value - check if it existed before
|
|
121
|
+
existed = _get_nested(previous, path) is not None
|
|
122
|
+
changes.append(("update" if existed else "add", path, diff))
|
|
108
123
|
return
|
|
109
124
|
|
|
110
125
|
for key, value in diff.items():
|
|
@@ -112,72 +127,136 @@ def _collect_changes(
|
|
|
112
127
|
# $delete contains a list of keys to delete
|
|
113
128
|
if isinstance(value, list):
|
|
114
129
|
for deleted_key in value:
|
|
115
|
-
changes.append(("delete", path + [str(deleted_key)], None
|
|
130
|
+
changes.append(("delete", path + [str(deleted_key)], None))
|
|
116
131
|
else:
|
|
117
|
-
changes.append(("delete", path + [str(value)], None
|
|
132
|
+
changes.append(("delete", path + [str(value)], None))
|
|
118
133
|
|
|
119
134
|
elif key == "$replace":
|
|
120
|
-
# $replace
|
|
135
|
+
# $replace replaces the entire collection at this path
|
|
136
|
+
# We need to track what was added and what was deleted
|
|
137
|
+
old_collection = _get_nested(previous, path)
|
|
138
|
+
old_keys = (
|
|
139
|
+
set(old_collection.keys())
|
|
140
|
+
if isinstance(old_collection, dict)
|
|
141
|
+
else set()
|
|
142
|
+
)
|
|
143
|
+
new_keys = set(value.keys()) if isinstance(value, dict) else set()
|
|
144
|
+
|
|
145
|
+
# Items that existed before but not in new = deleted
|
|
146
|
+
for deleted_key in old_keys - new_keys:
|
|
147
|
+
changes.append(("delete", path + [str(deleted_key)], None))
|
|
148
|
+
|
|
149
|
+
# Items in new collection
|
|
121
150
|
if isinstance(value, dict):
|
|
122
|
-
# Replacing with a dict - show each key as a replacement
|
|
123
151
|
for rkey, rval in value.items():
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
152
|
+
existed = rkey in old_keys
|
|
153
|
+
changes.append(
|
|
154
|
+
("update" if existed else "add", path + [str(rkey)], rval)
|
|
155
|
+
)
|
|
156
|
+
elif value or not old_keys:
|
|
157
|
+
# Non-dict replacement or empty replacement with nothing before
|
|
158
|
+
changes.append(
|
|
159
|
+
("update" if old_collection is not None else "add", path, value)
|
|
160
|
+
)
|
|
130
161
|
|
|
131
162
|
elif key.startswith("$"):
|
|
132
163
|
# Other special operations (future-proofing)
|
|
133
|
-
changes.append(("
|
|
164
|
+
changes.append(("add", path, {key: value}))
|
|
134
165
|
|
|
135
166
|
else:
|
|
136
|
-
# Regular nested key
|
|
137
|
-
|
|
167
|
+
# Regular nested key - check if this item existed before
|
|
168
|
+
new_path = path + [str(key)]
|
|
169
|
+
existed = _get_nested(previous, new_path) is not None
|
|
170
|
+
if existed:
|
|
171
|
+
# Item exists - recurse to show specific field changes
|
|
172
|
+
_collect_changes(value, new_path, changes, previous)
|
|
173
|
+
else:
|
|
174
|
+
# New item - record as add with full value, don't recurse
|
|
175
|
+
changes.append(("add", new_path, value))
|
|
138
176
|
|
|
139
177
|
|
|
140
|
-
def
|
|
178
|
+
def _format_change_lines(
|
|
141
179
|
change_type: str, path: list[str], value: Any, use_color: bool
|
|
142
|
-
) -> str:
|
|
143
|
-
"""Format a single change as
|
|
144
|
-
path_str = _format_path(path, use_color)
|
|
145
|
-
value_str = _format_value(value, use_color)
|
|
146
|
-
|
|
180
|
+
) -> list[str]:
|
|
181
|
+
"""Format a single change as one or more lines."""
|
|
147
182
|
if change_type == "delete":
|
|
148
|
-
if use_color:
|
|
149
|
-
return f"
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
183
|
+
if not use_color:
|
|
184
|
+
return [f" {'.'.join(path)} ✗"]
|
|
185
|
+
if len(path) == 1:
|
|
186
|
+
return [f" {_DELETE}{path[0]} ✗{_RESET}"]
|
|
187
|
+
prefix = ".".join(path[:-1])
|
|
188
|
+
final = path[-1]
|
|
189
|
+
return [f" {_PATH_PREFIX}{prefix}.{_RESET}{_DELETE}{final} ✗{_RESET}"]
|
|
190
|
+
|
|
191
|
+
if change_type == "add":
|
|
192
|
+
# New item being created - only final element in green
|
|
193
|
+
# For dict values, show children on separate indented lines
|
|
194
|
+
if isinstance(value, dict) and value:
|
|
195
|
+
lines = []
|
|
196
|
+
# First line: path with green final element and grey =
|
|
197
|
+
if not use_color:
|
|
198
|
+
lines.append(f" {'.'.join(path)} =")
|
|
199
|
+
elif len(path) == 1:
|
|
200
|
+
lines.append(f" {_ADD}{path[0]}{_RESET} {_DIM}={_RESET}")
|
|
201
|
+
else:
|
|
202
|
+
prefix = ".".join(path[:-1])
|
|
203
|
+
final = path[-1]
|
|
204
|
+
lines.append(
|
|
205
|
+
f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_DIM}={_RESET}"
|
|
206
|
+
)
|
|
207
|
+
# Child lines: indented key: value, with aligned values
|
|
208
|
+
max_key_len = max(len(k) for k in value.keys())
|
|
209
|
+
field_width = max(max_key_len, 12) # minimum 12 chars
|
|
210
|
+
for k, v in value.items():
|
|
211
|
+
v_str = _format_value(v, use_color)
|
|
212
|
+
padding = " " * (field_width - len(k))
|
|
213
|
+
if use_color:
|
|
214
|
+
lines.append(f" {k}{_DIM}:{_RESET}{padding} {v_str}")
|
|
215
|
+
else:
|
|
216
|
+
lines.append(f" {k}:{padding} {v_str}")
|
|
217
|
+
return lines
|
|
218
|
+
else:
|
|
219
|
+
value_str = _format_value(value, use_color)
|
|
220
|
+
if not use_color:
|
|
221
|
+
return [f" {'.'.join(path)} = {value_str}"]
|
|
222
|
+
if len(path) == 1:
|
|
223
|
+
return [f" {_ADD}{path[0]}{_RESET} {_DIM}={_RESET} {value_str}"]
|
|
224
|
+
prefix = ".".join(path[:-1])
|
|
225
|
+
final = path[-1]
|
|
226
|
+
return [
|
|
227
|
+
f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_DIM}={_RESET} {value_str}"
|
|
228
|
+
]
|
|
229
|
+
|
|
230
|
+
# update: Existing item being updated - normal path colors
|
|
231
|
+
value_str = _format_value(value, use_color)
|
|
232
|
+
path_str = _format_path(path, use_color)
|
|
158
233
|
if use_color:
|
|
159
|
-
return f" {
|
|
160
|
-
return f"
|
|
234
|
+
return [f" {path_str} {_DIM}={_RESET} {value_str}"]
|
|
235
|
+
return [f" {path_str} = {value_str}"]
|
|
161
236
|
|
|
162
237
|
|
|
163
|
-
def format_diff(diff: dict) -> list[str]:
|
|
238
|
+
def format_diff(diff: dict, previous: dict | None = None) -> list[str]:
|
|
164
239
|
"""
|
|
165
240
|
Format a JSON diff as human-readable lines.
|
|
166
241
|
|
|
242
|
+
Args:
|
|
243
|
+
diff: The JSON diff dict
|
|
244
|
+
previous: The previous state dict (for determining add vs update)
|
|
245
|
+
|
|
167
246
|
Returns a list of formatted lines (without newlines).
|
|
168
247
|
Single changes return one line, multiple changes return multiple lines.
|
|
169
248
|
"""
|
|
170
249
|
use_color = _use_color()
|
|
171
|
-
changes: list[tuple[str, list[str], Any
|
|
172
|
-
_collect_changes(diff, [], changes)
|
|
250
|
+
changes: list[tuple[str, list[str], Any]] = []
|
|
251
|
+
_collect_changes(diff, [], changes, previous)
|
|
173
252
|
|
|
174
253
|
if not changes:
|
|
175
254
|
return []
|
|
176
255
|
|
|
177
256
|
# Format each change
|
|
178
257
|
lines = []
|
|
179
|
-
for change_type, path, value
|
|
180
|
-
lines.
|
|
258
|
+
for change_type, path, value in changes:
|
|
259
|
+
lines.extend(_format_change_lines(change_type, path, value, use_color))
|
|
181
260
|
|
|
182
261
|
return lines
|
|
183
262
|
|
|
@@ -198,7 +277,12 @@ def format_action_header(action: str, user_display: str | None = None) -> str:
|
|
|
198
277
|
return action
|
|
199
278
|
|
|
200
279
|
|
|
201
|
-
def log_change(
|
|
280
|
+
def log_change(
|
|
281
|
+
action: str,
|
|
282
|
+
diff: dict,
|
|
283
|
+
user_display: str | None = None,
|
|
284
|
+
previous: dict | None = None,
|
|
285
|
+
) -> None:
|
|
202
286
|
"""
|
|
203
287
|
Log a database change with pretty-printed diff.
|
|
204
288
|
|
|
@@ -206,9 +290,10 @@ def log_change(action: str, diff: dict, user_display: str | None = None) -> None
|
|
|
206
290
|
action: The action name (e.g., "login", "admin:delete_user")
|
|
207
291
|
diff: The JSON diff dict
|
|
208
292
|
user_display: Optional display name of the user who performed the action
|
|
293
|
+
previous: The previous state dict (for determining add vs update)
|
|
209
294
|
"""
|
|
210
295
|
header = format_action_header(action, user_display)
|
|
211
|
-
diff_lines = format_diff(diff)
|
|
296
|
+
diff_lines = format_diff(diff, previous)
|
|
212
297
|
|
|
213
298
|
if not diff_lines:
|
|
214
299
|
logger.info(header)
|
|
@@ -517,16 +517,18 @@ def set_session_host(key: str, host: str, *, ctx: SessionContext | None = None)
|
|
|
517
517
|
update_session(key, host=host, ctx=ctx)
|
|
518
518
|
|
|
519
519
|
|
|
520
|
-
def delete_session(
|
|
520
|
+
def delete_session(
|
|
521
|
+
key: str, *, ctx: SessionContext | None = None, action: str = "delete_session"
|
|
522
|
+
) -> None:
|
|
521
523
|
"""Delete a session.
|
|
522
524
|
|
|
523
525
|
The acting user should be logged via ctx.
|
|
524
|
-
For user logout, pass ctx of the user's session.
|
|
526
|
+
For user logout, pass ctx of the user's session and action="logout".
|
|
525
527
|
For admin terminating a session, pass admin's ctx.
|
|
526
528
|
"""
|
|
527
529
|
if key not in _db.sessions:
|
|
528
530
|
raise ValueError("Session not found")
|
|
529
|
-
with _db.transaction(
|
|
531
|
+
with _db.transaction(action, ctx):
|
|
530
532
|
del _db.sessions[key]
|
|
531
533
|
|
|
532
534
|
|
|
@@ -554,6 +556,7 @@ def create_reset_token(
|
|
|
554
556
|
token_type: str,
|
|
555
557
|
*,
|
|
556
558
|
ctx: SessionContext | None = None,
|
|
559
|
+
user: str | None = None,
|
|
557
560
|
) -> None:
|
|
558
561
|
"""Create a reset token from a passphrase.
|
|
559
562
|
|
|
@@ -561,13 +564,14 @@ def create_reset_token(
|
|
|
561
564
|
For self-service (user creating own recovery link), pass user's ctx.
|
|
562
565
|
For admin operations, pass admin's ctx.
|
|
563
566
|
For system operations (bootstrap), pass neither to log no user.
|
|
567
|
+
For API operations where ctx is not available but user is known, pass user.
|
|
564
568
|
"""
|
|
565
569
|
key = _reset_key(passphrase)
|
|
566
570
|
if key in _db.reset_tokens:
|
|
567
571
|
raise ValueError("Reset token already exists")
|
|
568
572
|
if user_uuid not in _db.users:
|
|
569
573
|
raise ValueError(f"User {user_uuid} not found")
|
|
570
|
-
with _db.transaction("create_reset_token", ctx):
|
|
574
|
+
with _db.transaction("create_reset_token", ctx, user=user):
|
|
571
575
|
_db.reset_tokens[key] = ResetToken(
|
|
572
576
|
user_uuid=user_uuid, expiry=expiry, token_type=token_type
|
|
573
577
|
)
|
|
@@ -7,12 +7,12 @@ from urllib.parse import urlparse
|
|
|
7
7
|
|
|
8
8
|
from fastapi_vue.hostutil import parse_endpoint
|
|
9
9
|
from uvicorn import Config, Server
|
|
10
|
+
from uvicorn import run as uvicorn_run
|
|
10
11
|
|
|
11
12
|
from paskia import globals as _globals
|
|
12
13
|
from paskia.bootstrap import bootstrap_if_needed
|
|
13
14
|
from paskia.config import PaskiaConfig
|
|
14
15
|
from paskia.db.background import flush
|
|
15
|
-
from paskia.fastapi import app as fastapi_app
|
|
16
16
|
from paskia.fastapi import reset as reset_cmd
|
|
17
17
|
from paskia.util import startupbox
|
|
18
18
|
from paskia.util.hostutil import normalize_origin
|
|
@@ -188,7 +188,7 @@ def main():
|
|
|
188
188
|
devmode = bool(os.environ.get("FASTAPI_VUE_FRONTEND_URL"))
|
|
189
189
|
|
|
190
190
|
run_kwargs: dict = {
|
|
191
|
-
"log_level": "
|
|
191
|
+
"log_level": "warning", # Suppress startup messages; we use custom logging
|
|
192
192
|
"access_log": False, # We use custom AccessLogMiddleware instead
|
|
193
193
|
}
|
|
194
194
|
|
|
@@ -199,8 +199,6 @@ def main():
|
|
|
199
199
|
raise SystemExit(f"Dev mode requires localhost:4402, got {host}:{port}")
|
|
200
200
|
run_kwargs["reload"] = True
|
|
201
201
|
run_kwargs["reload_dirs"] = ["paskia"]
|
|
202
|
-
# Suppress uvicorn startup messages in dev mode
|
|
203
|
-
run_kwargs["log_level"] = "warning"
|
|
204
202
|
|
|
205
203
|
async def async_main():
|
|
206
204
|
await _globals.init(
|
|
@@ -220,10 +218,18 @@ def main():
|
|
|
220
218
|
async with asyncio.TaskGroup() as tg:
|
|
221
219
|
for ep in endpoints:
|
|
222
220
|
tg.create_task(
|
|
223
|
-
Server(
|
|
221
|
+
Server(
|
|
222
|
+
Config(app="paskia.fastapi:app", **run_kwargs, **ep)
|
|
223
|
+
).serve()
|
|
224
224
|
)
|
|
225
|
+
elif devmode:
|
|
226
|
+
# Use uvicorn.run for proper reload support (it handles subprocess spawning)
|
|
227
|
+
ep = endpoints[0]
|
|
228
|
+
uvicorn_run("paskia.fastapi:app", **run_kwargs, **ep)
|
|
225
229
|
else:
|
|
226
|
-
server = Server(
|
|
230
|
+
server = Server(
|
|
231
|
+
Config(app="paskia.fastapi:app", **run_kwargs, **endpoints[0])
|
|
232
|
+
)
|
|
227
233
|
await server.serve()
|
|
228
234
|
|
|
229
235
|
try:
|
|
@@ -127,7 +127,7 @@ async def admin_create_org(
|
|
|
127
127
|
db.create_org(org, ctx=ctx)
|
|
128
128
|
# Grant requested permissions to the new org
|
|
129
129
|
for perm in permissions:
|
|
130
|
-
db.add_permission_to_org(str(org.uuid), perm)
|
|
130
|
+
db.add_permission_to_org(str(org.uuid), perm, ctx=ctx)
|
|
131
131
|
|
|
132
132
|
return {"uuid": str(org.uuid)}
|
|
133
133
|
|
|
@@ -706,7 +706,7 @@ async def admin_delete_user_session(
|
|
|
706
706
|
if not target_session or target_session.user_uuid != user_uuid:
|
|
707
707
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
708
708
|
|
|
709
|
-
db.delete_session(session_id, ctx=ctx)
|
|
709
|
+
db.delete_session(session_id, ctx=ctx, action="admin:delete_session")
|
|
710
710
|
|
|
711
711
|
# Check if admin terminated their own session
|
|
712
712
|
current_terminated = session_id == auth
|
|
@@ -78,7 +78,7 @@ async def validate_token(
|
|
|
78
78
|
try:
|
|
79
79
|
ctx = await authz.verify(
|
|
80
80
|
auth,
|
|
81
|
-
perm,
|
|
81
|
+
" ".join(perm).split(),
|
|
82
82
|
host=request.headers.get("host"),
|
|
83
83
|
max_age=max_age,
|
|
84
84
|
)
|
|
@@ -94,6 +94,7 @@ async def validate_token(
|
|
|
94
94
|
ip=request.client.host if request.client else "",
|
|
95
95
|
user_agent=request.headers.get("user-agent") or "",
|
|
96
96
|
expiry=expires(),
|
|
97
|
+
ctx=ctx,
|
|
97
98
|
)
|
|
98
99
|
session.set_session_cookie(response, auth)
|
|
99
100
|
renewed = True
|
|
@@ -130,7 +131,10 @@ async def forward_authentication(
|
|
|
130
131
|
"""
|
|
131
132
|
try:
|
|
132
133
|
ctx = await authz.verify(
|
|
133
|
-
auth,
|
|
134
|
+
auth,
|
|
135
|
+
" ".join(perm).split(),
|
|
136
|
+
host=request.headers.get("host"),
|
|
137
|
+
max_age=max_age,
|
|
134
138
|
)
|
|
135
139
|
# Build permission scopes for Remote-Groups header
|
|
136
140
|
role_permissions = (
|
|
@@ -248,7 +252,7 @@ async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE):
|
|
|
248
252
|
if not ctx:
|
|
249
253
|
return {"message": "Already logged out"}
|
|
250
254
|
with suppress(Exception):
|
|
251
|
-
db.delete_session(auth, ctx=ctx)
|
|
255
|
+
db.delete_session(auth, ctx=ctx, action="logout")
|
|
252
256
|
session.clear_session_cookie(response)
|
|
253
257
|
return {"message": "Logged out successfully"}
|
|
254
258
|
|
|
@@ -2,6 +2,7 @@ import logging
|
|
|
2
2
|
|
|
3
3
|
from fastapi import HTTPException
|
|
4
4
|
|
|
5
|
+
from paskia.fastapi.logging import log_permission_denied
|
|
5
6
|
from paskia.util import permutil, sessionutil
|
|
6
7
|
|
|
7
8
|
logger = logging.getLogger(__name__)
|
|
@@ -93,20 +94,14 @@ async def verify(
|
|
|
93
94
|
logger.warning(f"Invalid max_age format '{max_age}': {e}")
|
|
94
95
|
|
|
95
96
|
if not match(ctx, perm):
|
|
96
|
-
# Determine which permissions are missing for clearer diagnostics
|
|
97
97
|
effective_scopes = (
|
|
98
98
|
{p.scope for p in (ctx.permissions or [])}
|
|
99
99
|
if ctx.permissions
|
|
100
100
|
else set(ctx.role.permissions or [])
|
|
101
101
|
)
|
|
102
102
|
missing = sorted(set(perm) - effective_scopes)
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
getattr(ctx.user, "uuid", "?"),
|
|
106
|
-
getattr(ctx.role, "display_name", "?"),
|
|
107
|
-
missing,
|
|
108
|
-
perm,
|
|
109
|
-
list(effective_scopes),
|
|
103
|
+
log_permission_denied(
|
|
104
|
+
ctx, perm, missing, require_all=(match == permutil.has_all)
|
|
110
105
|
)
|
|
111
106
|
raise AuthException(
|
|
112
107
|
status_code=403, mode="forbidden", detail="Permission required"
|
|
@@ -4,8 +4,12 @@ import logging
|
|
|
4
4
|
import sys
|
|
5
5
|
import time
|
|
6
6
|
from ipaddress import IPv6Address
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
7
8
|
|
|
8
9
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from paskia.db.structs import SessionContext
|
|
9
13
|
from starlette.requests import Request
|
|
10
14
|
from starlette.responses import Response
|
|
11
15
|
|
|
@@ -13,18 +17,24 @@ logger = logging.getLogger("paskia.access")
|
|
|
13
17
|
|
|
14
18
|
_RESET = "\033[0m"
|
|
15
19
|
_STATUS_INFO = "\033[32m" # 1xx (green)
|
|
16
|
-
_STATUS_OK = "\033[92m" # 2xx (bright green)
|
|
20
|
+
_STATUS_OK = "\033[1;92m" # 2xx (bright green)
|
|
17
21
|
_STATUS_REDIRECT = "\033[32m" # 3xx (green)
|
|
18
22
|
_STATUS_CLIENT_ERR = "\033[0;31m" # 4xx (red)
|
|
19
|
-
_STATUS_SERVER_ERR = "\033[1;
|
|
23
|
+
_STATUS_SERVER_ERR = "\033[1;91m" # 5xx (bold bright red)
|
|
20
24
|
_METHOD_READ = "\033[0;34m" # GET, HEAD, OPTIONS (blue)
|
|
21
|
-
_METHOD_WRITE = "\033[1;
|
|
22
|
-
_HOST = "\033[
|
|
23
|
-
_PATH = "\033[
|
|
24
|
-
_TIMING = "\033[
|
|
25
|
-
_WS_OPEN = "\033[1;
|
|
26
|
-
_WS_CLOSE = "\033[
|
|
27
|
-
_WS_STATUS = "\033[
|
|
25
|
+
_METHOD_WRITE = "\033[1;94m" # POST, PUT, DELETE, PATCH (bold bright blue)
|
|
26
|
+
_HOST = "\033[38;5;242m" # hostname (dark grey)
|
|
27
|
+
_PATH = "\033[38;5;250m" # path (white)
|
|
28
|
+
_TIMING = "\033[38;5;242m" # timing/devmode (dark grey)
|
|
29
|
+
_WS_OPEN = "\033[1;93m" # WebSocket connect (bold bright yellow)
|
|
30
|
+
_WS_CLOSE = "\033[33m" # WebSocket disconnect (yellow)
|
|
31
|
+
_WS_STATUS = "\033[38;5;242m" # WebSocket close status (dark grey)
|
|
32
|
+
_AUTHZ_DENIED = "\033[0;31m" # Permission denied (red)
|
|
33
|
+
_AUTHZ_USER = "\033[1;34m" # User info (light blue)
|
|
34
|
+
_AUTHZ_ORG = "\033[34m" # User info (blue)
|
|
35
|
+
_AUTHZ_NEEDS = "\033[1;38;5;231m" # Needs (brightest white)
|
|
36
|
+
_AUTHZ_MISSING = "\033[1;31m" # Missing scope (bold red)
|
|
37
|
+
_AUTHZ_GRANTED = "\033[0;32m" # Granted scope (green)
|
|
28
38
|
|
|
29
39
|
|
|
30
40
|
def format_ipv6_network(ip: str) -> str:
|
|
@@ -41,8 +51,8 @@ def format_ipv6_network(ip: str) -> str:
|
|
|
41
51
|
network_int >>= 16
|
|
42
52
|
# Compress consecutive zero groups
|
|
43
53
|
result = ":".join(groups) + "::"
|
|
44
|
-
# Simplify leading zeros in groups and compress
|
|
45
|
-
return str(IPv6Address(result + "0"))
|
|
54
|
+
# Simplify leading zeros in groups and compress, then strip trailing ::
|
|
55
|
+
return str(IPv6Address(result + "0")).removesuffix("::")
|
|
46
56
|
except Exception:
|
|
47
57
|
return ip
|
|
48
58
|
|
|
@@ -83,7 +93,7 @@ def format_access_log(
|
|
|
83
93
|
use_color = sys.stderr.isatty()
|
|
84
94
|
|
|
85
95
|
# Format components with fixed widths for alignment
|
|
86
|
-
ip = format_client_ip(client).ljust(
|
|
96
|
+
ip = format_client_ip(client).ljust(19) # IPv6 network max 19 chars
|
|
87
97
|
timing = f"{duration_ms:.0f}ms"
|
|
88
98
|
method_padded = method.ljust(7) # Longest method is OPTIONS (7)
|
|
89
99
|
|
|
@@ -116,25 +126,39 @@ def _next_ws_id() -> int:
|
|
|
116
126
|
return ws_id
|
|
117
127
|
|
|
118
128
|
|
|
119
|
-
def log_ws_open(
|
|
129
|
+
def log_ws_open(ws) -> int:
|
|
120
130
|
"""Log WebSocket connection open. Returns connection ID for use in close."""
|
|
121
131
|
use_color = sys.stderr.isatty()
|
|
122
132
|
ws_id = _next_ws_id()
|
|
123
133
|
|
|
124
|
-
|
|
134
|
+
client = ws.client.host if ws.client else "-"
|
|
135
|
+
host = ws.headers.get("host", "-")
|
|
136
|
+
path = ws.url.path
|
|
137
|
+
origin = ws.headers.get("origin")
|
|
138
|
+
|
|
139
|
+
ip = format_client_ip(client).ljust(19)
|
|
125
140
|
id_str = f"{ws_id:02d}".ljust(7) # Align with method field (7 chars)
|
|
126
141
|
|
|
142
|
+
# Determine if origin should be shown (omit when same as host)
|
|
143
|
+
# Origin header includes scheme (e.g., "https://example.com"), compare host part
|
|
144
|
+
origin_host = origin.split("://", 1)[-1] if origin else None
|
|
145
|
+
show_origin = origin_host and origin_host != host
|
|
146
|
+
|
|
127
147
|
if use_color:
|
|
128
148
|
# 🔌 aligned with status (takes ~2 char width), ID aligned with method
|
|
129
149
|
prefix = f"🔌 {_WS_OPEN}{id_str}{_RESET}"
|
|
130
150
|
host_str = f"{_HOST}{host}{_RESET}"
|
|
131
151
|
path_str = f"{_PATH}{path}{_RESET}"
|
|
152
|
+
origin_str = (
|
|
153
|
+
f" {_RESET}from {_HOST}{origin_host}{_RESET}" if show_origin else ""
|
|
154
|
+
)
|
|
132
155
|
else:
|
|
133
156
|
prefix = f"WS+ {id_str}"
|
|
134
157
|
host_str = host
|
|
135
158
|
path_str = path
|
|
159
|
+
origin_str = f" from {origin_host}" if show_origin else ""
|
|
136
160
|
|
|
137
|
-
logger.info(f"{ip} {prefix} {host_str}{path_str}")
|
|
161
|
+
logger.info(f"{ip} {prefix} {host_str}{path_str}{origin_str}")
|
|
138
162
|
return ws_id
|
|
139
163
|
|
|
140
164
|
|
|
@@ -158,15 +182,12 @@ WS_CLOSE_CODES = {
|
|
|
158
182
|
}
|
|
159
183
|
|
|
160
184
|
|
|
161
|
-
def log_ws_close(
|
|
162
|
-
client: str, ws_id: int, close_code: int | None, duration_ms: float
|
|
163
|
-
) -> None:
|
|
185
|
+
def log_ws_close(ws_id: int, close_code: int | None, duration: float) -> None:
|
|
164
186
|
"""Log WebSocket connection close with duration and status."""
|
|
165
187
|
use_color = sys.stderr.isatty()
|
|
166
188
|
|
|
167
|
-
ip = format_client_ip(client).ljust(15)
|
|
168
189
|
id_str = f"{ws_id:02d}".ljust(7) # Align with method field (7 chars)
|
|
169
|
-
timing = f"{
|
|
190
|
+
timing = f"{duration * 1000:.0f}ms"
|
|
170
191
|
|
|
171
192
|
# Convert close code to status text
|
|
172
193
|
if close_code is None:
|
|
@@ -184,7 +205,27 @@ def log_ws_close(
|
|
|
184
205
|
status_str = status
|
|
185
206
|
timing_str = timing
|
|
186
207
|
|
|
187
|
-
logger.info(f"{
|
|
208
|
+
logger.info(f"{' ' * 19} {prefix} {status_str} {timing_str}")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def log_permission_denied(
|
|
212
|
+
ctx: "SessionContext", required: list[str], missing: list[str], *, require_all: bool
|
|
213
|
+
) -> None:
|
|
214
|
+
"""Log permission denied with org, role, user and highlighted missing scopes."""
|
|
215
|
+
missing_set = set(missing)
|
|
216
|
+
scopes = " ".join(
|
|
217
|
+
f"{_AUTHZ_MISSING}{s}✗{_RESET}"
|
|
218
|
+
if s in missing_set
|
|
219
|
+
else f"{_AUTHZ_GRANTED}{s}✓{_RESET}"
|
|
220
|
+
for s in required
|
|
221
|
+
)
|
|
222
|
+
n = "" if len(required) == 1 else " all" if require_all else " any"
|
|
223
|
+
logger.warning(
|
|
224
|
+
f"{_AUTHZ_DENIED}Permission denied{_RESET} "
|
|
225
|
+
f"{_AUTHZ_USER}{ctx.user.display_name}{_RESET} "
|
|
226
|
+
f"{_AUTHZ_ORG}({ctx.org.display_name} {ctx.role.display_name}){_RESET} "
|
|
227
|
+
f"{_AUTHZ_NEEDS}needs{n}:{_RESET} {scopes}"
|
|
228
|
+
)
|
|
188
229
|
|
|
189
230
|
|
|
190
231
|
class AccessLogMiddleware(BaseHTTPMiddleware):
|
|
@@ -216,3 +257,5 @@ def configure_access_logging():
|
|
|
216
257
|
logger.addHandler(handler)
|
|
217
258
|
logger.setLevel(logging.INFO)
|
|
218
259
|
logger.propagate = False
|
|
260
|
+
# Suppress watchfiles "X changes detected" INFO messages (keep WARNING for reload notification)
|
|
261
|
+
logging.getLogger("watchfiles.main").setLevel(logging.WARNING)
|
|
@@ -20,6 +20,8 @@ from paskia.util import hostutil, passphrase, vitedev
|
|
|
20
20
|
configure_access_logging()
|
|
21
21
|
configure_db_logging()
|
|
22
22
|
|
|
23
|
+
_access_logger = logging.getLogger("paskia.access")
|
|
24
|
+
|
|
23
25
|
# Vue Frontend static files
|
|
24
26
|
frontend = Frontend(
|
|
25
27
|
Path(__file__).parent.parent / "frontend-build",
|
|
@@ -59,7 +61,6 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
|
|
|
59
61
|
if frontend.devmode:
|
|
60
62
|
logging.getLogger("uvicorn").setLevel(logging.INFO)
|
|
61
63
|
logging.getLogger("uvicorn.error").setLevel(logging.WARNING)
|
|
62
|
-
|
|
63
64
|
await frontend.load()
|
|
64
65
|
await start_background()
|
|
65
66
|
yield
|