paskia 0.7.2__py3-none-any.whl → 0.8.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 +12 -49
- paskia/bootstrap.py +30 -25
- paskia/db/__init__.py +163 -401
- paskia/db/background.py +128 -0
- paskia/db/jsonl.py +132 -0
- paskia/db/operations.py +1241 -0
- paskia/db/structs.py +148 -0
- paskia/fastapi/admin.py +456 -215
- paskia/fastapi/api.py +16 -15
- paskia/fastapi/authz.py +7 -2
- paskia/fastapi/mainapp.py +2 -1
- paskia/fastapi/remote.py +20 -20
- paskia/fastapi/reset.py +9 -10
- paskia/fastapi/user.py +10 -18
- paskia/fastapi/ws.py +22 -19
- paskia/frontend-build/auth/admin/index.html +3 -3
- paskia/frontend-build/auth/assets/AccessDenied-aTdCvz9k.js +8 -0
- paskia/frontend-build/auth/assets/admin-BeNu48FR.css +1 -0
- paskia/frontend-build/auth/assets/admin-tVs8oyLv.js +1 -0
- paskia/frontend-build/auth/assets/{auth-BU_O38k2.css → auth-BKX7shEe.css} +1 -1
- paskia/frontend-build/auth/assets/auth-Dk3q4pNS.js +1 -0
- paskia/frontend-build/auth/index.html +3 -3
- paskia/globals.py +7 -10
- paskia/migrate/__init__.py +274 -0
- paskia/migrate/sql.py +381 -0
- paskia/util/permutil.py +16 -5
- paskia/util/sessionutil.py +3 -2
- paskia/util/userinfo.py +12 -26
- {paskia-0.7.2.dist-info → paskia-0.8.0.dist-info}/METADATA +21 -25
- {paskia-0.7.2.dist-info → paskia-0.8.0.dist-info}/RECORD +33 -29
- {paskia-0.7.2.dist-info → paskia-0.8.0.dist-info}/entry_points.txt +1 -0
- paskia/db/sql.py +0 -1424
- paskia/frontend-build/auth/assets/AccessDenied-C-lL9vbN.js +0 -8
- paskia/frontend-build/auth/assets/admin-Cs6Mg773.css +0 -1
- paskia/frontend-build/auth/assets/admin-Df5_Damp.js +0 -1
- paskia/frontend-build/auth/assets/auth-Df3pjeSS.js +0 -1
- paskia/util/tokens.py +0 -44
- {paskia-0.7.2.dist-info → paskia-0.8.0.dist-info}/WHEEL +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.8.0'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 8, 0)
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|
paskia/authsession.py
CHANGED
|
@@ -11,11 +11,10 @@ independent of any web framework:
|
|
|
11
11
|
from datetime import datetime, timezone
|
|
12
12
|
from uuid import UUID
|
|
13
13
|
|
|
14
|
+
from paskia import db
|
|
14
15
|
from paskia.config import SESSION_LIFETIME
|
|
15
16
|
from paskia.db import ResetToken, Session
|
|
16
|
-
from paskia.globals import db, passkey
|
|
17
17
|
from paskia.util import hostutil
|
|
18
|
-
from paskia.util.tokens import create_token, reset_key, session_key
|
|
19
18
|
|
|
20
19
|
EXPIRES = SESSION_LIFETIME
|
|
21
20
|
|
|
@@ -30,46 +29,10 @@ def reset_expires() -> datetime:
|
|
|
30
29
|
return datetime.now(timezone.utc) + RESET_LIFETIME
|
|
31
30
|
|
|
32
31
|
|
|
33
|
-
def session_expiry(session: Session) -> datetime:
|
|
34
|
-
"""Calculate the expiration timestamp for a session (UTC aware)."""
|
|
35
|
-
# After migration all renewed timestamps are timezone-aware UTC
|
|
36
|
-
return session.renewed + EXPIRES
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
async def create_session(
|
|
40
|
-
user_uuid: UUID,
|
|
41
|
-
credential_uuid: UUID,
|
|
42
|
-
*,
|
|
43
|
-
host: str,
|
|
44
|
-
ip: str,
|
|
45
|
-
user_agent: str,
|
|
46
|
-
) -> str:
|
|
47
|
-
"""Create a new session and return a session token."""
|
|
48
|
-
normalized_host = hostutil.normalize_host(host)
|
|
49
|
-
if not normalized_host:
|
|
50
|
-
raise ValueError("Host required for session creation")
|
|
51
|
-
hostname = normalized_host.split(":")[0] # Domain names only, IPs aren't supported
|
|
52
|
-
rp_id = passkey.instance.rp_id
|
|
53
|
-
if not (hostname == rp_id or hostname.endswith(f".{rp_id}")):
|
|
54
|
-
raise ValueError(f"Host must be the same as or a subdomain of {rp_id}")
|
|
55
|
-
token = create_token()
|
|
56
|
-
now = datetime.now(timezone.utc)
|
|
57
|
-
await db.instance.create_session(
|
|
58
|
-
user_uuid=user_uuid,
|
|
59
|
-
credential_uuid=credential_uuid,
|
|
60
|
-
key=session_key(token),
|
|
61
|
-
host=normalized_host,
|
|
62
|
-
ip=ip,
|
|
63
|
-
user_agent=user_agent,
|
|
64
|
-
renewed=now,
|
|
65
|
-
)
|
|
66
|
-
return token
|
|
67
|
-
|
|
68
|
-
|
|
69
32
|
async def get_reset(token: str) -> ResetToken:
|
|
70
|
-
"""Validate a credential reset token.
|
|
71
|
-
record =
|
|
72
|
-
if record
|
|
33
|
+
"""Validate a credential reset token."""
|
|
34
|
+
record = db.get_reset_token(token)
|
|
35
|
+
if record:
|
|
73
36
|
return record
|
|
74
37
|
raise ValueError("This authentication link is no longer valid.")
|
|
75
38
|
|
|
@@ -79,11 +42,11 @@ async def get_session(token: str, host: str | None = None) -> Session:
|
|
|
79
42
|
host = hostutil.normalize_host(host)
|
|
80
43
|
if not host:
|
|
81
44
|
raise ValueError("Invalid host")
|
|
82
|
-
session =
|
|
83
|
-
if session
|
|
45
|
+
session = db.get_session(token)
|
|
46
|
+
if session:
|
|
84
47
|
if session.host is None:
|
|
85
48
|
# First time binding: store exact host:port (or IPv6 form) now.
|
|
86
|
-
|
|
49
|
+
db.set_session_host(session.key, host)
|
|
87
50
|
session.host = host
|
|
88
51
|
elif session.host != host:
|
|
89
52
|
raise ValueError("Session host mismatch")
|
|
@@ -93,14 +56,14 @@ async def get_session(token: str, host: str | None = None) -> Session:
|
|
|
93
56
|
|
|
94
57
|
async def refresh_session_token(token: str, *, ip: str, user_agent: str):
|
|
95
58
|
"""Refresh a session extending its expiry."""
|
|
96
|
-
session_record =
|
|
59
|
+
session_record = db.get_session(token)
|
|
97
60
|
if not session_record:
|
|
98
61
|
raise ValueError("Session not found or expired")
|
|
99
|
-
updated =
|
|
100
|
-
|
|
62
|
+
updated = db.update_session(
|
|
63
|
+
token,
|
|
101
64
|
ip=ip,
|
|
102
65
|
user_agent=user_agent,
|
|
103
|
-
|
|
66
|
+
expiry=expires(),
|
|
104
67
|
)
|
|
105
68
|
if not updated:
|
|
106
69
|
raise ValueError("Session not found or expired")
|
|
@@ -109,4 +72,4 @@ async def refresh_session_token(token: str, *, ip: str, user_agent: str):
|
|
|
109
72
|
async def delete_credential(credential_uuid: UUID, auth: str, host: str | None = None):
|
|
110
73
|
"""Delete a specific credential for the current user."""
|
|
111
74
|
s = await get_session(auth, host=host)
|
|
112
|
-
|
|
75
|
+
db.delete_credential(credential_uuid, s.user_uuid)
|
paskia/bootstrap.py
CHANGED
|
@@ -12,9 +12,9 @@ from datetime import datetime, timezone
|
|
|
12
12
|
|
|
13
13
|
import uuid7
|
|
14
14
|
|
|
15
|
-
from paskia import authsession,
|
|
15
|
+
from paskia import authsession, db
|
|
16
16
|
from paskia.db import Org, Permission, Role, User
|
|
17
|
-
from paskia.util import hostutil, passphrase
|
|
17
|
+
from paskia.util import hostutil, passphrase
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
def _init_logger() -> logging.Logger:
|
|
@@ -42,9 +42,9 @@ async def _create_and_log_admin_reset_link(user_uuid, message, session_type) ->
|
|
|
42
42
|
"""Create an admin reset link and log it with the provided message."""
|
|
43
43
|
token = passphrase.generate()
|
|
44
44
|
expiry = authsession.reset_expires()
|
|
45
|
-
|
|
45
|
+
db.create_reset_token(
|
|
46
46
|
user_uuid=user_uuid,
|
|
47
|
-
|
|
47
|
+
passphrase=token,
|
|
48
48
|
expiry=expiry,
|
|
49
49
|
token_type=session_type,
|
|
50
50
|
)
|
|
@@ -61,25 +61,32 @@ async def bootstrap_system() -> dict:
|
|
|
61
61
|
dict: Contains information about created entities and reset link
|
|
62
62
|
"""
|
|
63
63
|
# Create permission first - will fail if already exists
|
|
64
|
-
perm0 = Permission(
|
|
65
|
-
|
|
64
|
+
perm0 = Permission(
|
|
65
|
+
uuid=uuid7.create(), scope="auth:admin", display_name="Master Admin"
|
|
66
|
+
)
|
|
67
|
+
db.create_permission(perm0)
|
|
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)
|
|
66
74
|
|
|
67
75
|
org = Org(uuid7.create(), "Organization")
|
|
68
|
-
|
|
76
|
+
db.create_organization(org)
|
|
69
77
|
|
|
70
|
-
#
|
|
71
|
-
|
|
72
|
-
|
|
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)
|
|
73
81
|
|
|
74
82
|
# Create an Administration role granting both org and global admin
|
|
75
|
-
# Compose permissions for Administration role: global admin + org admin auto-perm
|
|
76
83
|
role = Role(
|
|
77
84
|
uuid7.create(),
|
|
78
85
|
org.uuid,
|
|
79
86
|
"Administration",
|
|
80
|
-
permissions=[perm0.
|
|
87
|
+
permissions=[perm0.scope, perm_org_admin.scope],
|
|
81
88
|
)
|
|
82
|
-
|
|
89
|
+
db.create_role(role)
|
|
83
90
|
|
|
84
91
|
user = User(
|
|
85
92
|
uuid=uuid7.create(),
|
|
@@ -88,7 +95,7 @@ async def bootstrap_system() -> dict:
|
|
|
88
95
|
created_at=datetime.now(timezone.utc),
|
|
89
96
|
visits=0,
|
|
90
97
|
)
|
|
91
|
-
|
|
98
|
+
db.create_user(user)
|
|
92
99
|
|
|
93
100
|
# Generate reset link and log it
|
|
94
101
|
reset_link = await _create_and_log_admin_reset_link(
|
|
@@ -101,7 +108,11 @@ async def bootstrap_system() -> dict:
|
|
|
101
108
|
"role": role,
|
|
102
109
|
"permissions": [
|
|
103
110
|
perm0,
|
|
104
|
-
*[
|
|
111
|
+
*[
|
|
112
|
+
db.get_permission_by_scope(p)
|
|
113
|
+
for p in org.permissions
|
|
114
|
+
if db.get_permission_by_scope(p)
|
|
115
|
+
],
|
|
105
116
|
],
|
|
106
117
|
"reset_link": reset_link,
|
|
107
118
|
}
|
|
@@ -116,17 +127,13 @@ async def check_admin_credentials() -> bool:
|
|
|
116
127
|
"""
|
|
117
128
|
try:
|
|
118
129
|
# Get permission organizations to find admin users
|
|
119
|
-
permission_orgs =
|
|
120
|
-
"auth:admin"
|
|
121
|
-
)
|
|
130
|
+
permission_orgs = db.get_permission_organizations("auth:admin")
|
|
122
131
|
|
|
123
132
|
if not permission_orgs:
|
|
124
133
|
return False
|
|
125
134
|
|
|
126
135
|
# Get users from the first organization with admin permission
|
|
127
|
-
org_users =
|
|
128
|
-
str(permission_orgs[0].uuid)
|
|
129
|
-
)
|
|
136
|
+
org_users = db.get_organization_users(str(permission_orgs[0].uuid))
|
|
130
137
|
admin_users = [user for user, role in org_users if role == "Administration"]
|
|
131
138
|
|
|
132
139
|
if not admin_users:
|
|
@@ -134,9 +141,7 @@ async def check_admin_credentials() -> bool:
|
|
|
134
141
|
|
|
135
142
|
# Check first admin user for credentials
|
|
136
143
|
admin_user = admin_users[0]
|
|
137
|
-
credentials =
|
|
138
|
-
admin_user.uuid
|
|
139
|
-
)
|
|
144
|
+
credentials = db.get_credentials_by_user_uuid(admin_user.uuid)
|
|
140
145
|
|
|
141
146
|
if not credentials:
|
|
142
147
|
# Admin exists but has no credentials, create reset link
|
|
@@ -162,7 +167,7 @@ async def bootstrap_if_needed() -> bool:
|
|
|
162
167
|
"""
|
|
163
168
|
try:
|
|
164
169
|
# Check if the admin permission exists - if it does, system is already bootstrapped
|
|
165
|
-
|
|
170
|
+
db.get_permission("auth:admin")
|
|
166
171
|
# Permission exists, system is already bootstrapped
|
|
167
172
|
# Check if admin needs credentials (only for already-bootstrapped systems)
|
|
168
173
|
await check_admin_credentials()
|