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.
Files changed (69) hide show
  1. {paskia-0.9.1 → paskia-0.10.0}/PKG-INFO +1 -1
  2. {paskia-0.9.1 → paskia-0.10.0}/paskia/_version.py +2 -2
  3. {paskia-0.9.1 → paskia-0.10.0}/paskia/db/jsonl.py +2 -2
  4. {paskia-0.9.1 → paskia-0.10.0}/paskia/db/logging.py +130 -45
  5. {paskia-0.9.1 → paskia-0.10.0}/paskia/db/operations.py +8 -4
  6. {paskia-0.9.1 → paskia-0.10.0}/paskia/fastapi/__main__.py +12 -6
  7. {paskia-0.9.1 → paskia-0.10.0}/paskia/fastapi/admin.py +2 -2
  8. {paskia-0.9.1 → paskia-0.10.0}/paskia/fastapi/api.py +7 -3
  9. {paskia-0.9.1 → paskia-0.10.0}/paskia/fastapi/authz.py +3 -8
  10. {paskia-0.9.1 → paskia-0.10.0}/paskia/fastapi/logging.py +64 -21
  11. {paskia-0.9.1 → paskia-0.10.0}/paskia/fastapi/mainapp.py +2 -1
  12. {paskia-0.9.1 → paskia-0.10.0}/paskia/fastapi/remote.py +11 -37
  13. {paskia-0.9.1 → paskia-0.10.0}/paskia/fastapi/ws.py +12 -35
  14. paskia-0.10.0/paskia/fastapi/wschat.py +115 -0
  15. {paskia-0.9.1 → paskia-0.10.0}/paskia/fastapi/wsutil.py +2 -7
  16. {paskia-0.9.1 → paskia-0.10.0}/paskia/frontend-build/auth/admin/index.html +6 -6
  17. paskia-0.10.0/paskia/frontend-build/auth/assets/AccessDenied-C29NZI95.css +1 -0
  18. paskia-0.10.0/paskia/frontend-build/auth/assets/AccessDenied-DAdzg_MJ.js +12 -0
  19. 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
  20. 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
  21. paskia-0.10.0/paskia/frontend-build/auth/assets/_plugin-vue_export-helper-D2l53SUz.js +49 -0
  22. paskia-0.10.0/paskia/frontend-build/auth/assets/_plugin-vue_export-helper-DYJ24FZK.css +1 -0
  23. paskia-0.10.0/paskia/frontend-build/auth/assets/admin-BeFvGyD6.js +1 -0
  24. 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
  25. 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
  26. paskia-0.10.0/paskia/frontend-build/auth/assets/auth-DvHf8hgy.js +1 -0
  27. 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
  28. paskia-0.10.0/paskia/frontend-build/auth/assets/reset-B8PlNXuP.css +1 -0
  29. paskia-0.10.0/paskia/frontend-build/auth/assets/reset-D71FG0VL.js +1 -0
  30. 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
  31. {paskia-0.9.1 → paskia-0.10.0}/paskia/frontend-build/auth/index.html +6 -6
  32. {paskia-0.9.1 → paskia-0.10.0}/paskia/frontend-build/auth/restricted/index.html +5 -5
  33. {paskia-0.9.1 → paskia-0.10.0}/paskia/frontend-build/int/forward/index.html +5 -5
  34. {paskia-0.9.1 → paskia-0.10.0}/paskia/frontend-build/int/reset/index.html +4 -4
  35. paskia-0.9.1/paskia/fastapi/wschat.py +0 -62
  36. paskia-0.9.1/paskia/frontend-build/auth/assets/AccessDenied-DPkUS8LZ.css +0 -1
  37. paskia-0.9.1/paskia/frontend-build/auth/assets/AccessDenied-Fmeb6EtF.js +0 -8
  38. paskia-0.9.1/paskia/frontend-build/auth/assets/_plugin-vue_export-helper-BTzJAQlS.css +0 -1
  39. paskia-0.9.1/paskia/frontend-build/auth/assets/_plugin-vue_export-helper-nhjnO_bd.js +0 -2
  40. paskia-0.9.1/paskia/frontend-build/auth/assets/admin-CPE1pLMm.js +0 -1
  41. paskia-0.9.1/paskia/frontend-build/auth/assets/auth-YIZvPlW_.js +0 -1
  42. paskia-0.9.1/paskia/frontend-build/auth/assets/reset-Chtv69AT.css +0 -1
  43. paskia-0.9.1/paskia/frontend-build/auth/assets/reset-s20PATTN.js +0 -1
  44. {paskia-0.9.1 → paskia-0.10.0}/.gitignore +0 -0
  45. {paskia-0.9.1 → paskia-0.10.0}/README.md +0 -0
  46. {paskia-0.9.1 → paskia-0.10.0}/paskia/__init__.py +0 -0
  47. {paskia-0.9.1 → paskia-0.10.0}/paskia/aaguid/__init__.py +0 -0
  48. {paskia-0.9.1 → paskia-0.10.0}/paskia/aaguid/combined_aaguid.json +0 -0
  49. {paskia-0.9.1 → paskia-0.10.0}/paskia/authsession.py +0 -0
  50. {paskia-0.9.1 → paskia-0.10.0}/paskia/bootstrap.py +0 -0
  51. {paskia-0.9.1 → paskia-0.10.0}/paskia/config.py +0 -0
  52. {paskia-0.9.1 → paskia-0.10.0}/paskia/db/__init__.py +0 -0
  53. {paskia-0.9.1 → paskia-0.10.0}/paskia/db/background.py +0 -0
  54. {paskia-0.9.1 → paskia-0.10.0}/paskia/db/migrations.py +0 -0
  55. {paskia-0.9.1 → paskia-0.10.0}/paskia/db/structs.py +0 -0
  56. {paskia-0.9.1 → paskia-0.10.0}/paskia/fastapi/__init__.py +0 -0
  57. {paskia-0.9.1 → paskia-0.10.0}/paskia/fastapi/auth_host.py +0 -0
  58. {paskia-0.9.1 → paskia-0.10.0}/paskia/fastapi/reset.py +0 -0
  59. {paskia-0.9.1 → paskia-0.10.0}/paskia/fastapi/response.py +0 -0
  60. {paskia-0.9.1 → paskia-0.10.0}/paskia/fastapi/session.py +0 -0
  61. {paskia-0.9.1 → paskia-0.10.0}/paskia/fastapi/user.py +0 -0
  62. {paskia-0.9.1 → paskia-0.10.0}/paskia/frontend-build/auth/assets/helpers-DzjFIx78.js +0 -0
  63. {paskia-0.9.1 → paskia-0.10.0}/paskia/frontend-build/auth/assets/pow-2N9bxgAo.js +0 -0
  64. {paskia-0.9.1 → paskia-0.10.0}/paskia/globals.py +0 -0
  65. {paskia-0.9.1 → paskia-0.10.0}/paskia/migrate/__init__.py +0 -0
  66. {paskia-0.9.1 → paskia-0.10.0}/paskia/migrate/sql.py +0 -0
  67. {paskia-0.9.1 → paskia-0.10.0}/paskia/remoteauth.py +0 -0
  68. {paskia-0.9.1 → paskia-0.10.0}/paskia/sansio.py +0 -0
  69. {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.9.1
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.9.1'
32
- __version_tuple__ = version_tuple = (0, 9, 1)
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
- _REPLACE = "\033[0;33m" # Yellow for replacements
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, path: list[str], changes: list[tuple[str, list[str], Any, Any | None]]
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, old_value).
103
- change_type is one of: 'set', 'replace', 'delete'
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 - this is a set operation
107
- changes.append(("set", path, diff, None))
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, None))
130
+ changes.append(("delete", path + [str(deleted_key)], None))
116
131
  else:
117
- changes.append(("delete", path + [str(value)], None, None))
132
+ changes.append(("delete", path + [str(value)], None))
118
133
 
119
134
  elif key == "$replace":
120
- # $replace contains the new value for this path
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
- changes.append(("replace", path + [str(rkey)], rval, None))
125
- if not value:
126
- # Empty replacement - clearing the collection
127
- changes.append(("replace", path, {}, None))
128
- else:
129
- changes.append(("replace", path, value, None))
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(("set", path, {key: value}, None))
164
+ changes.append(("add", path, {key: value}))
134
165
 
135
166
  else:
136
- # Regular nested key
137
- _collect_changes(value, path + [str(key)], changes)
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 _format_change_line(
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 a one-line string."""
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" {path_str}"
150
- return f" - {path_str}"
151
-
152
- if change_type == "replace":
153
- if use_color:
154
- return f" {_REPLACE}{_RESET} {path_str} {_DIM}={_RESET} {value_str}"
155
- return f" ~ {path_str} = {value_str}"
156
-
157
- # Default: set/add
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" {_ADD}+{_RESET} {path_str} {_DIM}={_RESET} {value_str}"
160
- return f" + {path_str} = {value_str}"
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, Any | None]] = []
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, _ in changes:
180
- lines.append(_format_change_line(change_type, path, value, use_color))
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(action: str, diff: dict, user_display: str | None = None) -> None:
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(key: str, *, ctx: SessionContext | None = None) -> None:
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("delete_session", ctx):
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": "info",
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(Config(app=fastapi_app, **run_kwargs, **ep)).serve()
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(Config(app=fastapi_app, **run_kwargs, **endpoints[0]))
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, perm, host=request.headers.get("host"), max_age=max_age
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
- logger.warning(
104
- "Permission denied: user=%s role=%s missing=%s required=%s granted=%s", # noqa: E501
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;31m" # 5xx (bright red)
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;34m" # POST, PUT, DELETE, PATCH (bright blue)
22
- _HOST = "\033[1;30m" # hostname (dark grey)
23
- _PATH = "\033[0m" # path (default)
24
- _TIMING = "\033[2m" # timing (dim)
25
- _WS_OPEN = "\033[1;33m" # WebSocket connect (bright yellow)
26
- _WS_CLOSE = "\033[0;33m" # WebSocket disconnect (yellow)
27
- _WS_STATUS = "\033[1;30m" # WebSocket close status (dark grey)
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(15) # IPv4 max 15 chars
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(client: str, host: str, path: str) -> int:
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
- ip = format_client_ip(client).ljust(15)
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"{duration_ms:.0f}ms"
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"{ip} {prefix} {status_str} {timing_str}")
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