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.
Files changed (39) hide show
  1. paskia/_version.py +2 -2
  2. paskia/authsession.py +12 -49
  3. paskia/bootstrap.py +30 -25
  4. paskia/db/__init__.py +163 -401
  5. paskia/db/background.py +128 -0
  6. paskia/db/jsonl.py +132 -0
  7. paskia/db/operations.py +1241 -0
  8. paskia/db/structs.py +148 -0
  9. paskia/fastapi/admin.py +456 -215
  10. paskia/fastapi/api.py +16 -15
  11. paskia/fastapi/authz.py +7 -2
  12. paskia/fastapi/mainapp.py +2 -1
  13. paskia/fastapi/remote.py +20 -20
  14. paskia/fastapi/reset.py +9 -10
  15. paskia/fastapi/user.py +10 -18
  16. paskia/fastapi/ws.py +22 -19
  17. paskia/frontend-build/auth/admin/index.html +3 -3
  18. paskia/frontend-build/auth/assets/AccessDenied-aTdCvz9k.js +8 -0
  19. paskia/frontend-build/auth/assets/admin-BeNu48FR.css +1 -0
  20. paskia/frontend-build/auth/assets/admin-tVs8oyLv.js +1 -0
  21. paskia/frontend-build/auth/assets/{auth-BU_O38k2.css → auth-BKX7shEe.css} +1 -1
  22. paskia/frontend-build/auth/assets/auth-Dk3q4pNS.js +1 -0
  23. paskia/frontend-build/auth/index.html +3 -3
  24. paskia/globals.py +7 -10
  25. paskia/migrate/__init__.py +274 -0
  26. paskia/migrate/sql.py +381 -0
  27. paskia/util/permutil.py +16 -5
  28. paskia/util/sessionutil.py +3 -2
  29. paskia/util/userinfo.py +12 -26
  30. {paskia-0.7.2.dist-info → paskia-0.8.0.dist-info}/METADATA +21 -25
  31. {paskia-0.7.2.dist-info → paskia-0.8.0.dist-info}/RECORD +33 -29
  32. {paskia-0.7.2.dist-info → paskia-0.8.0.dist-info}/entry_points.txt +1 -0
  33. paskia/db/sql.py +0 -1424
  34. paskia/frontend-build/auth/assets/AccessDenied-C-lL9vbN.js +0 -8
  35. paskia/frontend-build/auth/assets/admin-Cs6Mg773.css +0 -1
  36. paskia/frontend-build/auth/assets/admin-Df5_Damp.js +0 -1
  37. paskia/frontend-build/auth/assets/auth-Df3pjeSS.js +0 -1
  38. paskia/util/tokens.py +0 -44
  39. {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.7.2'
32
- __version_tuple__ = version_tuple = (0, 7, 2)
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. Returns None if the token is not well formed (i.e. it is another type of token)."""
71
- record = await db.instance.get_reset_token(reset_key(token))
72
- if record and record.expiry >= datetime.now(timezone.utc):
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 = await db.instance.get_session(session_key(token))
83
- if session and session_expiry(session) >= datetime.now(timezone.utc):
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
- await db.instance.set_session_host(session.key, host)
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 = await db.instance.get_session(session_key(token))
59
+ session_record = db.get_session(token)
97
60
  if not session_record:
98
61
  raise ValueError("Session not found or expired")
99
- updated = await db.instance.update_session(
100
- session_key(token),
62
+ updated = db.update_session(
63
+ token,
101
64
  ip=ip,
102
65
  user_agent=user_agent,
103
- renewed=datetime.now(timezone.utc),
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
- await db.instance.delete_credential(credential_uuid, s.user_uuid)
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, globals
15
+ from paskia import authsession, db
16
16
  from paskia.db import Org, Permission, Role, User
17
- from paskia.util import hostutil, passphrase, tokens
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
- await globals.db.instance.create_reset_token(
45
+ db.create_reset_token(
46
46
  user_uuid=user_uuid,
47
- key=tokens.reset_key(token),
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(id="auth:admin", display_name="Master Admin")
65
- await globals.db.instance.create_permission(perm0)
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
- await globals.db.instance.create_organization(org)
76
+ db.create_organization(org)
69
77
 
70
- # After creation, org.permissions now includes the auto-created org admin permission
71
- # Allow this org to grant global admin explicitly
72
- await globals.db.instance.add_permission_to_organization(str(org.uuid), perm0.id)
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.id, *org.permissions],
87
+ permissions=[perm0.scope, perm_org_admin.scope],
81
88
  )
82
- await globals.db.instance.create_role(role)
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
- await globals.db.instance.create_user(user)
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
- *[Permission(id=p, display_name="") for p in org.permissions],
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 = await globals.db.instance.get_permission_organizations(
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 = await globals.db.instance.get_organization_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 = await globals.db.instance.get_credentials_by_user_uuid(
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
- await globals.db.instance.get_permission("auth:admin")
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()