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/_version.py
CHANGED
|
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
|
|
|
28
28
|
commit_id: COMMIT_ID
|
|
29
29
|
__commit_id__: COMMIT_ID
|
|
30
30
|
|
|
31
|
-
__version__ = version = '0.
|
|
32
|
-
__version_tuple__ = version_tuple = (0,
|
|
31
|
+
__version__ = version = '0.9.1'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 9, 1)
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|
paskia/aaguid/__init__.py
CHANGED
|
@@ -10,6 +10,7 @@ This module provides functionality to:
|
|
|
10
10
|
import json
|
|
11
11
|
from collections.abc import Iterable
|
|
12
12
|
from importlib.resources import files
|
|
13
|
+
from uuid import UUID
|
|
13
14
|
|
|
14
15
|
__ALL__ = ["AAGUID", "filter"]
|
|
15
16
|
|
|
@@ -18,15 +19,15 @@ AAGUID_FILE = files("paskia") / "aaguid" / "combined_aaguid.json"
|
|
|
18
19
|
AAGUID: dict[str, dict] = json.loads(AAGUID_FILE.read_text(encoding="utf-8"))
|
|
19
20
|
|
|
20
21
|
|
|
21
|
-
def filter(aaguids: Iterable[
|
|
22
|
+
def filter(aaguids: Iterable[UUID]) -> dict[str, dict]:
|
|
22
23
|
"""
|
|
23
24
|
Get AAGUID information only for the provided set of AAGUIDs.
|
|
24
25
|
|
|
25
26
|
Args:
|
|
26
|
-
aaguids:
|
|
27
|
+
aaguids: Iterable of AAGUIDs (UUIDs) that the user has credentials for
|
|
27
28
|
|
|
28
29
|
Returns:
|
|
29
|
-
Dictionary mapping AAGUID to authenticator information for only
|
|
30
|
+
Dictionary mapping AAGUID string to authenticator information for only
|
|
30
31
|
the AAGUIDs that the user has and that we have data for
|
|
31
32
|
"""
|
|
32
|
-
return {
|
|
33
|
+
return {(s := str(a)): AAGUID[s] for a in aaguids if (s := str(a)) in AAGUID}
|
paskia/authsession.py
CHANGED
|
@@ -8,68 +8,40 @@ independent of any web framework:
|
|
|
8
8
|
- Credential management
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
|
-
from datetime import
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
12
13
|
from uuid import UUID
|
|
13
14
|
|
|
14
15
|
from paskia import db
|
|
15
|
-
from paskia.config import SESSION_LIFETIME
|
|
16
|
-
from paskia.db import ResetToken, Session
|
|
16
|
+
from paskia.config import RESET_LIFETIME, SESSION_LIFETIME
|
|
17
17
|
from paskia.util import hostutil
|
|
18
18
|
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from paskia.db import ResetToken
|
|
21
|
+
|
|
19
22
|
EXPIRES = SESSION_LIFETIME
|
|
20
23
|
|
|
21
24
|
|
|
22
25
|
def expires() -> datetime:
|
|
23
|
-
return datetime.now(
|
|
26
|
+
return datetime.now(UTC) + EXPIRES
|
|
24
27
|
|
|
25
28
|
|
|
26
29
|
def reset_expires() -> datetime:
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
return datetime.now(timezone.utc) + RESET_LIFETIME
|
|
30
|
+
return datetime.now(UTC) + RESET_LIFETIME
|
|
30
31
|
|
|
31
32
|
|
|
32
|
-
|
|
33
|
+
def get_reset(token: str) -> "ResetToken":
|
|
33
34
|
"""Validate a credential reset token."""
|
|
35
|
+
|
|
34
36
|
record = db.get_reset_token(token)
|
|
35
37
|
if record:
|
|
36
38
|
return record
|
|
37
39
|
raise ValueError("This authentication link is no longer valid.")
|
|
38
40
|
|
|
39
41
|
|
|
40
|
-
|
|
41
|
-
"""Validate a session token and return session data if valid."""
|
|
42
|
-
host = hostutil.normalize_host(host)
|
|
43
|
-
if not host:
|
|
44
|
-
raise ValueError("Invalid host")
|
|
45
|
-
session = db.get_session(token)
|
|
46
|
-
if session:
|
|
47
|
-
if session.host is None:
|
|
48
|
-
# First time binding: store exact host:port (or IPv6 form) now.
|
|
49
|
-
db.set_session_host(session.key, host)
|
|
50
|
-
session.host = host
|
|
51
|
-
elif session.host != host:
|
|
52
|
-
raise ValueError("Session host mismatch")
|
|
53
|
-
return session
|
|
54
|
-
raise ValueError("Your session has expired. Please sign in again!")
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
async def refresh_session_token(token: str, *, ip: str, user_agent: str):
|
|
58
|
-
"""Refresh a session extending its expiry."""
|
|
59
|
-
session_record = db.get_session(token)
|
|
60
|
-
if not session_record:
|
|
61
|
-
raise ValueError("Session not found or expired")
|
|
62
|
-
updated = db.update_session(
|
|
63
|
-
token,
|
|
64
|
-
ip=ip,
|
|
65
|
-
user_agent=user_agent,
|
|
66
|
-
expiry=expires(),
|
|
67
|
-
)
|
|
68
|
-
if not updated:
|
|
69
|
-
raise ValueError("Session not found or expired")
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
async def delete_credential(credential_uuid: UUID, auth: str, host: str | None = None):
|
|
42
|
+
def delete_credential(credential_uuid: UUID, auth: str, host: str | None = None):
|
|
73
43
|
"""Delete a specific credential for the current user."""
|
|
74
|
-
|
|
75
|
-
|
|
44
|
+
ctx = db.data().session_ctx(auth, hostutil.normalize_host(host))
|
|
45
|
+
if not ctx:
|
|
46
|
+
raise ValueError("Session expired")
|
|
47
|
+
db.delete_credential(credential_uuid, ctx.user.uuid)
|
paskia/bootstrap.py
CHANGED
|
@@ -8,26 +8,11 @@ generating a reset link for initial admin setup.
|
|
|
8
8
|
|
|
9
9
|
import asyncio
|
|
10
10
|
import logging
|
|
11
|
-
from datetime import datetime, timezone
|
|
12
11
|
|
|
13
|
-
import
|
|
14
|
-
|
|
15
|
-
from paskia import authsession, db
|
|
16
|
-
from paskia.db import Org, Permission, Role, User
|
|
12
|
+
from paskia import authsession, db, globals
|
|
17
13
|
from paskia.util import hostutil, passphrase
|
|
18
14
|
|
|
19
|
-
|
|
20
|
-
def _init_logger() -> logging.Logger:
|
|
21
|
-
logger = logging.getLogger(__name__)
|
|
22
|
-
if not logger.handlers and not logging.getLogger().handlers:
|
|
23
|
-
h = logging.StreamHandler()
|
|
24
|
-
h.setFormatter(logging.Formatter("%(message)s"))
|
|
25
|
-
logger.addHandler(h)
|
|
26
|
-
logger.setLevel(logging.INFO)
|
|
27
|
-
return logger
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
logger = _init_logger()
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
31
16
|
|
|
32
17
|
# Shared log message template for admin reset links
|
|
33
18
|
ADMIN_RESET_MESSAGE = """\
|
|
@@ -38,84 +23,25 @@ ADMIN_RESET_MESSAGE = """\
|
|
|
38
23
|
"""
|
|
39
24
|
|
|
40
25
|
|
|
41
|
-
|
|
42
|
-
"""
|
|
43
|
-
|
|
44
|
-
expiry = authsession.reset_expires()
|
|
45
|
-
db.create_reset_token(
|
|
46
|
-
user_uuid=user_uuid,
|
|
47
|
-
passphrase=token,
|
|
48
|
-
expiry=expiry,
|
|
49
|
-
token_type=session_type,
|
|
50
|
-
)
|
|
51
|
-
reset_link = hostutil.reset_link_url(token)
|
|
26
|
+
def _log_reset_link(message: str, passphrase: str) -> str:
|
|
27
|
+
"""Log a reset link message and return the URL."""
|
|
28
|
+
reset_link = hostutil.reset_link_url(passphrase)
|
|
52
29
|
logger.info(ADMIN_RESET_MESSAGE, message, reset_link)
|
|
53
30
|
return reset_link
|
|
54
31
|
|
|
55
32
|
|
|
56
|
-
async def bootstrap_system() ->
|
|
33
|
+
async def bootstrap_system() -> None:
|
|
57
34
|
"""
|
|
58
35
|
Bootstrap the entire system with default data.
|
|
59
36
|
|
|
60
|
-
|
|
61
|
-
|
|
37
|
+
Uses db.bootstrap() which performs all operations in a single transaction.
|
|
38
|
+
The transaction log will show a single "bootstrap" action with all changes.
|
|
62
39
|
"""
|
|
63
|
-
#
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
)
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
# Create org admin permission - allows managing users within an org
|
|
70
|
-
perm_org_admin = Permission(
|
|
71
|
-
uuid=uuid7.create(), scope="auth:org:admin", display_name="Org Admin"
|
|
72
|
-
)
|
|
73
|
-
db.create_permission(perm_org_admin)
|
|
74
|
-
|
|
75
|
-
org = Org(uuid7.create(), "Organization")
|
|
76
|
-
db.create_organization(org)
|
|
77
|
-
|
|
78
|
-
# Allow this org to grant global admin and org admin permissions
|
|
79
|
-
db.add_permission_to_organization(str(org.uuid), perm0.scope)
|
|
80
|
-
db.add_permission_to_organization(str(org.uuid), perm_org_admin.scope)
|
|
81
|
-
|
|
82
|
-
# Create an Administration role granting both org and global admin
|
|
83
|
-
role = Role(
|
|
84
|
-
uuid7.create(),
|
|
85
|
-
org.uuid,
|
|
86
|
-
"Administration",
|
|
87
|
-
permissions=[perm0.scope, perm_org_admin.scope],
|
|
88
|
-
)
|
|
89
|
-
db.create_role(role)
|
|
90
|
-
|
|
91
|
-
user = User(
|
|
92
|
-
uuid=uuid7.create(),
|
|
93
|
-
display_name="Admin",
|
|
94
|
-
role_uuid=role.uuid,
|
|
95
|
-
created_at=datetime.now(timezone.utc),
|
|
96
|
-
visits=0,
|
|
97
|
-
)
|
|
98
|
-
db.create_user(user)
|
|
99
|
-
|
|
100
|
-
# Generate reset link and log it
|
|
101
|
-
reset_link = await _create_and_log_admin_reset_link(
|
|
102
|
-
user.uuid, "✅ Bootstrap completed!", "admin bootstrap"
|
|
103
|
-
)
|
|
104
|
-
|
|
105
|
-
return {
|
|
106
|
-
"user": user,
|
|
107
|
-
"org": org,
|
|
108
|
-
"role": role,
|
|
109
|
-
"permissions": [
|
|
110
|
-
perm0,
|
|
111
|
-
*[
|
|
112
|
-
db.get_permission_by_scope(p)
|
|
113
|
-
for p in org.permissions
|
|
114
|
-
if db.get_permission_by_scope(p)
|
|
115
|
-
],
|
|
116
|
-
],
|
|
117
|
-
"reset_link": reset_link,
|
|
118
|
-
}
|
|
40
|
+
# Call the single-transaction bootstrap function
|
|
41
|
+
reset_passphrase = db.bootstrap()
|
|
42
|
+
|
|
43
|
+
# Log the reset link (this is separate from the transaction log)
|
|
44
|
+
_log_reset_link("✅ Bootstrap completed!", reset_passphrase)
|
|
119
45
|
|
|
120
46
|
|
|
121
47
|
async def check_admin_credentials() -> bool:
|
|
@@ -127,13 +53,15 @@ async def check_admin_credentials() -> bool:
|
|
|
127
53
|
"""
|
|
128
54
|
try:
|
|
129
55
|
# Get permission organizations to find admin users
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
56
|
+
p = next(
|
|
57
|
+
(p for p in db.data().permissions.values() if p.scope == "auth:admin"), None
|
|
58
|
+
)
|
|
59
|
+
if not p or not p.orgs:
|
|
133
60
|
return False
|
|
134
61
|
|
|
135
62
|
# Get users from the first organization with admin permission
|
|
136
|
-
|
|
63
|
+
first_org_uuid = next(iter(p.orgs))
|
|
64
|
+
org_users = db.get_organization_users(first_org_uuid)
|
|
137
65
|
admin_users = [user for user, role in org_users if role == "Administration"]
|
|
138
66
|
|
|
139
67
|
if not admin_users:
|
|
@@ -141,15 +69,19 @@ async def check_admin_credentials() -> bool:
|
|
|
141
69
|
|
|
142
70
|
# Check first admin user for credentials
|
|
143
71
|
admin_user = admin_users[0]
|
|
144
|
-
credentials = db.get_credentials_by_user_uuid(admin_user.uuid)
|
|
145
72
|
|
|
146
|
-
if not
|
|
73
|
+
if not db.get_user_credential_ids(admin_user.uuid):
|
|
147
74
|
# Admin exists but has no credentials, create reset link
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
75
|
+
|
|
76
|
+
token = passphrase.generate()
|
|
77
|
+
expiry = authsession.reset_expires()
|
|
78
|
+
db.create_reset_token(
|
|
79
|
+
user_uuid=admin_user.uuid,
|
|
80
|
+
passphrase=token,
|
|
81
|
+
expiry=expiry,
|
|
82
|
+
token_type="admin registration",
|
|
152
83
|
)
|
|
84
|
+
_log_reset_link("⚠️ Admin user has no credentials!", token)
|
|
153
85
|
return True
|
|
154
86
|
|
|
155
87
|
return False
|
|
@@ -165,16 +97,12 @@ async def bootstrap_if_needed() -> bool:
|
|
|
165
97
|
Returns:
|
|
166
98
|
bool: True if bootstrapping was performed, False if system was already set up
|
|
167
99
|
"""
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
db.get_permission("auth:admin")
|
|
100
|
+
# Check if the admin permission exists - if it does, system is already bootstrapped
|
|
101
|
+
if any(p.scope == "auth:admin" for p in db.data().permissions.values()):
|
|
171
102
|
# Permission exists, system is already bootstrapped
|
|
172
103
|
# Check if admin needs credentials (only for already-bootstrapped systems)
|
|
173
104
|
await check_admin_credentials()
|
|
174
105
|
return False
|
|
175
|
-
except Exception:
|
|
176
|
-
# Permission doesn't exist, need to bootstrap
|
|
177
|
-
pass
|
|
178
106
|
|
|
179
107
|
# No admin permission found, need to bootstrap
|
|
180
108
|
# Bootstrap creates the admin user AND the reset link, so no need to check credentials after
|
paskia/db/__init__.py
CHANGED
|
@@ -1,24 +1,25 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Database module for WebAuthn passkey authentication.
|
|
3
3
|
|
|
4
|
-
Read: Access
|
|
5
|
-
CTX:
|
|
4
|
+
Read: Access data() directly, use build_* to convert to public structs.
|
|
5
|
+
CTX: data().session_ctx(key) returns SessionContext with effective permissions.
|
|
6
6
|
Write: Functions validate and commit, or raise ValueError.
|
|
7
7
|
|
|
8
8
|
Usage:
|
|
9
9
|
from paskia import db
|
|
10
10
|
|
|
11
11
|
# Read (after init)
|
|
12
|
-
user_data = db.
|
|
12
|
+
user_data = db.data().users[user_uuid]
|
|
13
13
|
user = db.build_user(user_uuid)
|
|
14
14
|
|
|
15
15
|
# Context
|
|
16
|
-
ctx = db.
|
|
16
|
+
ctx = db.data().session_ctx(session_key)
|
|
17
17
|
|
|
18
18
|
# Write
|
|
19
19
|
db.create_user(user)
|
|
20
20
|
"""
|
|
21
21
|
|
|
22
|
+
import paskia.db.operations as operations
|
|
22
23
|
from paskia.db.background import (
|
|
23
24
|
start_background,
|
|
24
25
|
start_cleanup,
|
|
@@ -26,59 +27,37 @@ from paskia.db.background import (
|
|
|
26
27
|
stop_cleanup,
|
|
27
28
|
)
|
|
28
29
|
from paskia.db.operations import (
|
|
29
|
-
|
|
30
|
-
_db,
|
|
31
|
-
add_permission_to_organization,
|
|
30
|
+
add_permission_to_org,
|
|
32
31
|
add_permission_to_role,
|
|
33
|
-
|
|
34
|
-
build_org,
|
|
35
|
-
build_permission,
|
|
36
|
-
build_reset_token,
|
|
37
|
-
build_role,
|
|
38
|
-
build_session,
|
|
39
|
-
build_user,
|
|
32
|
+
bootstrap,
|
|
40
33
|
cleanup_expired,
|
|
41
34
|
create_credential,
|
|
42
35
|
create_credential_session,
|
|
43
|
-
|
|
36
|
+
create_org,
|
|
44
37
|
create_permission,
|
|
45
38
|
create_reset_token,
|
|
46
39
|
create_role,
|
|
47
40
|
create_session,
|
|
48
41
|
create_user,
|
|
49
42
|
delete_credential,
|
|
50
|
-
|
|
43
|
+
delete_org,
|
|
51
44
|
delete_permission,
|
|
52
45
|
delete_reset_token,
|
|
53
46
|
delete_role,
|
|
54
47
|
delete_session,
|
|
55
48
|
delete_sessions_for_user,
|
|
56
49
|
delete_user,
|
|
57
|
-
get_credential_by_id,
|
|
58
|
-
get_credentials_by_user_uuid,
|
|
59
|
-
get_organization,
|
|
60
50
|
get_organization_users,
|
|
61
|
-
get_permission,
|
|
62
|
-
get_permission_by_scope,
|
|
63
|
-
get_permission_organizations,
|
|
64
51
|
get_reset_token,
|
|
65
|
-
|
|
66
|
-
get_roles_by_organization,
|
|
67
|
-
get_session,
|
|
68
|
-
get_session_context,
|
|
69
|
-
get_user_by_uuid,
|
|
52
|
+
get_user_credential_ids,
|
|
70
53
|
get_user_organization,
|
|
71
54
|
init,
|
|
72
|
-
list_organizations,
|
|
73
|
-
list_permissions,
|
|
74
|
-
list_sessions_for_user,
|
|
75
55
|
login,
|
|
76
|
-
|
|
56
|
+
remove_permission_from_org,
|
|
77
57
|
remove_permission_from_role,
|
|
78
|
-
rename_permission,
|
|
79
58
|
set_session_host,
|
|
80
59
|
update_credential_sign_count,
|
|
81
|
-
|
|
60
|
+
update_org_name,
|
|
82
61
|
update_permission,
|
|
83
62
|
update_role_name,
|
|
84
63
|
update_session,
|
|
@@ -87,6 +66,7 @@ from paskia.db.operations import (
|
|
|
87
66
|
update_user_role_in_organization,
|
|
88
67
|
)
|
|
89
68
|
from paskia.db.structs import (
|
|
69
|
+
DB,
|
|
90
70
|
Credential,
|
|
91
71
|
Org,
|
|
92
72
|
Permission,
|
|
@@ -97,6 +77,12 @@ from paskia.db.structs import (
|
|
|
97
77
|
User,
|
|
98
78
|
)
|
|
99
79
|
|
|
80
|
+
|
|
81
|
+
def data() -> DB:
|
|
82
|
+
"""Get the database instance for direct read access."""
|
|
83
|
+
return operations._db
|
|
84
|
+
|
|
85
|
+
|
|
100
86
|
__all__ = [
|
|
101
87
|
# Types
|
|
102
88
|
"Credential",
|
|
@@ -109,7 +95,7 @@ __all__ = [
|
|
|
109
95
|
"SessionContext",
|
|
110
96
|
"User",
|
|
111
97
|
# Instance
|
|
112
|
-
"
|
|
98
|
+
"data",
|
|
113
99
|
"init",
|
|
114
100
|
# Background
|
|
115
101
|
"start_background",
|
|
@@ -118,44 +104,31 @@ __all__ = [
|
|
|
118
104
|
"stop_cleanup",
|
|
119
105
|
# Builders
|
|
120
106
|
"build_credential",
|
|
121
|
-
"build_org",
|
|
122
107
|
"build_permission",
|
|
123
108
|
"build_reset_token",
|
|
124
109
|
"build_role",
|
|
125
110
|
"build_session",
|
|
126
111
|
"build_user",
|
|
127
112
|
# Read ops
|
|
128
|
-
"get_credential_by_id",
|
|
129
|
-
"get_credentials_by_user_uuid",
|
|
130
|
-
"get_organization",
|
|
131
113
|
"get_organization_users",
|
|
132
|
-
"get_permission",
|
|
133
|
-
"get_permission_by_scope",
|
|
134
|
-
"get_permission_organizations",
|
|
135
114
|
"get_reset_token",
|
|
136
|
-
"
|
|
137
|
-
"get_roles_by_organization",
|
|
138
|
-
"get_session",
|
|
139
|
-
"get_session_context",
|
|
140
|
-
"get_user_by_uuid",
|
|
115
|
+
"get_user_credential_ids",
|
|
141
116
|
"get_user_organization",
|
|
142
|
-
"list_organizations",
|
|
143
|
-
"list_permissions",
|
|
144
|
-
"list_sessions_for_user",
|
|
145
117
|
# Write ops
|
|
146
|
-
"
|
|
118
|
+
"add_permission_to_org",
|
|
147
119
|
"add_permission_to_role",
|
|
120
|
+
"bootstrap",
|
|
148
121
|
"cleanup_expired",
|
|
149
122
|
"create_credential",
|
|
150
123
|
"create_credential_session",
|
|
151
|
-
"
|
|
124
|
+
"create_org",
|
|
152
125
|
"create_permission",
|
|
153
126
|
"create_reset_token",
|
|
154
127
|
"create_role",
|
|
155
128
|
"create_session",
|
|
156
129
|
"create_user",
|
|
157
130
|
"delete_credential",
|
|
158
|
-
"
|
|
131
|
+
"delete_org",
|
|
159
132
|
"delete_permission",
|
|
160
133
|
"delete_reset_token",
|
|
161
134
|
"delete_role",
|
|
@@ -163,12 +136,11 @@ __all__ = [
|
|
|
163
136
|
"delete_sessions_for_user",
|
|
164
137
|
"delete_user",
|
|
165
138
|
"login",
|
|
166
|
-
"
|
|
139
|
+
"remove_permission_from_org",
|
|
167
140
|
"remove_permission_from_role",
|
|
168
|
-
"rename_permission",
|
|
169
141
|
"set_session_host",
|
|
170
142
|
"update_credential_sign_count",
|
|
171
|
-
"
|
|
143
|
+
"update_org_name",
|
|
172
144
|
"update_permission",
|
|
173
145
|
"update_role_name",
|
|
174
146
|
"update_session",
|
paskia/db/background.py
CHANGED
|
@@ -6,62 +6,34 @@ Periodically flushes pending changes to disk and cleans up expired items.
|
|
|
6
6
|
|
|
7
7
|
import asyncio
|
|
8
8
|
import logging
|
|
9
|
-
from datetime import
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
10
|
|
|
11
|
-
from paskia.db.
|
|
11
|
+
from paskia.db.operations import _store, cleanup_expired
|
|
12
12
|
|
|
13
|
-
# Flush
|
|
14
|
-
|
|
15
|
-
# Cleanup expired items every N seconds (cheap when nothing to remove)
|
|
16
|
-
CLEANUP_INTERVAL = 1
|
|
13
|
+
FLUSH_INTERVAL = 0.1 # Flush to disk
|
|
14
|
+
CLEANUP_INTERVAL = 1 # Expired item cleanup
|
|
17
15
|
|
|
18
16
|
|
|
19
17
|
_logger = logging.getLogger(__name__)
|
|
20
18
|
_background_task: asyncio.Task | None = None
|
|
21
19
|
|
|
22
20
|
|
|
23
|
-
def cleanup() -> None:
|
|
24
|
-
"""Remove expired sessions and reset tokens from the database."""
|
|
25
|
-
from paskia.db.operations import _db
|
|
26
|
-
|
|
27
|
-
if _db is None or _db._data is None:
|
|
28
|
-
return
|
|
29
|
-
|
|
30
|
-
with _db.transaction("expiry"):
|
|
31
|
-
current_time = datetime.now(timezone.utc)
|
|
32
|
-
|
|
33
|
-
# Clean expired sessions
|
|
34
|
-
to_delete_sessions = [
|
|
35
|
-
k for k, s in _db._data.sessions.items() if s.expiry < current_time
|
|
36
|
-
]
|
|
37
|
-
for k in to_delete_sessions:
|
|
38
|
-
del _db._data.sessions[k]
|
|
39
|
-
|
|
40
|
-
# Clean expired reset tokens
|
|
41
|
-
to_delete_tokens = [
|
|
42
|
-
k for k, t in _db._data.reset_tokens.items() if t.expiry < current_time
|
|
43
|
-
]
|
|
44
|
-
for k in to_delete_tokens:
|
|
45
|
-
del _db._data.reset_tokens[k]
|
|
46
|
-
|
|
47
|
-
|
|
48
21
|
async def flush() -> None:
|
|
49
22
|
"""Write all pending database changes to disk."""
|
|
50
|
-
from paskia.db.operations import _db
|
|
51
23
|
|
|
52
|
-
if
|
|
53
|
-
_logger.warning("flush() called but
|
|
24
|
+
if _store is None:
|
|
25
|
+
_logger.warning("flush() called but _store is None")
|
|
54
26
|
return
|
|
55
|
-
await
|
|
27
|
+
await _store.flush()
|
|
56
28
|
|
|
57
29
|
|
|
58
30
|
async def _background_loop():
|
|
59
31
|
"""Background task that periodically flushes changes and cleans up."""
|
|
60
32
|
# Run cleanup immediately on startup to clear old expired items
|
|
61
|
-
|
|
33
|
+
cleanup_expired()
|
|
62
34
|
await flush()
|
|
63
35
|
|
|
64
|
-
last_cleanup = datetime.now(
|
|
36
|
+
last_cleanup = datetime.now(UTC)
|
|
65
37
|
|
|
66
38
|
while True:
|
|
67
39
|
try:
|
|
@@ -69,10 +41,10 @@ async def _background_loop():
|
|
|
69
41
|
# Flush pending changes to disk
|
|
70
42
|
await flush()
|
|
71
43
|
|
|
72
|
-
# Run cleanup
|
|
73
|
-
now = datetime.now(
|
|
44
|
+
# Run cleanup periodically
|
|
45
|
+
now = datetime.now(UTC)
|
|
74
46
|
if (now - last_cleanup).total_seconds() >= CLEANUP_INTERVAL:
|
|
75
|
-
|
|
47
|
+
cleanup_expired()
|
|
76
48
|
await flush() # Flush cleanup changes
|
|
77
49
|
last_cleanup = now
|
|
78
50
|
except asyncio.CancelledError:
|
|
@@ -101,6 +73,14 @@ async def start_background():
|
|
|
101
73
|
if loop is not task_loop:
|
|
102
74
|
_logger.debug("Background task in different event loop, restarting")
|
|
103
75
|
_background_task = None
|
|
76
|
+
else:
|
|
77
|
+
# Task is running in the same event loop - this is an error
|
|
78
|
+
raise RuntimeError(
|
|
79
|
+
"Background task is already running. "
|
|
80
|
+
"start_background() must not be called multiple times in the same event loop."
|
|
81
|
+
)
|
|
82
|
+
except RuntimeError:
|
|
83
|
+
raise # Re-raise RuntimeError from above
|
|
104
84
|
except Exception as e:
|
|
105
85
|
_logger.debug("Error checking background task loop: %s, restarting", e)
|
|
106
86
|
_background_task = None
|