paskia 0.8.1__tar.gz → 0.9.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {paskia-0.8.1 → paskia-0.9.1}/PKG-INFO +1 -1
- {paskia-0.8.1 → paskia-0.9.1}/paskia/_version.py +2 -2
- {paskia-0.8.1 → paskia-0.9.1}/paskia/aaguid/__init__.py +5 -4
- paskia-0.9.1/paskia/authsession.py +47 -0
- paskia-0.9.1/paskia/bootstrap.py +123 -0
- {paskia-0.8.1 → paskia-0.9.1}/paskia/db/__init__.py +27 -55
- {paskia-0.8.1 → paskia-0.9.1}/paskia/db/background.py +20 -40
- paskia-0.9.1/paskia/db/jsonl.py +282 -0
- paskia-0.9.1/paskia/db/logging.py +233 -0
- paskia-0.9.1/paskia/db/migrations.py +33 -0
- paskia-0.9.1/paskia/db/operations.py +825 -0
- paskia-0.9.1/paskia/db/structs.py +462 -0
- {paskia-0.8.1 → paskia-0.9.1}/paskia/fastapi/__main__.py +25 -28
- {paskia-0.8.1 → paskia-0.9.1}/paskia/fastapi/admin.py +147 -329
- {paskia-0.8.1 → paskia-0.9.1}/paskia/fastapi/api.py +68 -110
- paskia-0.9.1/paskia/fastapi/logging.py +218 -0
- {paskia-0.8.1 → paskia-0.9.1}/paskia/fastapi/mainapp.py +25 -8
- {paskia-0.8.1 → paskia-0.9.1}/paskia/fastapi/remote.py +16 -39
- {paskia-0.8.1 → paskia-0.9.1}/paskia/fastapi/reset.py +27 -19
- paskia-0.9.1/paskia/fastapi/response.py +22 -0
- {paskia-0.8.1 → paskia-0.9.1}/paskia/fastapi/session.py +2 -2
- {paskia-0.8.1 → paskia-0.9.1}/paskia/fastapi/user.py +24 -30
- {paskia-0.8.1 → paskia-0.9.1}/paskia/fastapi/ws.py +25 -60
- paskia-0.9.1/paskia/fastapi/wschat.py +62 -0
- {paskia-0.8.1 → paskia-0.9.1}/paskia/fastapi/wsutil.py +15 -2
- {paskia-0.8.1 → paskia-0.9.1}/paskia/frontend-build/auth/admin/index.html +5 -5
- paskia-0.8.1/paskia/frontend-build/auth/assets/AccessDenied-Bc249ASC.css → paskia-0.9.1/paskia/frontend-build/auth/assets/AccessDenied-DPkUS8LZ.css +1 -1
- paskia-0.9.1/paskia/frontend-build/auth/assets/AccessDenied-Fmeb6EtF.js +8 -0
- paskia-0.8.1/paskia/frontend-build/auth/assets/RestrictedAuth-DgdJyscT.css → paskia-0.9.1/paskia/frontend-build/auth/assets/RestrictedAuth-CvR33_Z0.css +1 -1
- paskia-0.9.1/paskia/frontend-build/auth/assets/RestrictedAuth-DsJXicIw.js +1 -0
- paskia-0.8.1/paskia/frontend-build/auth/assets/_plugin-vue_export-helper-rKFEraYH.js → paskia-0.9.1/paskia/frontend-build/auth/assets/_plugin-vue_export-helper-nhjnO_bd.js +1 -1
- paskia-0.9.1/paskia/frontend-build/auth/assets/admin-CPE1pLMm.js +1 -0
- paskia-0.8.1/paskia/frontend-build/auth/assets/admin-BeNu48FR.css → paskia-0.9.1/paskia/frontend-build/auth/assets/admin-DzzjSg72.css +1 -1
- paskia-0.8.1/paskia/frontend-build/auth/assets/auth-BKX7shEe.css → paskia-0.9.1/paskia/frontend-build/auth/assets/auth-C7k64Wad.css +1 -1
- paskia-0.9.1/paskia/frontend-build/auth/assets/auth-YIZvPlW_.js +1 -0
- paskia-0.8.1/paskia/frontend-build/auth/assets/forward-Dzg-aE1C.js → paskia-0.9.1/paskia/frontend-build/auth/assets/forward-DmqVHZ7e.js +1 -1
- paskia-0.9.1/paskia/frontend-build/auth/assets/reset-Chtv69AT.css +1 -0
- paskia-0.9.1/paskia/frontend-build/auth/assets/reset-s20PATTN.js +1 -0
- paskia-0.8.1/paskia/frontend-build/auth/assets/restricted-C0IQufuH.js → paskia-0.9.1/paskia/frontend-build/auth/assets/restricted-D3AJx3_6.js +1 -1
- {paskia-0.8.1 → paskia-0.9.1}/paskia/frontend-build/auth/index.html +5 -5
- {paskia-0.8.1 → paskia-0.9.1}/paskia/frontend-build/auth/restricted/index.html +4 -4
- {paskia-0.8.1 → paskia-0.9.1}/paskia/frontend-build/int/forward/index.html +4 -4
- {paskia-0.8.1 → paskia-0.9.1}/paskia/frontend-build/int/reset/index.html +3 -3
- {paskia-0.8.1 → paskia-0.9.1}/paskia/globals.py +2 -2
- {paskia-0.8.1 → paskia-0.9.1}/paskia/migrate/__init__.py +67 -60
- {paskia-0.8.1 → paskia-0.9.1}/paskia/migrate/sql.py +94 -37
- {paskia-0.8.1 → paskia-0.9.1}/paskia/remoteauth.py +7 -8
- {paskia-0.8.1 → paskia-0.9.1}/paskia/sansio.py +6 -12
- {paskia-0.8.1 → paskia-0.9.1}/pyproject.toml +1 -5
- paskia-0.8.1/paskia/authsession.py +0 -75
- paskia-0.8.1/paskia/bootstrap.py +0 -195
- paskia-0.8.1/paskia/db/jsonl.py +0 -132
- paskia-0.8.1/paskia/db/operations.py +0 -1241
- paskia-0.8.1/paskia/db/structs.py +0 -148
- paskia-0.8.1/paskia/frontend-build/auth/assets/AccessDenied-aTdCvz9k.js +0 -8
- paskia-0.8.1/paskia/frontend-build/auth/assets/RestrictedAuth-BLMK7-nL.js +0 -1
- paskia-0.8.1/paskia/frontend-build/auth/assets/admin-tVs8oyLv.js +0 -1
- paskia-0.8.1/paskia/frontend-build/auth/assets/auth-Dk3q4pNS.js +0 -1
- paskia-0.8.1/paskia/frontend-build/auth/assets/reset-BWF4cWKR.css +0 -1
- paskia-0.8.1/paskia/frontend-build/auth/assets/reset-C_Td1_jn.js +0 -1
- {paskia-0.8.1 → paskia-0.9.1}/.gitignore +0 -0
- {paskia-0.8.1 → paskia-0.9.1}/README.md +0 -0
- {paskia-0.8.1 → paskia-0.9.1}/paskia/__init__.py +0 -0
- {paskia-0.8.1 → paskia-0.9.1}/paskia/aaguid/combined_aaguid.json +0 -0
- {paskia-0.8.1 → paskia-0.9.1}/paskia/config.py +0 -0
- {paskia-0.8.1 → paskia-0.9.1}/paskia/fastapi/__init__.py +0 -0
- {paskia-0.8.1 → paskia-0.9.1}/paskia/fastapi/auth_host.py +0 -0
- {paskia-0.8.1 → paskia-0.9.1}/paskia/fastapi/authz.py +0 -0
- {paskia-0.8.1 → paskia-0.9.1}/paskia/frontend-build/auth/assets/_plugin-vue_export-helper-BTzJAQlS.css +0 -0
- {paskia-0.8.1 → paskia-0.9.1}/paskia/frontend-build/auth/assets/helpers-DzjFIx78.js +0 -0
- {paskia-0.8.1 → paskia-0.9.1}/paskia/frontend-build/auth/assets/pow-2N9bxgAo.js +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: paskia
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.1
|
|
4
4
|
Summary: Passkey Auth made easy: all sites and APIs can be guarded even without any changes on the protected site.
|
|
5
5
|
Project-URL: Homepage, https://git.zi.fi/LeoVasanko/paskia
|
|
6
6
|
Project-URL: Repository, https://github.com/LeoVasanko/paskia
|
|
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
|
|
|
28
28
|
commit_id: COMMIT_ID
|
|
29
29
|
__commit_id__: COMMIT_ID
|
|
30
30
|
|
|
31
|
-
__version__ = version = '0.
|
|
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
|
|
@@ -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}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core session management for WebAuthn authentication.
|
|
3
|
+
|
|
4
|
+
This module provides generic session management functionality that is
|
|
5
|
+
independent of any web framework:
|
|
6
|
+
- Session creation and validation
|
|
7
|
+
- Token handling and refresh
|
|
8
|
+
- Credential management
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
from uuid import UUID
|
|
14
|
+
|
|
15
|
+
from paskia import db
|
|
16
|
+
from paskia.config import RESET_LIFETIME, SESSION_LIFETIME
|
|
17
|
+
from paskia.util import hostutil
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from paskia.db import ResetToken
|
|
21
|
+
|
|
22
|
+
EXPIRES = SESSION_LIFETIME
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def expires() -> datetime:
|
|
26
|
+
return datetime.now(UTC) + EXPIRES
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def reset_expires() -> datetime:
|
|
30
|
+
return datetime.now(UTC) + RESET_LIFETIME
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_reset(token: str) -> "ResetToken":
|
|
34
|
+
"""Validate a credential reset token."""
|
|
35
|
+
|
|
36
|
+
record = db.get_reset_token(token)
|
|
37
|
+
if record:
|
|
38
|
+
return record
|
|
39
|
+
raise ValueError("This authentication link is no longer valid.")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def delete_credential(credential_uuid: UUID, auth: str, host: str | None = None):
|
|
43
|
+
"""Delete a specific credential for the current user."""
|
|
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)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Bootstrap module for passkey authentication system.
|
|
3
|
+
|
|
4
|
+
This module handles initial system setup when a new database is created,
|
|
5
|
+
including creating default admin user, organization, permissions, and
|
|
6
|
+
generating a reset link for initial admin setup.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
from paskia import authsession, db, globals
|
|
13
|
+
from paskia.util import hostutil, passphrase
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
# Shared log message template for admin reset links
|
|
18
|
+
ADMIN_RESET_MESSAGE = """\
|
|
19
|
+
%s
|
|
20
|
+
|
|
21
|
+
👤 Admin %s
|
|
22
|
+
- Use this link to register a Passkey for the admin user!
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
|
|
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)
|
|
29
|
+
logger.info(ADMIN_RESET_MESSAGE, message, reset_link)
|
|
30
|
+
return reset_link
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def bootstrap_system() -> None:
|
|
34
|
+
"""
|
|
35
|
+
Bootstrap the entire system with default data.
|
|
36
|
+
|
|
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.
|
|
39
|
+
"""
|
|
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)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def check_admin_credentials() -> bool:
|
|
48
|
+
"""
|
|
49
|
+
Check if the admin user needs credentials and create a reset link if needed.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
bool: True if a reset link was created, False if admin already has credentials
|
|
53
|
+
"""
|
|
54
|
+
try:
|
|
55
|
+
# Get permission organizations to find admin users
|
|
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:
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
# Get users from the first organization with admin permission
|
|
63
|
+
first_org_uuid = next(iter(p.orgs))
|
|
64
|
+
org_users = db.get_organization_users(first_org_uuid)
|
|
65
|
+
admin_users = [user for user, role in org_users if role == "Administration"]
|
|
66
|
+
|
|
67
|
+
if not admin_users:
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
# Check first admin user for credentials
|
|
71
|
+
admin_user = admin_users[0]
|
|
72
|
+
|
|
73
|
+
if not db.get_user_credential_ids(admin_user.uuid):
|
|
74
|
+
# Admin exists but has no credentials, create reset link
|
|
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",
|
|
83
|
+
)
|
|
84
|
+
_log_reset_link("⚠️ Admin user has no credentials!", token)
|
|
85
|
+
return True
|
|
86
|
+
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
except Exception:
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def bootstrap_if_needed() -> bool:
|
|
94
|
+
"""
|
|
95
|
+
Check if system needs bootstrapping and perform it if necessary.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
bool: True if bootstrapping was performed, False if system was already set up
|
|
99
|
+
"""
|
|
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()):
|
|
102
|
+
# Permission exists, system is already bootstrapped
|
|
103
|
+
# Check if admin needs credentials (only for already-bootstrapped systems)
|
|
104
|
+
await check_admin_credentials()
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
# No admin permission found, need to bootstrap
|
|
108
|
+
# Bootstrap creates the admin user AND the reset link, so no need to check credentials after
|
|
109
|
+
await bootstrap_system()
|
|
110
|
+
return True
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# CLI interface
|
|
114
|
+
async def main():
|
|
115
|
+
"""Main CLI entry point for bootstrapping."""
|
|
116
|
+
# Configure logging for CLI usage
|
|
117
|
+
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
|
|
118
|
+
|
|
119
|
+
await globals.init()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
if __name__ == "__main__":
|
|
123
|
+
asyncio.run(main())
|
|
@@ -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",
|
|
@@ -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
|