paskia 0.8.1__py3-none-any.whl → 0.9.0__py3-none-any.whl
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/_version.py +2 -2
- paskia/authsession.py +14 -27
- paskia/bootstrap.py +31 -103
- paskia/db/__init__.py +25 -51
- paskia/db/background.py +17 -37
- paskia/db/jsonl.py +168 -6
- paskia/db/migrations.py +34 -0
- paskia/db/operations.py +400 -723
- paskia/db/structs.py +214 -90
- paskia/fastapi/__main__.py +24 -28
- paskia/fastapi/admin.py +101 -160
- paskia/fastapi/api.py +47 -83
- paskia/fastapi/mainapp.py +13 -6
- paskia/fastapi/remote.py +16 -39
- paskia/fastapi/reset.py +27 -17
- paskia/fastapi/session.py +2 -2
- paskia/fastapi/user.py +21 -27
- paskia/fastapi/ws.py +27 -62
- paskia/fastapi/wschat.py +62 -0
- paskia/frontend-build/auth/admin/index.html +5 -5
- paskia/frontend-build/auth/assets/{AccessDenied-Bc249ASC.css → AccessDenied-DPkUS8LZ.css} +1 -1
- paskia/frontend-build/auth/assets/AccessDenied-Fmeb6EtF.js +8 -0
- paskia/frontend-build/auth/assets/{RestrictedAuth-DgdJyscT.css → RestrictedAuth-CvR33_Z0.css} +1 -1
- paskia/frontend-build/auth/assets/RestrictedAuth-DsJXicIw.js +1 -0
- paskia/frontend-build/auth/assets/{_plugin-vue_export-helper-rKFEraYH.js → _plugin-vue_export-helper-nhjnO_bd.js} +1 -1
- paskia/frontend-build/auth/assets/admin-CPE1pLMm.js +1 -0
- paskia/frontend-build/auth/assets/{admin-BeNu48FR.css → admin-DzzjSg72.css} +1 -1
- paskia/frontend-build/auth/assets/{auth-BKX7shEe.css → auth-C7k64Wad.css} +1 -1
- paskia/frontend-build/auth/assets/auth-YIZvPlW_.js +1 -0
- paskia/frontend-build/auth/assets/{forward-Dzg-aE1C.js → forward-DmqVHZ7e.js} +1 -1
- paskia/frontend-build/auth/assets/reset-Chtv69AT.css +1 -0
- paskia/frontend-build/auth/assets/reset-s20PATTN.js +1 -0
- paskia/frontend-build/auth/assets/{restricted-C0IQufuH.js → restricted-D3AJx3_6.js} +1 -1
- paskia/frontend-build/auth/index.html +5 -5
- paskia/frontend-build/auth/restricted/index.html +4 -4
- paskia/frontend-build/int/forward/index.html +4 -4
- paskia/frontend-build/int/reset/index.html +3 -3
- paskia/globals.py +2 -2
- paskia/migrate/__init__.py +62 -55
- paskia/migrate/sql.py +72 -22
- paskia/remoteauth.py +1 -2
- paskia/sansio.py +6 -12
- {paskia-0.8.1.dist-info → paskia-0.9.0.dist-info}/METADATA +1 -1
- paskia-0.9.0.dist-info/RECORD +57 -0
- paskia/frontend-build/auth/assets/AccessDenied-aTdCvz9k.js +0 -8
- paskia/frontend-build/auth/assets/RestrictedAuth-BLMK7-nL.js +0 -1
- paskia/frontend-build/auth/assets/admin-tVs8oyLv.js +0 -1
- paskia/frontend-build/auth/assets/auth-Dk3q4pNS.js +0 -1
- paskia/frontend-build/auth/assets/reset-BWF4cWKR.css +0 -1
- paskia/frontend-build/auth/assets/reset-C_Td1_jn.js +0 -1
- paskia-0.8.1.dist-info/RECORD +0 -55
- {paskia-0.8.1.dist-info → paskia-0.9.0.dist-info}/WHEEL +0 -0
- {paskia-0.8.1.dist-info → paskia-0.9.0.dist-info}/entry_points.txt +0 -0
paskia/db/operations.py
CHANGED
|
@@ -1,34 +1,27 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Database for WebAuthn passkey authentication.
|
|
3
3
|
|
|
4
|
-
Read operations: Access _db
|
|
4
|
+
Read operations: Access _db directly, use build_* helpers to get public structs.
|
|
5
5
|
Context lookup: get_session_context() returns full SessionContext with effective permissions.
|
|
6
6
|
Write operations: Functions that validate and commit, or raise ValueError.
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
import hashlib
|
|
10
|
-
import json
|
|
11
10
|
import logging
|
|
12
11
|
import os
|
|
13
12
|
import secrets
|
|
14
|
-
import sys
|
|
15
|
-
from collections import deque
|
|
16
|
-
from contextlib import contextmanager
|
|
17
13
|
from datetime import datetime, timezone
|
|
18
|
-
from pathlib import Path
|
|
19
|
-
from typing import Any
|
|
20
14
|
from uuid import UUID
|
|
21
15
|
|
|
22
|
-
import
|
|
16
|
+
import uuid7
|
|
23
17
|
|
|
18
|
+
from paskia.config import SESSION_LIFETIME
|
|
24
19
|
from paskia.db.jsonl import (
|
|
25
20
|
DB_PATH_DEFAULT,
|
|
26
|
-
|
|
27
|
-
compute_diff,
|
|
28
|
-
create_change_record,
|
|
29
|
-
load_jsonl,
|
|
21
|
+
JsonlStore,
|
|
30
22
|
)
|
|
31
23
|
from paskia.db.structs import (
|
|
24
|
+
DB,
|
|
32
25
|
Credential,
|
|
33
26
|
Org,
|
|
34
27
|
Permission,
|
|
@@ -37,220 +30,32 @@ from paskia.db.structs import (
|
|
|
37
30
|
Session,
|
|
38
31
|
SessionContext,
|
|
39
32
|
User,
|
|
40
|
-
_CredentialData,
|
|
41
|
-
_DatabaseData,
|
|
42
|
-
_OrgData,
|
|
43
|
-
_PermissionData,
|
|
44
|
-
_ResetTokenData,
|
|
45
|
-
_RoleData,
|
|
46
|
-
_SessionData,
|
|
47
|
-
_UserData,
|
|
48
33
|
)
|
|
34
|
+
from paskia.util.hostutil import normalize_host
|
|
35
|
+
from paskia.util.passphrase import generate as generate_passphrase
|
|
49
36
|
from paskia.util.passphrase import is_well_formed as _is_passphrase
|
|
50
37
|
|
|
51
38
|
_logger = logging.getLogger(__name__)
|
|
52
39
|
|
|
53
|
-
#
|
|
54
|
-
_json_encoder = msgspec.json.Encoder()
|
|
55
|
-
_json_decoder = msgspec.json.Decoder(_DatabaseData)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
class DB:
|
|
59
|
-
"""In-memory database with JSONL persistence.
|
|
60
|
-
|
|
61
|
-
Access data directly via _data for reads.
|
|
62
|
-
Use transaction() context manager for writes.
|
|
63
|
-
"""
|
|
64
|
-
|
|
65
|
-
def __init__(self, db_path: str = DB_PATH_DEFAULT):
|
|
66
|
-
self.db_path = Path(db_path)
|
|
67
|
-
self._data = _DatabaseData(
|
|
68
|
-
permissions={},
|
|
69
|
-
orgs={},
|
|
70
|
-
roles={},
|
|
71
|
-
users={},
|
|
72
|
-
credentials={},
|
|
73
|
-
sessions={},
|
|
74
|
-
reset_tokens={},
|
|
75
|
-
)
|
|
76
|
-
self._previous_builtins: dict[str, Any] = {}
|
|
77
|
-
self._pending_changes: deque[_ChangeRecord] = deque()
|
|
78
|
-
self._current_action: str = "system"
|
|
79
|
-
self._current_user: str | None = None
|
|
80
|
-
|
|
81
|
-
async def load(self, db_path: str | None = None) -> None:
|
|
82
|
-
"""Load data from JSONL change log.
|
|
83
|
-
|
|
84
|
-
If file doesn't exist or is empty, keeps the initialized empty structure and
|
|
85
|
-
sets _previous_builtins to {} for creating a new database.
|
|
86
|
-
"""
|
|
87
|
-
if db_path is not None:
|
|
88
|
-
self.db_path = Path(db_path)
|
|
89
|
-
try:
|
|
90
|
-
data_dict = await load_jsonl(self.db_path)
|
|
91
|
-
if data_dict: # Only decode if we have data
|
|
92
|
-
self._data = _json_decoder.decode(_json_encoder.encode(data_dict))
|
|
93
|
-
# Track the JSONL file state directly - this is what we diff against
|
|
94
|
-
self._previous_builtins = data_dict
|
|
95
|
-
# If data_dict is empty, keep initialized _data and _previous_builtins = {}
|
|
96
|
-
except ValueError:
|
|
97
|
-
if self.db_path.exists():
|
|
98
|
-
raise # File exists but failed to load - re-raise
|
|
99
|
-
# File doesn't exist: keep initialized _data, _previous_builtins stays {}
|
|
100
|
-
|
|
101
|
-
def _queue_change(self) -> None:
|
|
102
|
-
current = msgspec.to_builtins(self._data)
|
|
103
|
-
diff = compute_diff(self._previous_builtins, current)
|
|
104
|
-
if diff:
|
|
105
|
-
self._pending_changes.append(
|
|
106
|
-
create_change_record(self._current_action, diff, self._current_user)
|
|
107
|
-
)
|
|
108
|
-
self._previous_builtins = current
|
|
109
|
-
# Log the change with user display name if available
|
|
110
|
-
user_display = None
|
|
111
|
-
if self._current_user:
|
|
112
|
-
try:
|
|
113
|
-
user_uuid = UUID(self._current_user)
|
|
114
|
-
if user_uuid in self._data.users:
|
|
115
|
-
user_display = self._data.users[user_uuid].display_name
|
|
116
|
-
except (ValueError, KeyError):
|
|
117
|
-
user_display = self._current_user
|
|
118
|
-
|
|
119
|
-
diff_json = json.dumps(diff, default=str)
|
|
120
|
-
if user_display:
|
|
121
|
-
print(
|
|
122
|
-
f"{self._current_action} by {user_display}: {diff_json}",
|
|
123
|
-
file=sys.stderr,
|
|
124
|
-
)
|
|
125
|
-
else:
|
|
126
|
-
print(f"{self._current_action}: {diff_json}", file=sys.stderr)
|
|
127
|
-
|
|
128
|
-
@contextmanager
|
|
129
|
-
def transaction(
|
|
130
|
-
self,
|
|
131
|
-
action: str,
|
|
132
|
-
ctx: SessionContext | None = None,
|
|
133
|
-
*,
|
|
134
|
-
user: str | None = None,
|
|
135
|
-
):
|
|
136
|
-
"""Wrap writes in transaction. Queues change on successful exit.
|
|
137
|
-
|
|
138
|
-
Args:
|
|
139
|
-
action: Describes the operation (e.g., "Created user", "Login")
|
|
140
|
-
ctx: Session context of user performing the action (None for system operations)
|
|
141
|
-
user: User UUID string (alternative to ctx when full context unavailable)
|
|
142
|
-
"""
|
|
143
|
-
old_action = self._current_action
|
|
144
|
-
old_user = self._current_user
|
|
145
|
-
self._current_action = action
|
|
146
|
-
# Prefer ctx.user.uuid if ctx provided, otherwise use user param
|
|
147
|
-
self._current_user = str(ctx.user.uuid) if ctx else user
|
|
148
|
-
try:
|
|
149
|
-
yield
|
|
150
|
-
self._queue_change()
|
|
151
|
-
finally:
|
|
152
|
-
self._current_action = old_action
|
|
153
|
-
self._current_user = old_user
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
# Global instance, always available (empty until init() loads data)
|
|
40
|
+
# Global database instance (empty until init() loads data)
|
|
157
41
|
_db = DB()
|
|
42
|
+
_store = JsonlStore(_db)
|
|
43
|
+
_db._store = _store
|
|
44
|
+
_initialized = False
|
|
158
45
|
|
|
159
46
|
|
|
160
47
|
async def init(*args, **kwargs):
|
|
161
|
-
"""Load database
|
|
162
|
-
|
|
163
|
-
|
|
48
|
+
"""Load database from JSONL file."""
|
|
49
|
+
global _db, _initialized
|
|
50
|
+
if _initialized:
|
|
51
|
+
_logger.debug("Database already initialized, skipping reload")
|
|
52
|
+
return
|
|
164
53
|
db_path = os.environ.get("PASKIA_DB", DB_PATH_DEFAULT)
|
|
165
54
|
if db_path.startswith("json:"):
|
|
166
55
|
db_path = db_path[5:]
|
|
167
|
-
await
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
# -------------------------------------------------------------------------
|
|
172
|
-
# Builders: Convert internal _*Data to public structs
|
|
173
|
-
# -------------------------------------------------------------------------
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
def build_permission(uuid: UUID) -> Permission:
|
|
177
|
-
p = _db._data.permissions[uuid]
|
|
178
|
-
return Permission(
|
|
179
|
-
uuid=uuid, scope=p.scope, display_name=p.display_name, domain=p.domain
|
|
180
|
-
)
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
def build_user(uuid: UUID) -> User:
|
|
184
|
-
u = _db._data.users[uuid]
|
|
185
|
-
return User(
|
|
186
|
-
uuid=uuid,
|
|
187
|
-
display_name=u.display_name,
|
|
188
|
-
role_uuid=u.role,
|
|
189
|
-
created_at=u.created_at,
|
|
190
|
-
last_seen=u.last_seen,
|
|
191
|
-
visits=u.visits,
|
|
192
|
-
)
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
def build_role(uuid: UUID) -> Role:
|
|
196
|
-
r = _db._data.roles[uuid]
|
|
197
|
-
return Role(
|
|
198
|
-
uuid=uuid,
|
|
199
|
-
org_uuid=r.org,
|
|
200
|
-
display_name=r.display_name,
|
|
201
|
-
permissions=[str(pid) for pid in r.permissions.keys()],
|
|
202
|
-
)
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
def build_org(uuid: UUID, include_roles: bool = False) -> Org:
|
|
206
|
-
o = _db._data.orgs[uuid]
|
|
207
|
-
perm_uuids = [
|
|
208
|
-
str(pid) for pid, p in _db._data.permissions.items() if uuid in p.orgs
|
|
209
|
-
]
|
|
210
|
-
org = Org(uuid=uuid, display_name=o.display_name, permissions=perm_uuids)
|
|
211
|
-
if include_roles:
|
|
212
|
-
org.roles = [
|
|
213
|
-
build_role(rid) for rid, r in _db._data.roles.items() if r.org == uuid
|
|
214
|
-
]
|
|
215
|
-
return org
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
def build_credential(uuid: UUID) -> Credential:
|
|
219
|
-
c = _db._data.credentials[uuid]
|
|
220
|
-
return Credential(
|
|
221
|
-
uuid=uuid,
|
|
222
|
-
credential_id=c.credential_id,
|
|
223
|
-
user_uuid=c.user,
|
|
224
|
-
aaguid=c.aaguid,
|
|
225
|
-
public_key=c.public_key,
|
|
226
|
-
sign_count=c.sign_count,
|
|
227
|
-
created_at=c.created_at,
|
|
228
|
-
last_used=c.last_used,
|
|
229
|
-
last_verified=c.last_verified,
|
|
230
|
-
)
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
def build_session(key: str) -> Session:
|
|
234
|
-
s = _db._data.sessions[key]
|
|
235
|
-
return Session(
|
|
236
|
-
key=key,
|
|
237
|
-
user_uuid=s.user,
|
|
238
|
-
credential_uuid=s.credential,
|
|
239
|
-
host=s.host,
|
|
240
|
-
ip=s.ip,
|
|
241
|
-
user_agent=s.user_agent,
|
|
242
|
-
expiry=s.expiry,
|
|
243
|
-
)
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
def build_reset_token(key: bytes) -> ResetToken:
|
|
247
|
-
t = _db._data.reset_tokens[key]
|
|
248
|
-
return ResetToken(
|
|
249
|
-
key=key,
|
|
250
|
-
user_uuid=t.user,
|
|
251
|
-
expiry=t.expiry,
|
|
252
|
-
token_type=t.token_type,
|
|
253
|
-
)
|
|
56
|
+
await _store.load(db_path)
|
|
57
|
+
_db = _store.db
|
|
58
|
+
_initialized = True
|
|
254
59
|
|
|
255
60
|
|
|
256
61
|
# -------------------------------------------------------------------------
|
|
@@ -258,155 +63,46 @@ def build_reset_token(key: bytes) -> ResetToken:
|
|
|
258
63
|
# -------------------------------------------------------------------------
|
|
259
64
|
|
|
260
65
|
|
|
261
|
-
def
|
|
262
|
-
"""Get permission by UUID or scope.
|
|
263
|
-
|
|
264
|
-
For backwards compatibility, this accepts either:
|
|
265
|
-
- A UUID string (the primary key)
|
|
266
|
-
- A scope string (searches for matching scope)
|
|
267
|
-
"""
|
|
268
|
-
# First try as UUID key
|
|
269
|
-
if isinstance(permission_id, UUID):
|
|
270
|
-
if permission_id in _db._data.permissions:
|
|
271
|
-
return build_permission(permission_id)
|
|
272
|
-
else:
|
|
273
|
-
try:
|
|
274
|
-
uuid = UUID(permission_id)
|
|
275
|
-
if uuid in _db._data.permissions:
|
|
276
|
-
return build_permission(uuid)
|
|
277
|
-
except ValueError:
|
|
278
|
-
pass
|
|
279
|
-
# Fall back to scope search
|
|
280
|
-
for uuid, p in _db._data.permissions.items():
|
|
281
|
-
if p.scope == str(permission_id):
|
|
282
|
-
return build_permission(uuid)
|
|
283
|
-
return None
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
def get_permission_by_scope(scope: str) -> Permission | None:
|
|
287
|
-
"""Get permission by scope identifier."""
|
|
288
|
-
for uuid, p in _db._data.permissions.items():
|
|
289
|
-
if p.scope == scope:
|
|
290
|
-
return build_permission(uuid)
|
|
291
|
-
return None
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
def list_permissions() -> list[Permission]:
|
|
295
|
-
"""List all permissions."""
|
|
296
|
-
return [build_permission(uuid) for uuid in _db._data.permissions]
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
def get_permission_organizations(scope: str) -> list[Org]:
|
|
300
|
-
"""Get organizations that can grant a permission scope."""
|
|
301
|
-
for p in _db._data.permissions.values():
|
|
302
|
-
if p.scope == scope:
|
|
303
|
-
return [build_org(org_uuid) for org_uuid in p.orgs]
|
|
304
|
-
return []
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
def get_organization(uuid: str | UUID) -> Org | None:
|
|
308
|
-
"""Get organization by UUID."""
|
|
309
|
-
if isinstance(uuid, str):
|
|
310
|
-
uuid = UUID(uuid)
|
|
311
|
-
return build_org(uuid, include_roles=True) if uuid in _db._data.orgs else None
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
def list_organizations() -> list[Org]:
|
|
315
|
-
"""List all organizations."""
|
|
316
|
-
return [build_org(uuid, include_roles=True) for uuid in _db._data.orgs]
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
def get_organization_users(org_uuid: str | UUID) -> list[tuple[User, str]]:
|
|
320
|
-
"""Get all users in an organization with their role names."""
|
|
321
|
-
if isinstance(org_uuid, str):
|
|
322
|
-
org_uuid = UUID(org_uuid)
|
|
323
|
-
role_map = {
|
|
324
|
-
rid: r.display_name for rid, r in _db._data.roles.items() if r.org == org_uuid
|
|
325
|
-
}
|
|
326
|
-
return [
|
|
327
|
-
(build_user(uid), role_map[u.role])
|
|
328
|
-
for uid, u in _db._data.users.items()
|
|
329
|
-
if u.role in role_map
|
|
330
|
-
]
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
def get_role(uuid: str | UUID) -> Role | None:
|
|
334
|
-
"""Get role by UUID."""
|
|
335
|
-
if isinstance(uuid, str):
|
|
336
|
-
uuid = UUID(uuid)
|
|
337
|
-
return build_role(uuid) if uuid in _db._data.roles else None
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
def get_roles_by_organization(org_uuid: str | UUID) -> list[Role]:
|
|
341
|
-
"""Get all roles in an organization."""
|
|
342
|
-
if isinstance(org_uuid, str):
|
|
343
|
-
org_uuid = UUID(org_uuid)
|
|
344
|
-
return [build_role(rid) for rid, r in _db._data.roles.items() if r.org == org_uuid]
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
def get_user_by_uuid(uuid: str | UUID) -> User | None:
|
|
348
|
-
"""Get user by UUID."""
|
|
349
|
-
if isinstance(uuid, str):
|
|
350
|
-
uuid = UUID(uuid)
|
|
351
|
-
return build_user(uuid) if uuid in _db._data.users else None
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
def get_user_organization(user_uuid: str | UUID) -> tuple[Org, str]:
|
|
66
|
+
def get_user_organization(user_uuid: UUID) -> tuple[Org, str]:
|
|
355
67
|
"""Get the organization a user belongs to and their role name.
|
|
356
68
|
|
|
357
69
|
Raises ValueError if user not found.
|
|
70
|
+
|
|
71
|
+
Call sites:
|
|
72
|
+
- Get user's organization when updating user role (admin.py:493)
|
|
73
|
+
- Get user's organization for user credential listing (admin.py:530)
|
|
74
|
+
- Get user's organization for user details API (admin.py:579)
|
|
75
|
+
- Get user's organization for updating user display name (admin.py:721)
|
|
76
|
+
- Get user's organization for deleting user credential (admin.py:754)
|
|
77
|
+
- Get user's organization for deleting user session (admin.py:783)
|
|
358
78
|
"""
|
|
359
|
-
if
|
|
360
|
-
user_uuid = UUID(user_uuid)
|
|
361
|
-
if user_uuid not in _db._data.users:
|
|
79
|
+
if user_uuid not in _db.users:
|
|
362
80
|
raise ValueError(f"User {user_uuid} not found")
|
|
363
|
-
role_uuid = _db.
|
|
364
|
-
if role_uuid not in _db.
|
|
81
|
+
role_uuid = _db.users[user_uuid].role
|
|
82
|
+
if role_uuid not in _db.roles:
|
|
365
83
|
raise ValueError(f"Role {role_uuid} not found")
|
|
366
|
-
role_data = _db.
|
|
84
|
+
role_data = _db.roles[role_uuid]
|
|
367
85
|
org_uuid = role_data.org
|
|
368
|
-
return
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
def get_credential_by_id(credential_id: bytes) -> Credential | None:
|
|
372
|
-
"""Get credential by credential_id (the authenticator's ID)."""
|
|
373
|
-
for uuid, c in _db._data.credentials.items():
|
|
374
|
-
if c.credential_id == credential_id:
|
|
375
|
-
return build_credential(uuid)
|
|
376
|
-
return None
|
|
86
|
+
return _db.orgs[org_uuid], role_data.display_name
|
|
377
87
|
|
|
378
88
|
|
|
379
|
-
def
|
|
380
|
-
"""Get all
|
|
381
|
-
if isinstance(user_uuid, str):
|
|
382
|
-
user_uuid = UUID(user_uuid)
|
|
383
|
-
return [
|
|
384
|
-
build_credential(cid)
|
|
385
|
-
for cid, c in _db._data.credentials.items()
|
|
386
|
-
if c.user == user_uuid
|
|
387
|
-
]
|
|
89
|
+
def get_organization_users(org_uuid: UUID) -> list[tuple[User, str]]:
|
|
90
|
+
"""Get all users in an organization with their role names.
|
|
388
91
|
|
|
92
|
+
Returns list of (User, role_display_name) tuples.
|
|
93
|
+
"""
|
|
94
|
+
role_map = {
|
|
95
|
+
rid: r.display_name for rid, r in _db.roles.items() if r.org == org_uuid
|
|
96
|
+
}
|
|
97
|
+
return [(u, role_map[u.role]) for u in _db.users.values() if u.role in role_map]
|
|
389
98
|
|
|
390
|
-
def get_session(key: str) -> Session | None:
|
|
391
|
-
"""Get session by key."""
|
|
392
|
-
if key not in _db._data.sessions:
|
|
393
|
-
return None
|
|
394
|
-
s = _db._data.sessions[key]
|
|
395
|
-
if s.expiry < datetime.now(timezone.utc):
|
|
396
|
-
return None
|
|
397
|
-
return build_session(key)
|
|
398
99
|
|
|
100
|
+
def get_user_credential_ids(user_uuid: UUID) -> list[bytes]:
|
|
101
|
+
"""Get credential IDs for a user (for WebAuthn exclude lists).
|
|
399
102
|
|
|
400
|
-
|
|
401
|
-
"""
|
|
402
|
-
if
|
|
403
|
-
user_uuid = UUID(user_uuid)
|
|
404
|
-
now = datetime.now(timezone.utc)
|
|
405
|
-
return [
|
|
406
|
-
build_session(key)
|
|
407
|
-
for key, s in _db._data.sessions.items()
|
|
408
|
-
if s.user == user_uuid and s.expiry >= now
|
|
409
|
-
]
|
|
103
|
+
Returns empty list if user has no credentials.
|
|
104
|
+
"""
|
|
105
|
+
return [c.credential_id for c in _db.credentials.values() if c.user == user_uuid]
|
|
410
106
|
|
|
411
107
|
|
|
412
108
|
def _reset_key(passphrase: str) -> bytes:
|
|
@@ -421,14 +117,13 @@ def _reset_key(passphrase: str) -> bytes:
|
|
|
421
117
|
|
|
422
118
|
|
|
423
119
|
def get_reset_token(passphrase: str) -> ResetToken | None:
|
|
424
|
-
"""Get reset token by passphrase.
|
|
120
|
+
"""Get reset token by passphrase.
|
|
121
|
+
|
|
122
|
+
Call sites:
|
|
123
|
+
- Get reset token to validate it (authsession.py:34)
|
|
124
|
+
"""
|
|
425
125
|
key = _reset_key(passphrase)
|
|
426
|
-
|
|
427
|
-
return None
|
|
428
|
-
t = _db._data.reset_tokens[key]
|
|
429
|
-
if t.expiry < datetime.now(timezone.utc):
|
|
430
|
-
return None
|
|
431
|
-
return build_reset_token(key)
|
|
126
|
+
return _db.reset_tokens.get(key)
|
|
432
127
|
|
|
433
128
|
|
|
434
129
|
# -------------------------------------------------------------------------
|
|
@@ -447,68 +142,65 @@ def get_session_context(
|
|
|
447
142
|
|
|
448
143
|
Returns:
|
|
449
144
|
SessionContext if valid, None if session not found, expired, or host mismatch
|
|
145
|
+
|
|
146
|
+
Call sites:
|
|
147
|
+
- Example usage in docstring (db/__init__.py:16)
|
|
148
|
+
- Get session context from auth token (util/permutil.py:43)
|
|
450
149
|
"""
|
|
451
|
-
from paskia.util.hostutil import normalize_host
|
|
452
150
|
|
|
453
|
-
if session_key not in _db.
|
|
151
|
+
if session_key not in _db.sessions:
|
|
454
152
|
return None
|
|
455
153
|
|
|
456
|
-
s = _db.
|
|
154
|
+
s = _db.sessions[session_key]
|
|
457
155
|
if s.expiry < datetime.now(timezone.utc):
|
|
458
156
|
return None
|
|
459
157
|
|
|
460
|
-
#
|
|
461
|
-
if host is not None:
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
with _db.transaction("host_binding"):
|
|
465
|
-
s.host = host
|
|
466
|
-
elif s.host != host:
|
|
467
|
-
# Session bound to different host
|
|
468
|
-
return None
|
|
158
|
+
# Validate host matches (sessions are always created with a host)
|
|
159
|
+
if host is not None and s.host != host:
|
|
160
|
+
# Session bound to different host
|
|
161
|
+
return None
|
|
469
162
|
|
|
470
163
|
# Validate user exists
|
|
471
|
-
if s.user not in _db.
|
|
164
|
+
if s.user not in _db.users:
|
|
472
165
|
return None
|
|
473
166
|
|
|
474
167
|
# Validate role exists
|
|
475
|
-
role_uuid = _db.
|
|
476
|
-
if role_uuid not in _db.
|
|
168
|
+
role_uuid = _db.users[s.user].role
|
|
169
|
+
if role_uuid not in _db.roles:
|
|
477
170
|
return None
|
|
478
171
|
|
|
479
172
|
# Validate org exists
|
|
480
|
-
org_uuid = _db.
|
|
481
|
-
if org_uuid not in _db.
|
|
173
|
+
org_uuid = _db.roles[role_uuid].org
|
|
174
|
+
if org_uuid not in _db.orgs:
|
|
482
175
|
return None
|
|
483
176
|
|
|
484
|
-
session =
|
|
485
|
-
user =
|
|
486
|
-
role =
|
|
487
|
-
org =
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
177
|
+
session = _db.sessions[session_key]
|
|
178
|
+
user = _db.users[s.user]
|
|
179
|
+
role = _db.roles[role_uuid]
|
|
180
|
+
org = _db.orgs[org_uuid]
|
|
181
|
+
|
|
182
|
+
# Credential must exist (sessions are cascade-deleted when credential is deleted)
|
|
183
|
+
if s.credential not in _db.credentials:
|
|
184
|
+
return None
|
|
185
|
+
credential = _db.credentials[s.credential]
|
|
493
186
|
|
|
494
187
|
# Effective permissions: role's permissions that the org can grant
|
|
495
188
|
# Also filter by domain if host is provided
|
|
496
|
-
org_perm_uuids =
|
|
189
|
+
org_perm_uuids = {pid for pid, p in _db.permissions.items() if org_uuid in p.orgs}
|
|
497
190
|
normalized_host = normalize_host(host)
|
|
498
191
|
host_without_port = normalized_host.rsplit(":", 1)[0] if normalized_host else None
|
|
499
192
|
|
|
500
193
|
effective_perms = []
|
|
501
|
-
for
|
|
502
|
-
if
|
|
194
|
+
for perm_uuid in role.permission_set:
|
|
195
|
+
if perm_uuid not in org_perm_uuids:
|
|
503
196
|
continue
|
|
504
|
-
perm_uuid
|
|
505
|
-
if perm_uuid not in _db._data.permissions:
|
|
197
|
+
if perm_uuid not in _db.permissions:
|
|
506
198
|
continue
|
|
507
|
-
p = _db.
|
|
199
|
+
p = _db.permissions[perm_uuid]
|
|
508
200
|
# Check domain restriction
|
|
509
201
|
if p.domain is not None and p.domain != host_without_port:
|
|
510
202
|
continue
|
|
511
|
-
effective_perms.append(
|
|
203
|
+
effective_perms.append(_db.permissions[perm_uuid])
|
|
512
204
|
|
|
513
205
|
return SessionContext(
|
|
514
206
|
session=session,
|
|
@@ -516,7 +208,7 @@ def get_session_context(
|
|
|
516
208
|
org=org,
|
|
517
209
|
role=role,
|
|
518
210
|
credential=credential,
|
|
519
|
-
permissions=effective_perms
|
|
211
|
+
permissions=effective_perms,
|
|
520
212
|
)
|
|
521
213
|
|
|
522
214
|
|
|
@@ -527,473 +219,361 @@ def get_session_context(
|
|
|
527
219
|
|
|
528
220
|
def create_permission(perm: Permission, *, ctx: SessionContext | None = None) -> None:
|
|
529
221
|
"""Create a new permission."""
|
|
530
|
-
if perm.uuid in _db.
|
|
222
|
+
if perm.uuid in _db.permissions:
|
|
531
223
|
raise ValueError(f"Permission {perm.uuid} already exists")
|
|
532
|
-
with _db.transaction("
|
|
533
|
-
_db.
|
|
534
|
-
scope=perm.scope,
|
|
535
|
-
display_name=perm.display_name,
|
|
536
|
-
domain=perm.domain,
|
|
537
|
-
orgs={},
|
|
538
|
-
)
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
def update_permission(perm: Permission, *, ctx: SessionContext | None = None) -> None:
|
|
542
|
-
"""Update a permission's scope, display_name, and domain."""
|
|
543
|
-
if perm.uuid not in _db._data.permissions:
|
|
544
|
-
raise ValueError(f"Permission {perm.uuid} not found")
|
|
545
|
-
with _db.transaction("Updated permission", ctx):
|
|
546
|
-
_db._data.permissions[perm.uuid].scope = perm.scope
|
|
547
|
-
_db._data.permissions[perm.uuid].display_name = perm.display_name
|
|
548
|
-
_db._data.permissions[perm.uuid].domain = perm.domain
|
|
224
|
+
with _db.transaction("admin:create_permission", ctx):
|
|
225
|
+
_db.permissions[perm.uuid] = perm
|
|
549
226
|
|
|
550
227
|
|
|
551
|
-
def
|
|
552
|
-
|
|
553
|
-
|
|
228
|
+
def update_permission(
|
|
229
|
+
uuid: UUID,
|
|
230
|
+
scope: str,
|
|
554
231
|
display_name: str,
|
|
555
232
|
domain: str | None = None,
|
|
556
233
|
*,
|
|
557
234
|
ctx: SessionContext | None = None,
|
|
558
235
|
) -> None:
|
|
559
|
-
"""
|
|
236
|
+
"""Update a permission's scope, display_name, and domain.
|
|
560
237
|
|
|
561
|
-
|
|
562
|
-
Note: Scopes do not need to be unique (same scope with different domains is valid).
|
|
238
|
+
Only these fields can be modified; created_at and other metadata remain immutable.
|
|
563
239
|
"""
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
if not key:
|
|
571
|
-
raise ValueError(f"Permission with scope '{old_scope}' not found")
|
|
572
|
-
|
|
573
|
-
with _db.transaction("Renamed permission", ctx):
|
|
574
|
-
# Update the permission
|
|
575
|
-
_db._data.permissions[key].scope = new_scope
|
|
576
|
-
_db._data.permissions[key].display_name = display_name
|
|
577
|
-
_db._data.permissions[key].domain = domain
|
|
240
|
+
if uuid not in _db.permissions:
|
|
241
|
+
raise ValueError(f"Permission {uuid} not found")
|
|
242
|
+
with _db.transaction("admin:update_permission", ctx):
|
|
243
|
+
_db.permissions[uuid].scope = scope
|
|
244
|
+
_db.permissions[uuid].display_name = display_name
|
|
245
|
+
_db.permissions[uuid].domain = domain
|
|
578
246
|
|
|
579
247
|
|
|
580
|
-
def delete_permission(uuid:
|
|
248
|
+
def delete_permission(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
|
|
581
249
|
"""Delete a permission and remove it from all roles."""
|
|
582
|
-
if
|
|
583
|
-
uuid = UUID(uuid)
|
|
584
|
-
if uuid not in _db._data.permissions:
|
|
250
|
+
if uuid not in _db.permissions:
|
|
585
251
|
raise ValueError(f"Permission {uuid} not found")
|
|
586
|
-
with _db.transaction("
|
|
252
|
+
with _db.transaction("admin:delete_permission", ctx):
|
|
587
253
|
# Remove this permission from all roles
|
|
588
|
-
for role in _db.
|
|
254
|
+
for role in _db.roles.values():
|
|
589
255
|
role.permissions.pop(uuid, None)
|
|
590
|
-
del _db.
|
|
256
|
+
del _db.permissions[uuid]
|
|
591
257
|
|
|
592
258
|
|
|
593
|
-
def
|
|
259
|
+
def create_org(org: Org, *, ctx: SessionContext | None = None) -> None:
|
|
594
260
|
"""Create a new organization with an Administration role.
|
|
595
261
|
|
|
596
262
|
Automatically creates an 'Administration' role with auth:org:admin permission.
|
|
597
263
|
"""
|
|
598
|
-
if org.uuid in _db.
|
|
264
|
+
if org.uuid in _db.orgs:
|
|
599
265
|
raise ValueError(f"Organization {org.uuid} already exists")
|
|
600
|
-
with _db.transaction("
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
# Grant listed permissions to this org (org.permissions contains UUIDs now)
|
|
605
|
-
for perm_uuid_str in org.permissions:
|
|
606
|
-
perm_uuid = (
|
|
607
|
-
UUID(perm_uuid_str) if isinstance(perm_uuid_str, str) else perm_uuid_str
|
|
608
|
-
)
|
|
609
|
-
if perm_uuid in _db._data.permissions:
|
|
610
|
-
_db._data.permissions[perm_uuid].orgs[org.uuid] = True
|
|
266
|
+
with _db.transaction("admin:create_org", ctx):
|
|
267
|
+
new_org = Org(display_name=org.display_name)
|
|
268
|
+
_db.orgs[org.uuid] = new_org
|
|
269
|
+
new_org.uuid = org.uuid
|
|
611
270
|
# Create Administration role with org admin permission
|
|
612
|
-
import uuid7
|
|
613
271
|
|
|
614
272
|
admin_role_uuid = uuid7.create()
|
|
615
273
|
# Find the auth:org:admin permission UUID
|
|
616
274
|
org_admin_perm_uuid = None
|
|
617
|
-
for pid, p in _db.
|
|
275
|
+
for pid, p in _db.permissions.items():
|
|
618
276
|
if p.scope == "auth:org:admin":
|
|
619
277
|
org_admin_perm_uuid = pid
|
|
620
278
|
break
|
|
621
279
|
role_permissions = {org_admin_perm_uuid: True} if org_admin_perm_uuid else {}
|
|
622
|
-
|
|
280
|
+
admin_role = Role(
|
|
623
281
|
org=org.uuid,
|
|
624
282
|
display_name="Administration",
|
|
625
283
|
permissions=role_permissions,
|
|
626
284
|
)
|
|
285
|
+
admin_role.uuid = admin_role_uuid
|
|
286
|
+
_db.roles[admin_role_uuid] = admin_role
|
|
627
287
|
|
|
628
288
|
|
|
629
|
-
def
|
|
630
|
-
uuid:
|
|
289
|
+
def update_org_name(
|
|
290
|
+
uuid: UUID,
|
|
631
291
|
display_name: str,
|
|
632
292
|
*,
|
|
633
293
|
ctx: SessionContext | None = None,
|
|
634
294
|
) -> None:
|
|
635
295
|
"""Update organization display name."""
|
|
636
|
-
if
|
|
637
|
-
uuid = UUID(uuid)
|
|
638
|
-
if uuid not in _db._data.orgs:
|
|
296
|
+
if uuid not in _db.orgs:
|
|
639
297
|
raise ValueError(f"Organization {uuid} not found")
|
|
640
|
-
with _db.transaction("
|
|
641
|
-
_db.
|
|
298
|
+
with _db.transaction("admin:update_org_name", ctx):
|
|
299
|
+
_db.orgs[uuid].display_name = display_name
|
|
642
300
|
|
|
643
301
|
|
|
644
|
-
def
|
|
302
|
+
def delete_org(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
|
|
645
303
|
"""Delete organization and all its roles/users."""
|
|
646
|
-
if
|
|
647
|
-
uuid = UUID(uuid)
|
|
648
|
-
if uuid not in _db._data.orgs:
|
|
304
|
+
if uuid not in _db.orgs:
|
|
649
305
|
raise ValueError(f"Organization {uuid} not found")
|
|
650
|
-
with _db.transaction("
|
|
306
|
+
with _db.transaction("admin:delete_org", ctx):
|
|
651
307
|
# Remove org from all permissions
|
|
652
|
-
for p in _db.
|
|
308
|
+
for p in _db.permissions.values():
|
|
653
309
|
p.orgs.pop(uuid, None)
|
|
654
310
|
# Delete roles in this org
|
|
655
|
-
role_uuids = [rid for rid, r in _db.
|
|
311
|
+
role_uuids = [rid for rid, r in _db.roles.items() if r.org == uuid]
|
|
656
312
|
for rid in role_uuids:
|
|
657
|
-
del _db.
|
|
313
|
+
del _db.roles[rid]
|
|
658
314
|
# Delete users with those roles
|
|
659
|
-
user_uuids = [uid for uid, u in _db.
|
|
315
|
+
user_uuids = [uid for uid, u in _db.users.items() if u.role in role_uuids]
|
|
660
316
|
for uid in user_uuids:
|
|
661
|
-
del _db.
|
|
662
|
-
del _db.
|
|
317
|
+
del _db.users[uid]
|
|
318
|
+
del _db.orgs[uuid]
|
|
663
319
|
|
|
664
320
|
|
|
665
|
-
def
|
|
666
|
-
org_uuid:
|
|
667
|
-
|
|
321
|
+
def add_permission_to_org(
|
|
322
|
+
org_uuid: UUID,
|
|
323
|
+
permission_uuid: UUID,
|
|
668
324
|
*,
|
|
669
325
|
ctx: SessionContext | None = None,
|
|
670
326
|
) -> None:
|
|
671
327
|
"""Grant a permission to an organization by UUID."""
|
|
672
|
-
if
|
|
673
|
-
org_uuid = UUID(org_uuid)
|
|
674
|
-
if org_uuid not in _db._data.orgs:
|
|
328
|
+
if org_uuid not in _db.orgs:
|
|
675
329
|
raise ValueError(f"Organization {org_uuid} not found")
|
|
676
330
|
|
|
677
|
-
|
|
678
|
-
if isinstance(permission_id, str):
|
|
679
|
-
try:
|
|
680
|
-
permission_uuid = UUID(permission_id)
|
|
681
|
-
except ValueError:
|
|
682
|
-
# It's a scope - look up the UUID (backwards compat)
|
|
683
|
-
for pid, p in _db._data.permissions.items():
|
|
684
|
-
if p.scope == permission_id:
|
|
685
|
-
permission_uuid = pid
|
|
686
|
-
break
|
|
687
|
-
else:
|
|
688
|
-
raise ValueError(f"Permission {permission_id} not found")
|
|
689
|
-
else:
|
|
690
|
-
permission_uuid = permission_id
|
|
691
|
-
|
|
692
|
-
if permission_uuid not in _db._data.permissions:
|
|
331
|
+
if permission_uuid not in _db.permissions:
|
|
693
332
|
raise ValueError(f"Permission {permission_uuid} not found")
|
|
694
333
|
|
|
695
|
-
with _db.transaction("
|
|
696
|
-
_db.
|
|
334
|
+
with _db.transaction("admin:add_permission_to_org", ctx):
|
|
335
|
+
_db.permissions[permission_uuid].orgs[org_uuid] = True
|
|
697
336
|
|
|
698
337
|
|
|
699
|
-
def
|
|
700
|
-
org_uuid:
|
|
701
|
-
|
|
338
|
+
def remove_permission_from_org(
|
|
339
|
+
org_uuid: UUID,
|
|
340
|
+
permission_uuid: UUID,
|
|
702
341
|
*,
|
|
703
342
|
ctx: SessionContext | None = None,
|
|
704
343
|
) -> None:
|
|
705
344
|
"""Remove a permission from an organization by UUID."""
|
|
706
|
-
if
|
|
707
|
-
org_uuid = UUID(org_uuid)
|
|
708
|
-
if org_uuid not in _db._data.orgs:
|
|
345
|
+
if org_uuid not in _db.orgs:
|
|
709
346
|
raise ValueError(f"Organization {org_uuid} not found")
|
|
710
347
|
|
|
711
|
-
|
|
712
|
-
if isinstance(permission_id, str):
|
|
713
|
-
try:
|
|
714
|
-
permission_uuid = UUID(permission_id)
|
|
715
|
-
except ValueError:
|
|
716
|
-
# It's a scope - look up the UUID (backwards compat)
|
|
717
|
-
for pid, p in _db._data.permissions.items():
|
|
718
|
-
if p.scope == permission_id:
|
|
719
|
-
permission_uuid = pid
|
|
720
|
-
break
|
|
721
|
-
else:
|
|
722
|
-
return # Permission not found, silently return
|
|
723
|
-
else:
|
|
724
|
-
permission_uuid = permission_id
|
|
725
|
-
|
|
726
|
-
if permission_uuid not in _db._data.permissions:
|
|
348
|
+
if permission_uuid not in _db.permissions:
|
|
727
349
|
return # Permission not found, silently return
|
|
728
350
|
|
|
729
|
-
with _db.transaction("
|
|
730
|
-
_db.
|
|
351
|
+
with _db.transaction("admin:remove_permission_from_org", ctx):
|
|
352
|
+
_db.permissions[permission_uuid].orgs.pop(org_uuid, None)
|
|
731
353
|
|
|
732
354
|
|
|
733
355
|
def create_role(role: Role, *, ctx: SessionContext | None = None) -> None:
|
|
734
356
|
"""Create a new role."""
|
|
735
|
-
if role.uuid in _db.
|
|
357
|
+
if role.uuid in _db.roles:
|
|
736
358
|
raise ValueError(f"Role {role.uuid} already exists")
|
|
737
|
-
if role.
|
|
738
|
-
raise ValueError(f"Organization {role.
|
|
739
|
-
with _db.transaction("
|
|
740
|
-
_db.
|
|
741
|
-
org=role.org_uuid,
|
|
742
|
-
display_name=role.display_name,
|
|
743
|
-
permissions={UUID(pid): True for pid in role.permissions},
|
|
744
|
-
)
|
|
359
|
+
if role.org not in _db.orgs:
|
|
360
|
+
raise ValueError(f"Organization {role.org} not found")
|
|
361
|
+
with _db.transaction("admin:create_role", ctx):
|
|
362
|
+
_db.roles[role.uuid] = role
|
|
745
363
|
|
|
746
364
|
|
|
747
365
|
def update_role_name(
|
|
748
|
-
uuid:
|
|
366
|
+
uuid: UUID,
|
|
749
367
|
display_name: str,
|
|
750
368
|
*,
|
|
751
369
|
ctx: SessionContext | None = None,
|
|
752
370
|
) -> None:
|
|
753
371
|
"""Update role display name."""
|
|
754
|
-
if
|
|
755
|
-
uuid = UUID(uuid)
|
|
756
|
-
if uuid not in _db._data.roles:
|
|
372
|
+
if uuid not in _db.roles:
|
|
757
373
|
raise ValueError(f"Role {uuid} not found")
|
|
758
|
-
with _db.transaction("
|
|
759
|
-
_db.
|
|
374
|
+
with _db.transaction("admin:update_role_name", ctx):
|
|
375
|
+
_db.roles[uuid].display_name = display_name
|
|
760
376
|
|
|
761
377
|
|
|
762
378
|
def add_permission_to_role(
|
|
763
|
-
role_uuid:
|
|
764
|
-
permission_uuid:
|
|
379
|
+
role_uuid: UUID,
|
|
380
|
+
permission_uuid: UUID,
|
|
765
381
|
*,
|
|
766
382
|
ctx: SessionContext | None = None,
|
|
767
383
|
) -> None:
|
|
768
384
|
"""Add permission to role by UUID."""
|
|
769
|
-
if
|
|
770
|
-
role_uuid = UUID(role_uuid)
|
|
771
|
-
if isinstance(permission_uuid, str):
|
|
772
|
-
permission_uuid = UUID(permission_uuid)
|
|
773
|
-
if role_uuid not in _db._data.roles:
|
|
385
|
+
if role_uuid not in _db.roles:
|
|
774
386
|
raise ValueError(f"Role {role_uuid} not found")
|
|
775
|
-
if permission_uuid not in _db.
|
|
387
|
+
if permission_uuid not in _db.permissions:
|
|
776
388
|
raise ValueError(f"Permission {permission_uuid} not found")
|
|
777
|
-
with _db.transaction("
|
|
778
|
-
_db.
|
|
389
|
+
with _db.transaction("admin:add_permission_to_role", ctx):
|
|
390
|
+
_db.roles[role_uuid].permissions[permission_uuid] = True
|
|
779
391
|
|
|
780
392
|
|
|
781
393
|
def remove_permission_from_role(
|
|
782
|
-
role_uuid:
|
|
783
|
-
permission_uuid:
|
|
394
|
+
role_uuid: UUID,
|
|
395
|
+
permission_uuid: UUID,
|
|
784
396
|
*,
|
|
785
397
|
ctx: SessionContext | None = None,
|
|
786
398
|
) -> None:
|
|
787
399
|
"""Remove permission from role by UUID."""
|
|
788
|
-
if
|
|
789
|
-
role_uuid = UUID(role_uuid)
|
|
790
|
-
if isinstance(permission_uuid, str):
|
|
791
|
-
permission_uuid = UUID(permission_uuid)
|
|
792
|
-
if role_uuid not in _db._data.roles:
|
|
400
|
+
if role_uuid not in _db.roles:
|
|
793
401
|
raise ValueError(f"Role {role_uuid} not found")
|
|
794
|
-
with _db.transaction("
|
|
795
|
-
_db.
|
|
402
|
+
with _db.transaction("admin:remove_permission_from_role", ctx):
|
|
403
|
+
_db.roles[role_uuid].permissions.pop(permission_uuid, None)
|
|
796
404
|
|
|
797
405
|
|
|
798
|
-
def delete_role(uuid:
|
|
406
|
+
def delete_role(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
|
|
799
407
|
"""Delete a role."""
|
|
800
|
-
if
|
|
801
|
-
uuid = UUID(uuid)
|
|
802
|
-
if uuid not in _db._data.roles:
|
|
408
|
+
if uuid not in _db.roles:
|
|
803
409
|
raise ValueError(f"Role {uuid} not found")
|
|
804
410
|
# Check no users have this role
|
|
805
|
-
if any(u.role == uuid for u in _db.
|
|
411
|
+
if any(u.role == uuid for u in _db.users.values()):
|
|
806
412
|
raise ValueError(f"Cannot delete role {uuid}: users still assigned")
|
|
807
|
-
with _db.transaction("
|
|
808
|
-
del _db.
|
|
413
|
+
with _db.transaction("admin:delete_role", ctx):
|
|
414
|
+
del _db.roles[uuid]
|
|
809
415
|
|
|
810
416
|
|
|
811
417
|
def create_user(new_user: User, *, ctx: SessionContext | None = None) -> None:
|
|
812
418
|
"""Create a new user."""
|
|
813
|
-
if new_user.uuid in _db.
|
|
419
|
+
if new_user.uuid in _db.users:
|
|
814
420
|
raise ValueError(f"User {new_user.uuid} already exists")
|
|
815
|
-
if new_user.
|
|
816
|
-
raise ValueError(f"Role {new_user.
|
|
817
|
-
with _db.transaction("
|
|
818
|
-
_db.
|
|
819
|
-
display_name=new_user.display_name,
|
|
820
|
-
role=new_user.role_uuid,
|
|
821
|
-
created_at=new_user.created_at or datetime.now(timezone.utc),
|
|
822
|
-
last_seen=new_user.last_seen,
|
|
823
|
-
visits=new_user.visits,
|
|
824
|
-
)
|
|
421
|
+
if new_user.role not in _db.roles:
|
|
422
|
+
raise ValueError(f"Role {new_user.role} not found")
|
|
423
|
+
with _db.transaction("admin:create_user", ctx):
|
|
424
|
+
_db.users[new_user.uuid] = new_user
|
|
825
425
|
|
|
826
426
|
|
|
827
427
|
def update_user_display_name(
|
|
828
|
-
uuid:
|
|
428
|
+
uuid: UUID,
|
|
829
429
|
display_name: str,
|
|
830
430
|
*,
|
|
831
431
|
ctx: SessionContext | None = None,
|
|
832
432
|
) -> None:
|
|
833
433
|
"""Update user display name.
|
|
834
434
|
|
|
835
|
-
|
|
836
|
-
For
|
|
435
|
+
The acting user should be logged via ctx.
|
|
436
|
+
For self-service (user updating own name), pass user's ctx.
|
|
437
|
+
For admin operations, pass admin's ctx.
|
|
837
438
|
"""
|
|
838
439
|
if isinstance(uuid, str):
|
|
839
440
|
uuid = UUID(uuid)
|
|
840
|
-
if uuid not in _db.
|
|
441
|
+
if uuid not in _db.users:
|
|
841
442
|
raise ValueError(f"User {uuid} not found")
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
with _db.transaction("Renamed user", ctx, user=user_str):
|
|
845
|
-
_db._data.users[uuid].display_name = display_name
|
|
443
|
+
with _db.transaction("update_user_display_name", ctx):
|
|
444
|
+
_db.users[uuid].display_name = display_name
|
|
846
445
|
|
|
847
446
|
|
|
848
447
|
def update_user_role(
|
|
849
|
-
uuid:
|
|
850
|
-
role_uuid:
|
|
448
|
+
uuid: UUID,
|
|
449
|
+
role_uuid: UUID,
|
|
851
450
|
*,
|
|
852
451
|
ctx: SessionContext | None = None,
|
|
853
452
|
) -> None:
|
|
854
453
|
"""Update user's role."""
|
|
855
|
-
if
|
|
856
|
-
uuid = UUID(uuid)
|
|
857
|
-
if isinstance(role_uuid, str):
|
|
858
|
-
role_uuid = UUID(role_uuid)
|
|
859
|
-
if uuid not in _db._data.users:
|
|
454
|
+
if uuid not in _db.users:
|
|
860
455
|
raise ValueError(f"User {uuid} not found")
|
|
861
|
-
if role_uuid not in _db.
|
|
456
|
+
if role_uuid not in _db.roles:
|
|
862
457
|
raise ValueError(f"Role {role_uuid} not found")
|
|
863
|
-
with _db.transaction("
|
|
864
|
-
_db.
|
|
458
|
+
with _db.transaction("admin:update_user_role", ctx):
|
|
459
|
+
_db.users[uuid].role = role_uuid
|
|
865
460
|
|
|
866
461
|
|
|
867
462
|
def update_user_role_in_organization(
|
|
868
|
-
user_uuid:
|
|
463
|
+
user_uuid: UUID,
|
|
869
464
|
role_name: str,
|
|
870
465
|
*,
|
|
871
466
|
ctx: SessionContext | None = None,
|
|
872
467
|
) -> None:
|
|
873
468
|
"""Update user's role by role name within their current organization."""
|
|
874
|
-
if
|
|
875
|
-
user_uuid = UUID(user_uuid)
|
|
876
|
-
if user_uuid not in _db._data.users:
|
|
469
|
+
if user_uuid not in _db.users:
|
|
877
470
|
raise ValueError(f"User {user_uuid} not found")
|
|
878
|
-
current_role_uuid = _db.
|
|
879
|
-
if current_role_uuid not in _db.
|
|
471
|
+
current_role_uuid = _db.users[user_uuid].role
|
|
472
|
+
if current_role_uuid not in _db.roles:
|
|
880
473
|
raise ValueError("Current role not found")
|
|
881
|
-
org_uuid = _db.
|
|
474
|
+
org_uuid = _db.roles[current_role_uuid].org
|
|
882
475
|
# Find role by name in the same org
|
|
883
476
|
new_role_uuid = None
|
|
884
|
-
for rid, r in _db.
|
|
477
|
+
for rid, r in _db.roles.items():
|
|
885
478
|
if r.org == org_uuid and r.display_name == role_name:
|
|
886
479
|
new_role_uuid = rid
|
|
887
480
|
break
|
|
888
481
|
if new_role_uuid is None:
|
|
889
482
|
raise ValueError(f"Role '{role_name}' not found in organization")
|
|
890
|
-
with _db.transaction("
|
|
891
|
-
_db.
|
|
483
|
+
with _db.transaction("admin:update_user_role", ctx):
|
|
484
|
+
_db.users[user_uuid].role = new_role_uuid
|
|
892
485
|
|
|
893
486
|
|
|
894
|
-
def delete_user(uuid:
|
|
487
|
+
def delete_user(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
|
|
895
488
|
"""Delete user and their credentials/sessions."""
|
|
896
|
-
if
|
|
897
|
-
uuid = UUID(uuid)
|
|
898
|
-
if uuid not in _db._data.users:
|
|
489
|
+
if uuid not in _db.users:
|
|
899
490
|
raise ValueError(f"User {uuid} not found")
|
|
900
|
-
with _db.transaction("
|
|
491
|
+
with _db.transaction("admin:delete_user", ctx):
|
|
901
492
|
# Delete credentials
|
|
902
|
-
cred_uuids = [cid for cid, c in _db.
|
|
493
|
+
cred_uuids = [cid for cid, c in _db.credentials.items() if c.user == uuid]
|
|
903
494
|
for cid in cred_uuids:
|
|
904
|
-
del _db.
|
|
495
|
+
del _db.credentials[cid]
|
|
905
496
|
# Delete sessions
|
|
906
|
-
sess_keys = [k for k, s in _db.
|
|
497
|
+
sess_keys = [k for k, s in _db.sessions.items() if s.user == uuid]
|
|
907
498
|
for k in sess_keys:
|
|
908
|
-
del _db.
|
|
499
|
+
del _db.sessions[k]
|
|
909
500
|
# Delete reset tokens
|
|
910
|
-
token_keys = [k for k, t in _db.
|
|
501
|
+
token_keys = [k for k, t in _db.reset_tokens.items() if t.user == uuid]
|
|
911
502
|
for k in token_keys:
|
|
912
|
-
del _db.
|
|
913
|
-
del _db.
|
|
503
|
+
del _db.reset_tokens[k]
|
|
504
|
+
del _db.users[uuid]
|
|
914
505
|
|
|
915
506
|
|
|
916
507
|
def create_credential(cred: Credential, *, ctx: SessionContext | None = None) -> None:
|
|
917
508
|
"""Create a new credential."""
|
|
918
|
-
if cred.uuid in _db.
|
|
509
|
+
if cred.uuid in _db.credentials:
|
|
919
510
|
raise ValueError(f"Credential {cred.uuid} already exists")
|
|
920
|
-
if cred.
|
|
921
|
-
raise ValueError(f"User {cred.
|
|
922
|
-
with _db.transaction("
|
|
923
|
-
_db.
|
|
924
|
-
credential_id=cred.credential_id,
|
|
925
|
-
user=cred.user_uuid,
|
|
926
|
-
aaguid=cred.aaguid,
|
|
927
|
-
public_key=cred.public_key,
|
|
928
|
-
sign_count=cred.sign_count,
|
|
929
|
-
created_at=cred.created_at,
|
|
930
|
-
last_used=cred.last_used,
|
|
931
|
-
last_verified=cred.last_verified,
|
|
932
|
-
)
|
|
511
|
+
if cred.user not in _db.users:
|
|
512
|
+
raise ValueError(f"User {cred.user} not found")
|
|
513
|
+
with _db.transaction("create_credential", ctx):
|
|
514
|
+
_db.credentials[cred.uuid] = cred
|
|
933
515
|
|
|
934
516
|
|
|
935
517
|
def update_credential_sign_count(
|
|
936
|
-
uuid:
|
|
518
|
+
uuid: UUID,
|
|
937
519
|
sign_count: int,
|
|
938
520
|
last_used: datetime | None = None,
|
|
939
521
|
*,
|
|
940
522
|
ctx: SessionContext | None = None,
|
|
941
523
|
) -> None:
|
|
942
524
|
"""Update credential sign count and last_used."""
|
|
943
|
-
if
|
|
944
|
-
uuid = UUID(uuid)
|
|
945
|
-
if uuid not in _db._data.credentials:
|
|
525
|
+
if uuid not in _db.credentials:
|
|
946
526
|
raise ValueError(f"Credential {uuid} not found")
|
|
947
|
-
with _db.transaction("
|
|
948
|
-
_db.
|
|
527
|
+
with _db.transaction("update_credential_sign_count", ctx):
|
|
528
|
+
_db.credentials[uuid].sign_count = sign_count
|
|
949
529
|
if last_used:
|
|
950
|
-
_db.
|
|
530
|
+
_db.credentials[uuid].last_used = last_used
|
|
951
531
|
|
|
952
532
|
|
|
953
533
|
def delete_credential(
|
|
954
|
-
uuid:
|
|
955
|
-
user_uuid:
|
|
534
|
+
uuid: UUID,
|
|
535
|
+
user_uuid: UUID | None = None,
|
|
956
536
|
*,
|
|
957
537
|
ctx: SessionContext | None = None,
|
|
958
538
|
) -> None:
|
|
959
|
-
"""Delete a credential.
|
|
539
|
+
"""Delete a credential and all sessions using it.
|
|
960
540
|
|
|
961
541
|
If user_uuid is provided, validates that the credential belongs to that user.
|
|
962
542
|
"""
|
|
963
|
-
if
|
|
964
|
-
uuid = UUID(uuid)
|
|
965
|
-
if uuid not in _db._data.credentials:
|
|
543
|
+
if uuid not in _db.credentials:
|
|
966
544
|
raise ValueError(f"Credential {uuid} not found")
|
|
967
545
|
if user_uuid is not None:
|
|
968
|
-
|
|
969
|
-
user_uuid = UUID(user_uuid)
|
|
970
|
-
cred_user = _db._data.credentials[uuid].user
|
|
546
|
+
cred_user = _db.credentials[uuid].user
|
|
971
547
|
if cred_user != user_uuid:
|
|
972
548
|
raise ValueError(f"Credential {uuid} does not belong to user {user_uuid}")
|
|
973
|
-
with _db.transaction("
|
|
974
|
-
|
|
549
|
+
with _db.transaction("delete_credential", ctx):
|
|
550
|
+
# Delete all sessions using this credential
|
|
551
|
+
keys = [k for k, s in _db.sessions.items() if s.credential == uuid]
|
|
552
|
+
for k in keys:
|
|
553
|
+
del _db.sessions[k]
|
|
554
|
+
del _db.credentials[uuid]
|
|
975
555
|
|
|
976
556
|
|
|
977
557
|
def create_session(
|
|
978
558
|
key: str,
|
|
979
559
|
user_uuid: UUID,
|
|
980
560
|
credential_uuid: UUID,
|
|
981
|
-
host: str
|
|
982
|
-
ip: str
|
|
983
|
-
user_agent: str
|
|
561
|
+
host: str,
|
|
562
|
+
ip: str,
|
|
563
|
+
user_agent: str,
|
|
984
564
|
expiry: datetime,
|
|
985
565
|
*,
|
|
986
566
|
ctx: SessionContext | None = None,
|
|
987
567
|
) -> None:
|
|
988
568
|
"""Create a new session."""
|
|
989
|
-
if key in _db.
|
|
569
|
+
if key in _db.sessions:
|
|
990
570
|
raise ValueError("Session already exists")
|
|
991
|
-
if user_uuid not in _db.
|
|
571
|
+
if user_uuid not in _db.users:
|
|
992
572
|
raise ValueError(f"User {user_uuid} not found")
|
|
993
|
-
if credential_uuid not in _db.
|
|
573
|
+
if credential_uuid not in _db.credentials:
|
|
994
574
|
raise ValueError(f"Credential {credential_uuid} not found")
|
|
995
|
-
with _db.transaction("
|
|
996
|
-
_db.
|
|
575
|
+
with _db.transaction("create_session", ctx):
|
|
576
|
+
_db.sessions[key] = Session(
|
|
997
577
|
user=user_uuid,
|
|
998
578
|
credential=credential_uuid,
|
|
999
579
|
host=host,
|
|
@@ -1013,10 +593,10 @@ def update_session(
|
|
|
1013
593
|
ctx: SessionContext | None = None,
|
|
1014
594
|
) -> None:
|
|
1015
595
|
"""Update session metadata."""
|
|
1016
|
-
if key not in _db.
|
|
596
|
+
if key not in _db.sessions:
|
|
1017
597
|
raise ValueError("Session not found")
|
|
1018
|
-
with _db.transaction("
|
|
1019
|
-
s = _db.
|
|
598
|
+
with _db.transaction("update_session", ctx):
|
|
599
|
+
s = _db.sessions[key]
|
|
1020
600
|
if host is not None:
|
|
1021
601
|
s.host = host
|
|
1022
602
|
if ip is not None:
|
|
@@ -1035,33 +615,29 @@ def set_session_host(key: str, host: str, *, ctx: SessionContext | None = None)
|
|
|
1035
615
|
def delete_session(key: str, *, ctx: SessionContext | None = None) -> None:
|
|
1036
616
|
"""Delete a session.
|
|
1037
617
|
|
|
1038
|
-
|
|
1039
|
-
For
|
|
618
|
+
The acting user should be logged via ctx.
|
|
619
|
+
For user logout, pass ctx of the user's session.
|
|
620
|
+
For admin terminating a session, pass admin's ctx.
|
|
1040
621
|
"""
|
|
1041
|
-
if key not in _db.
|
|
622
|
+
if key not in _db.sessions:
|
|
1042
623
|
raise ValueError("Session not found")
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
with _db.transaction("Deleted session", ctx, user=user_str):
|
|
1046
|
-
del _db._data.sessions[key]
|
|
624
|
+
with _db.transaction("delete_session", ctx):
|
|
625
|
+
del _db.sessions[key]
|
|
1047
626
|
|
|
1048
627
|
|
|
1049
628
|
def delete_sessions_for_user(
|
|
1050
|
-
user_uuid:
|
|
629
|
+
user_uuid: UUID, *, ctx: SessionContext | None = None
|
|
1051
630
|
) -> None:
|
|
1052
631
|
"""Delete all sessions for a user.
|
|
1053
632
|
|
|
1054
|
-
|
|
1055
|
-
For
|
|
633
|
+
The acting user should be logged via ctx.
|
|
634
|
+
For user logout-all, pass ctx of the user's session.
|
|
635
|
+
For admin bulk termination, pass admin's ctx.
|
|
1056
636
|
"""
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
# For self-service, derive user from the user_uuid param
|
|
1060
|
-
user_str = str(user_uuid) if not ctx else None
|
|
1061
|
-
with _db.transaction("Deleted user sessions", ctx, user=user_str):
|
|
1062
|
-
keys = [k for k, s in _db._data.sessions.items() if s.user == user_uuid]
|
|
637
|
+
with _db.transaction("admin:delete_sessions_for_user", ctx):
|
|
638
|
+
keys = [k for k, s in _db.sessions.items() if s.user == user_uuid]
|
|
1063
639
|
for k in keys:
|
|
1064
|
-
del _db.
|
|
640
|
+
del _db.sessions[k]
|
|
1065
641
|
|
|
1066
642
|
|
|
1067
643
|
def create_reset_token(
|
|
@@ -1074,28 +650,28 @@ def create_reset_token(
|
|
|
1074
650
|
) -> None:
|
|
1075
651
|
"""Create a reset token from a passphrase.
|
|
1076
652
|
|
|
1077
|
-
|
|
1078
|
-
For
|
|
653
|
+
The acting user should be logged via ctx.
|
|
654
|
+
For self-service (user creating own recovery link), pass user's ctx.
|
|
655
|
+
For admin operations, pass admin's ctx.
|
|
656
|
+
For system operations (bootstrap), pass neither to log no user.
|
|
1079
657
|
"""
|
|
1080
658
|
key = _reset_key(passphrase)
|
|
1081
|
-
if key in _db.
|
|
659
|
+
if key in _db.reset_tokens:
|
|
1082
660
|
raise ValueError("Reset token already exists")
|
|
1083
|
-
if user_uuid not in _db.
|
|
661
|
+
if user_uuid not in _db.users:
|
|
1084
662
|
raise ValueError(f"User {user_uuid} not found")
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
with _db.transaction("Created reset token", ctx, user=user_str):
|
|
1088
|
-
_db._data.reset_tokens[key] = _ResetTokenData(
|
|
663
|
+
with _db.transaction("create_reset_token", ctx):
|
|
664
|
+
_db.reset_tokens[key] = ResetToken(
|
|
1089
665
|
user=user_uuid, expiry=expiry, token_type=token_type
|
|
1090
666
|
)
|
|
1091
667
|
|
|
1092
668
|
|
|
1093
669
|
def delete_reset_token(key: bytes, *, ctx: SessionContext | None = None) -> None:
|
|
1094
670
|
"""Delete a reset token."""
|
|
1095
|
-
if key not in _db.
|
|
671
|
+
if key not in _db.reset_tokens:
|
|
1096
672
|
raise ValueError("Reset token not found")
|
|
1097
|
-
with _db.transaction("
|
|
1098
|
-
del _db.
|
|
673
|
+
with _db.transaction("delete_reset_token", ctx):
|
|
674
|
+
del _db.reset_tokens[key]
|
|
1099
675
|
|
|
1100
676
|
|
|
1101
677
|
# -------------------------------------------------------------------------
|
|
@@ -1107,16 +683,14 @@ def cleanup_expired() -> int:
|
|
|
1107
683
|
"""Remove expired sessions and reset tokens. Returns count removed."""
|
|
1108
684
|
now = datetime.now(timezone.utc)
|
|
1109
685
|
count = 0
|
|
1110
|
-
with _db.transaction("
|
|
1111
|
-
expired_sessions = [k for k, s in _db.
|
|
686
|
+
with _db.transaction("expiry"):
|
|
687
|
+
expired_sessions = [k for k, s in _db.sessions.items() if s.expiry < now]
|
|
1112
688
|
for k in expired_sessions:
|
|
1113
|
-
del _db.
|
|
689
|
+
del _db.sessions[k]
|
|
1114
690
|
count += 1
|
|
1115
|
-
expired_tokens = [
|
|
1116
|
-
k for k, t in _db._data.reset_tokens.items() if t.expiry < now
|
|
1117
|
-
]
|
|
691
|
+
expired_tokens = [k for k, t in _db.reset_tokens.items() if t.expiry < now]
|
|
1118
692
|
for k in expired_tokens:
|
|
1119
|
-
del _db.
|
|
693
|
+
del _db.reset_tokens[k]
|
|
1120
694
|
count += 1
|
|
1121
695
|
return count
|
|
1122
696
|
|
|
@@ -1132,11 +706,12 @@ def _create_token() -> str:
|
|
|
1132
706
|
|
|
1133
707
|
|
|
1134
708
|
def login(
|
|
1135
|
-
user_uuid:
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
709
|
+
user_uuid: UUID,
|
|
710
|
+
credential_uuid: UUID,
|
|
711
|
+
sign_count: int,
|
|
712
|
+
host: str,
|
|
713
|
+
ip: str,
|
|
714
|
+
user_agent: str,
|
|
1140
715
|
expiry: datetime,
|
|
1141
716
|
) -> str:
|
|
1142
717
|
"""Update user/credential on login and create session in a single transaction.
|
|
@@ -1152,24 +727,24 @@ def login(
|
|
|
1152
727
|
if isinstance(user_uuid, str):
|
|
1153
728
|
user_uuid = UUID(user_uuid)
|
|
1154
729
|
now = datetime.now(timezone.utc)
|
|
1155
|
-
if user_uuid not in _db.
|
|
730
|
+
if user_uuid not in _db.users:
|
|
1156
731
|
raise ValueError(f"User {user_uuid} not found")
|
|
1157
|
-
if
|
|
1158
|
-
raise ValueError(f"Credential {
|
|
732
|
+
if credential_uuid not in _db.credentials:
|
|
733
|
+
raise ValueError(f"Credential {credential_uuid} not found")
|
|
1159
734
|
|
|
1160
735
|
session_key = _create_token()
|
|
1161
736
|
user_str = str(user_uuid)
|
|
1162
|
-
with _db.transaction("
|
|
737
|
+
with _db.transaction("login", user=user_str):
|
|
1163
738
|
# Update user
|
|
1164
|
-
_db.
|
|
1165
|
-
_db.
|
|
739
|
+
_db.users[user_uuid].last_seen = now
|
|
740
|
+
_db.users[user_uuid].visits += 1
|
|
1166
741
|
# Update credential
|
|
1167
|
-
_db.
|
|
1168
|
-
_db.
|
|
742
|
+
_db.credentials[credential_uuid].sign_count = sign_count
|
|
743
|
+
_db.credentials[credential_uuid].last_used = now
|
|
1169
744
|
# Create session
|
|
1170
|
-
_db.
|
|
745
|
+
_db.sessions[session_key] = Session(
|
|
1171
746
|
user=user_uuid,
|
|
1172
|
-
credential=
|
|
747
|
+
credential=credential_uuid,
|
|
1173
748
|
host=host,
|
|
1174
749
|
ip=ip,
|
|
1175
750
|
user_agent=user_agent,
|
|
@@ -1181,9 +756,9 @@ def login(
|
|
|
1181
756
|
def create_credential_session(
|
|
1182
757
|
user_uuid: UUID,
|
|
1183
758
|
credential: Credential,
|
|
1184
|
-
host: str
|
|
1185
|
-
ip: str
|
|
1186
|
-
user_agent: str
|
|
759
|
+
host: str,
|
|
760
|
+
ip: str,
|
|
761
|
+
user_agent: str,
|
|
1187
762
|
display_name: str | None = None,
|
|
1188
763
|
reset_key: bytes | None = None,
|
|
1189
764
|
) -> str:
|
|
@@ -1197,35 +772,25 @@ def create_credential_session(
|
|
|
1197
772
|
|
|
1198
773
|
Returns the generated session token.
|
|
1199
774
|
"""
|
|
1200
|
-
from paskia.config import SESSION_LIFETIME
|
|
1201
775
|
|
|
1202
776
|
now = datetime.now(timezone.utc)
|
|
1203
777
|
expiry = now + SESSION_LIFETIME
|
|
1204
778
|
session_key = _create_token()
|
|
1205
779
|
|
|
1206
|
-
if user_uuid not in _db.
|
|
780
|
+
if user_uuid not in _db.users:
|
|
1207
781
|
raise ValueError(f"User {user_uuid} not found")
|
|
1208
782
|
|
|
1209
783
|
user_str = str(user_uuid)
|
|
1210
|
-
with _db.transaction("
|
|
784
|
+
with _db.transaction("create_credential_session", user=user_str):
|
|
1211
785
|
# Update display name if provided
|
|
1212
786
|
if display_name:
|
|
1213
|
-
_db.
|
|
787
|
+
_db.users[user_uuid].display_name = display_name
|
|
1214
788
|
|
|
1215
789
|
# Create credential
|
|
1216
|
-
_db.
|
|
1217
|
-
credential_id=credential.credential_id,
|
|
1218
|
-
user=user_uuid,
|
|
1219
|
-
aaguid=credential.aaguid,
|
|
1220
|
-
public_key=credential.public_key,
|
|
1221
|
-
sign_count=credential.sign_count,
|
|
1222
|
-
created_at=credential.created_at,
|
|
1223
|
-
last_used=credential.last_used,
|
|
1224
|
-
last_verified=credential.last_verified,
|
|
1225
|
-
)
|
|
790
|
+
_db.credentials[credential.uuid] = credential
|
|
1226
791
|
|
|
1227
792
|
# Create session
|
|
1228
|
-
_db.
|
|
793
|
+
_db.sessions[session_key] = Session(
|
|
1229
794
|
user=user_uuid,
|
|
1230
795
|
credential=credential.uuid,
|
|
1231
796
|
host=host,
|
|
@@ -1236,6 +801,118 @@ def create_credential_session(
|
|
|
1236
801
|
|
|
1237
802
|
# Delete reset token if provided
|
|
1238
803
|
if reset_key:
|
|
1239
|
-
if reset_key in _db.
|
|
1240
|
-
del _db.
|
|
804
|
+
if reset_key in _db.reset_tokens:
|
|
805
|
+
del _db.reset_tokens[reset_key]
|
|
1241
806
|
return session_key
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
# -------------------------------------------------------------------------
|
|
810
|
+
# Bootstrap (single transaction for initial system setup)
|
|
811
|
+
# -------------------------------------------------------------------------
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
def bootstrap(
|
|
815
|
+
org_name: str = "Organization",
|
|
816
|
+
admin_name: str = "Admin",
|
|
817
|
+
reset_passphrase: str | None = None,
|
|
818
|
+
reset_expiry: datetime | None = None,
|
|
819
|
+
) -> str:
|
|
820
|
+
"""Bootstrap the entire system in a single transaction.
|
|
821
|
+
|
|
822
|
+
Creates:
|
|
823
|
+
- auth:admin permission (Master Admin)
|
|
824
|
+
- auth:org:admin permission (Org Admin)
|
|
825
|
+
- Organization with Administration role
|
|
826
|
+
- Admin user with Administration role
|
|
827
|
+
- Reset token for admin registration
|
|
828
|
+
|
|
829
|
+
This is the only way to create a new database file (besides migrate).
|
|
830
|
+
All data is created atomically - if any step fails, nothing is written.
|
|
831
|
+
|
|
832
|
+
Args:
|
|
833
|
+
org_name: Display name for the organization (default: "Organization")
|
|
834
|
+
admin_name: Display name for the admin user (default: "Admin")
|
|
835
|
+
reset_passphrase: Passphrase for the reset token (generated if not provided)
|
|
836
|
+
reset_expiry: Expiry datetime for the reset token (default: 14 days)
|
|
837
|
+
|
|
838
|
+
Returns:
|
|
839
|
+
The reset passphrase for admin registration.
|
|
840
|
+
"""
|
|
841
|
+
|
|
842
|
+
# Check if system is already bootstrapped
|
|
843
|
+
for p in _db.permissions.values():
|
|
844
|
+
if p.scope == "auth:admin":
|
|
845
|
+
raise ValueError(
|
|
846
|
+
"System already bootstrapped (auth:admin permission exists)"
|
|
847
|
+
)
|
|
848
|
+
|
|
849
|
+
# Generate UUIDs upfront
|
|
850
|
+
perm_admin_uuid = uuid7.create()
|
|
851
|
+
perm_org_admin_uuid = uuid7.create()
|
|
852
|
+
org_uuid = uuid7.create()
|
|
853
|
+
role_uuid = uuid7.create()
|
|
854
|
+
user_uuid = uuid7.create()
|
|
855
|
+
|
|
856
|
+
# Generate reset token components
|
|
857
|
+
if reset_passphrase is None:
|
|
858
|
+
reset_passphrase = generate_passphrase()
|
|
859
|
+
if reset_expiry is None:
|
|
860
|
+
from paskia.authsession import reset_expires # noqa: PLC0415
|
|
861
|
+
|
|
862
|
+
reset_expiry = reset_expires()
|
|
863
|
+
reset_key = _reset_key(reset_passphrase)
|
|
864
|
+
|
|
865
|
+
now = datetime.now(timezone.utc)
|
|
866
|
+
|
|
867
|
+
with _db.transaction("bootstrap"):
|
|
868
|
+
# Create auth:admin permission
|
|
869
|
+
perm_admin = Permission(
|
|
870
|
+
scope="auth:admin",
|
|
871
|
+
display_name="Master Admin",
|
|
872
|
+
orgs={org_uuid: True}, # Grant to org
|
|
873
|
+
)
|
|
874
|
+
perm_admin.uuid = perm_admin_uuid
|
|
875
|
+
_db.permissions[perm_admin_uuid] = perm_admin
|
|
876
|
+
|
|
877
|
+
# Create auth:org:admin permission
|
|
878
|
+
perm_org_admin = Permission(
|
|
879
|
+
scope="auth:org:admin",
|
|
880
|
+
display_name="Org Admin",
|
|
881
|
+
orgs={org_uuid: True}, # Grant to org
|
|
882
|
+
)
|
|
883
|
+
perm_org_admin.uuid = perm_org_admin_uuid
|
|
884
|
+
_db.permissions[perm_org_admin_uuid] = perm_org_admin
|
|
885
|
+
|
|
886
|
+
# Create organization
|
|
887
|
+
new_org = Org(display_name=org_name)
|
|
888
|
+
new_org.uuid = org_uuid
|
|
889
|
+
_db.orgs[org_uuid] = new_org
|
|
890
|
+
|
|
891
|
+
# Create Administration role with both permissions
|
|
892
|
+
admin_role = Role(
|
|
893
|
+
org=org_uuid,
|
|
894
|
+
display_name="Administration",
|
|
895
|
+
permissions={perm_admin_uuid: True, perm_org_admin_uuid: True},
|
|
896
|
+
)
|
|
897
|
+
admin_role.uuid = role_uuid
|
|
898
|
+
_db.roles[role_uuid] = admin_role
|
|
899
|
+
|
|
900
|
+
# Create admin user
|
|
901
|
+
admin_user = User(
|
|
902
|
+
display_name=admin_name,
|
|
903
|
+
role=role_uuid,
|
|
904
|
+
created_at=now,
|
|
905
|
+
last_seen=None,
|
|
906
|
+
visits=0,
|
|
907
|
+
)
|
|
908
|
+
admin_user.uuid = user_uuid
|
|
909
|
+
_db.users[user_uuid] = admin_user
|
|
910
|
+
|
|
911
|
+
# Create reset token
|
|
912
|
+
_db.reset_tokens[reset_key] = ResetToken(
|
|
913
|
+
user=user_uuid,
|
|
914
|
+
expiry=reset_expiry,
|
|
915
|
+
token_type="admin bootstrap",
|
|
916
|
+
)
|
|
917
|
+
|
|
918
|
+
return reset_passphrase
|