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/_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.0'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 9, 0)
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|
paskia/authsession.py
CHANGED
|
@@ -9,13 +9,16 @@ independent of any web framework:
|
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
11
|
from datetime import datetime, timezone
|
|
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
|
|
|
@@ -24,39 +27,21 @@ def expires() -> datetime:
|
|
|
24
27
|
|
|
25
28
|
|
|
26
29
|
def reset_expires() -> datetime:
|
|
27
|
-
from .config import RESET_LIFETIME
|
|
28
|
-
|
|
29
30
|
return datetime.now(timezone.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):
|
|
42
|
+
def refresh_session_token(token: str, *, ip: str, user_agent: str):
|
|
58
43
|
"""Refresh a session extending its expiry."""
|
|
59
|
-
session_record = db.
|
|
44
|
+
session_record = db.data().sessions.get(token)
|
|
60
45
|
if not session_record:
|
|
61
46
|
raise ValueError("Session not found or expired")
|
|
62
47
|
updated = db.update_session(
|
|
@@ -69,7 +54,9 @@ async def refresh_session_token(token: str, *, ip: str, user_agent: str):
|
|
|
69
54
|
raise ValueError("Session not found or expired")
|
|
70
55
|
|
|
71
56
|
|
|
72
|
-
|
|
57
|
+
def delete_credential(credential_uuid: UUID, auth: str, host: str | None = None):
|
|
73
58
|
"""Delete a specific credential for the current user."""
|
|
74
|
-
|
|
75
|
-
|
|
59
|
+
ctx = db.get_session_context(auth, hostutil.normalize_host(host))
|
|
60
|
+
if not ctx:
|
|
61
|
+
raise ValueError("Session expired")
|
|
62
|
+
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,7 +1,7 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Database module for WebAuthn passkey authentication.
|
|
3
3
|
|
|
4
|
-
Read: Access
|
|
4
|
+
Read: Access data() directly, use build_* to convert to public structs.
|
|
5
5
|
CTX: get_session_context(key) returns SessionContext with effective permissions.
|
|
6
6
|
Write: Functions validate and commit, or raise ValueError.
|
|
7
7
|
|
|
@@ -9,7 +9,7 @@ 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
|
|
@@ -19,6 +19,7 @@ Usage:
|
|
|
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,38 @@ 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
|
-
get_role,
|
|
66
|
-
get_roles_by_organization,
|
|
67
|
-
get_session,
|
|
68
52
|
get_session_context,
|
|
69
|
-
|
|
53
|
+
get_user_credential_ids,
|
|
70
54
|
get_user_organization,
|
|
71
55
|
init,
|
|
72
|
-
list_organizations,
|
|
73
|
-
list_permissions,
|
|
74
|
-
list_sessions_for_user,
|
|
75
56
|
login,
|
|
76
|
-
|
|
57
|
+
remove_permission_from_org,
|
|
77
58
|
remove_permission_from_role,
|
|
78
|
-
rename_permission,
|
|
79
59
|
set_session_host,
|
|
80
60
|
update_credential_sign_count,
|
|
81
|
-
|
|
61
|
+
update_org_name,
|
|
82
62
|
update_permission,
|
|
83
63
|
update_role_name,
|
|
84
64
|
update_session,
|
|
@@ -87,6 +67,7 @@ from paskia.db.operations import (
|
|
|
87
67
|
update_user_role_in_organization,
|
|
88
68
|
)
|
|
89
69
|
from paskia.db.structs import (
|
|
70
|
+
DB,
|
|
90
71
|
Credential,
|
|
91
72
|
Org,
|
|
92
73
|
Permission,
|
|
@@ -97,6 +78,12 @@ from paskia.db.structs import (
|
|
|
97
78
|
User,
|
|
98
79
|
)
|
|
99
80
|
|
|
81
|
+
|
|
82
|
+
def data() -> DB:
|
|
83
|
+
"""Get the database instance for direct read access."""
|
|
84
|
+
return operations._db
|
|
85
|
+
|
|
86
|
+
|
|
100
87
|
__all__ = [
|
|
101
88
|
# Types
|
|
102
89
|
"Credential",
|
|
@@ -109,7 +96,7 @@ __all__ = [
|
|
|
109
96
|
"SessionContext",
|
|
110
97
|
"User",
|
|
111
98
|
# Instance
|
|
112
|
-
"
|
|
99
|
+
"data",
|
|
113
100
|
"init",
|
|
114
101
|
# Background
|
|
115
102
|
"start_background",
|
|
@@ -118,44 +105,32 @@ __all__ = [
|
|
|
118
105
|
"stop_cleanup",
|
|
119
106
|
# Builders
|
|
120
107
|
"build_credential",
|
|
121
|
-
"build_org",
|
|
122
108
|
"build_permission",
|
|
123
109
|
"build_reset_token",
|
|
124
110
|
"build_role",
|
|
125
111
|
"build_session",
|
|
126
112
|
"build_user",
|
|
127
113
|
# Read ops
|
|
128
|
-
"get_credential_by_id",
|
|
129
|
-
"get_credentials_by_user_uuid",
|
|
130
|
-
"get_organization",
|
|
131
114
|
"get_organization_users",
|
|
132
|
-
"get_permission",
|
|
133
|
-
"get_permission_by_scope",
|
|
134
|
-
"get_permission_organizations",
|
|
135
115
|
"get_reset_token",
|
|
136
|
-
"get_role",
|
|
137
|
-
"get_roles_by_organization",
|
|
138
|
-
"get_session",
|
|
139
116
|
"get_session_context",
|
|
140
|
-
"
|
|
117
|
+
"get_user_credential_ids",
|
|
141
118
|
"get_user_organization",
|
|
142
|
-
"list_organizations",
|
|
143
|
-
"list_permissions",
|
|
144
|
-
"list_sessions_for_user",
|
|
145
119
|
# Write ops
|
|
146
|
-
"
|
|
120
|
+
"add_permission_to_org",
|
|
147
121
|
"add_permission_to_role",
|
|
122
|
+
"bootstrap",
|
|
148
123
|
"cleanup_expired",
|
|
149
124
|
"create_credential",
|
|
150
125
|
"create_credential_session",
|
|
151
|
-
"
|
|
126
|
+
"create_org",
|
|
152
127
|
"create_permission",
|
|
153
128
|
"create_reset_token",
|
|
154
129
|
"create_role",
|
|
155
130
|
"create_session",
|
|
156
131
|
"create_user",
|
|
157
132
|
"delete_credential",
|
|
158
|
-
"
|
|
133
|
+
"delete_org",
|
|
159
134
|
"delete_permission",
|
|
160
135
|
"delete_reset_token",
|
|
161
136
|
"delete_role",
|
|
@@ -163,12 +138,11 @@ __all__ = [
|
|
|
163
138
|
"delete_sessions_for_user",
|
|
164
139
|
"delete_user",
|
|
165
140
|
"login",
|
|
166
|
-
"
|
|
141
|
+
"remove_permission_from_org",
|
|
167
142
|
"remove_permission_from_role",
|
|
168
|
-
"rename_permission",
|
|
169
143
|
"set_session_host",
|
|
170
144
|
"update_credential_sign_count",
|
|
171
|
-
"
|
|
145
|
+
"update_org_name",
|
|
172
146
|
"update_permission",
|
|
173
147
|
"update_role_name",
|
|
174
148
|
"update_session",
|
paskia/db/background.py
CHANGED
|
@@ -8,57 +8,29 @@ import asyncio
|
|
|
8
8
|
import logging
|
|
9
9
|
from datetime import datetime, timezone
|
|
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
36
|
last_cleanup = datetime.now(timezone.utc)
|
|
@@ -69,10 +41,10 @@ async def _background_loop():
|
|
|
69
41
|
# Flush pending changes to disk
|
|
70
42
|
await flush()
|
|
71
43
|
|
|
72
|
-
# Run cleanup
|
|
44
|
+
# Run cleanup periodically
|
|
73
45
|
now = datetime.now(timezone.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
|