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