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/migrate/sql.py ADDED
@@ -0,0 +1,381 @@
1
+ """
2
+ Legacy SQL database implementation for migration purposes.
3
+
4
+ This module provides the async SQLAlchemy database layer that was used
5
+ before the JSONL format. It is kept here for migration purposes only.
6
+
7
+ DO NOT use this module for new code. Use paskia.db instead.
8
+ """
9
+
10
+ from contextlib import asynccontextmanager
11
+ from dataclasses import dataclass
12
+ from datetime import datetime, timezone
13
+ from uuid import UUID
14
+
15
+ from sqlalchemy import (
16
+ DateTime,
17
+ ForeignKey,
18
+ Integer,
19
+ LargeBinary,
20
+ String,
21
+ event,
22
+ select,
23
+ )
24
+ from sqlalchemy.dialects.sqlite import BLOB
25
+ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
26
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
27
+
28
+ from paskia.db import (
29
+ Credential,
30
+ Org,
31
+ ResetToken,
32
+ Role,
33
+ User,
34
+ )
35
+
36
+
37
+ # Local Permission class for SQL schema (uses 'id' not 'uuid' + 'scope')
38
+ @dataclass
39
+ class SqlPermission:
40
+ """Permission as stored in the old SQL schema with id field."""
41
+
42
+ id: str
43
+ display_name: str
44
+
45
+
46
+ DB_PATH_DEFAULT = "sqlite+aiosqlite:///paskia.sqlite"
47
+
48
+
49
+ # Local Session class for SQL schema (uses 'renewed' not 'expiry')
50
+ @dataclass
51
+ class _SqlSession:
52
+ """Session as stored in the old SQL schema with renewed timestamp."""
53
+
54
+ key: bytes
55
+ user_uuid: UUID
56
+ credential_uuid: UUID
57
+ host: str
58
+ ip: str
59
+ user_agent: str
60
+ renewed: datetime
61
+
62
+
63
+ def _normalize_dt(value: datetime | None) -> datetime | None:
64
+ if value is None:
65
+ return None
66
+ if value.tzinfo is None:
67
+ return value.replace(tzinfo=timezone.utc)
68
+ return value.astimezone(timezone.utc)
69
+
70
+
71
+ class Base(DeclarativeBase):
72
+ pass
73
+
74
+
75
+ class OrgModel(Base):
76
+ __tablename__ = "orgs"
77
+
78
+ uuid: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True)
79
+ display_name: Mapped[str] = mapped_column(String, nullable=False)
80
+
81
+ def as_dataclass(self):
82
+ # Base Org without permissions/roles (filled by data accessors)
83
+ return Org(UUID(bytes=self.uuid), self.display_name)
84
+
85
+ @staticmethod
86
+ def from_dataclass(org: Org):
87
+ return OrgModel(uuid=org.uuid.bytes, display_name=org.display_name)
88
+
89
+
90
+ class RoleModel(Base):
91
+ __tablename__ = "roles"
92
+
93
+ uuid: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True)
94
+ org_uuid: Mapped[bytes] = mapped_column(
95
+ LargeBinary(16), ForeignKey("orgs.uuid", ondelete="CASCADE"), nullable=False
96
+ )
97
+ display_name: Mapped[str] = mapped_column(String, nullable=False)
98
+
99
+ def as_dataclass(self):
100
+ # Base Role without permissions (filled by data accessors)
101
+ return Role(
102
+ uuid=UUID(bytes=self.uuid),
103
+ org_uuid=UUID(bytes=self.org_uuid),
104
+ display_name=self.display_name,
105
+ )
106
+
107
+ @staticmethod
108
+ def from_dataclass(role: Role):
109
+ return RoleModel(
110
+ uuid=role.uuid.bytes,
111
+ org_uuid=role.org_uuid.bytes,
112
+ display_name=role.display_name,
113
+ )
114
+
115
+
116
+ class UserModel(Base):
117
+ __tablename__ = "users"
118
+
119
+ uuid: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True)
120
+ display_name: Mapped[str] = mapped_column(String, nullable=False)
121
+ role_uuid: Mapped[bytes] = mapped_column(
122
+ LargeBinary(16), ForeignKey("roles.uuid", ondelete="CASCADE"), nullable=False
123
+ )
124
+ created_at: Mapped[datetime] = mapped_column(
125
+ DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
126
+ )
127
+ last_seen: Mapped[datetime | None] = mapped_column(
128
+ DateTime(timezone=True), nullable=True
129
+ )
130
+ visits: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
131
+
132
+ def as_dataclass(self) -> User:
133
+ return User(
134
+ uuid=UUID(bytes=self.uuid),
135
+ display_name=self.display_name,
136
+ role_uuid=UUID(bytes=self.role_uuid),
137
+ created_at=_normalize_dt(self.created_at) or self.created_at,
138
+ last_seen=_normalize_dt(self.last_seen) or self.last_seen,
139
+ visits=self.visits,
140
+ )
141
+
142
+ @staticmethod
143
+ def from_dataclass(user: User):
144
+ return UserModel(
145
+ uuid=user.uuid.bytes,
146
+ display_name=user.display_name,
147
+ role_uuid=user.role_uuid.bytes,
148
+ created_at=user.created_at or datetime.now(timezone.utc),
149
+ last_seen=user.last_seen,
150
+ visits=user.visits,
151
+ )
152
+
153
+
154
+ class CredentialModel(Base):
155
+ __tablename__ = "credentials"
156
+
157
+ uuid: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True)
158
+ credential_id: Mapped[bytes] = mapped_column(
159
+ LargeBinary(64), unique=True, index=True
160
+ )
161
+ user_uuid: Mapped[bytes] = mapped_column(
162
+ LargeBinary(16), ForeignKey("users.uuid", ondelete="CASCADE")
163
+ )
164
+ aaguid: Mapped[bytes] = mapped_column(LargeBinary(16), nullable=False)
165
+ public_key: Mapped[bytes] = mapped_column(BLOB, nullable=False)
166
+ sign_count: Mapped[int] = mapped_column(Integer, nullable=False)
167
+ created_at: Mapped[datetime] = mapped_column(
168
+ DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
169
+ )
170
+ last_used: Mapped[datetime | None] = mapped_column(
171
+ DateTime(timezone=True), nullable=True
172
+ )
173
+ last_verified: Mapped[datetime | None] = mapped_column(
174
+ DateTime(timezone=True), nullable=True
175
+ )
176
+
177
+ def as_dataclass(self):
178
+ return Credential(
179
+ uuid=UUID(bytes=self.uuid),
180
+ credential_id=self.credential_id,
181
+ user_uuid=UUID(bytes=self.user_uuid),
182
+ aaguid=UUID(bytes=self.aaguid),
183
+ public_key=self.public_key,
184
+ sign_count=self.sign_count,
185
+ created_at=_normalize_dt(self.created_at) or self.created_at,
186
+ last_used=_normalize_dt(self.last_used) or self.last_used,
187
+ last_verified=_normalize_dt(self.last_verified) or self.last_verified,
188
+ )
189
+
190
+
191
+ class SessionModel(Base):
192
+ __tablename__ = "sessions"
193
+
194
+ key: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True)
195
+ user_uuid: Mapped[bytes] = mapped_column(
196
+ LargeBinary(16), ForeignKey("users.uuid", ondelete="CASCADE"), nullable=False
197
+ )
198
+ credential_uuid: Mapped[bytes] = mapped_column(
199
+ LargeBinary(16),
200
+ ForeignKey("credentials.uuid", ondelete="CASCADE"),
201
+ nullable=False,
202
+ )
203
+ host: Mapped[str] = mapped_column(String, nullable=False)
204
+ ip: Mapped[str] = mapped_column(String(64), nullable=False)
205
+ user_agent: Mapped[str] = mapped_column(String(512), nullable=False)
206
+ renewed: Mapped[datetime] = mapped_column(
207
+ DateTime(timezone=True),
208
+ default=lambda: datetime.now(timezone.utc),
209
+ nullable=False,
210
+ )
211
+
212
+ def as_dataclass(self):
213
+ return _SqlSession(
214
+ key=self.key,
215
+ user_uuid=UUID(bytes=self.user_uuid),
216
+ credential_uuid=UUID(bytes=self.credential_uuid),
217
+ host=self.host,
218
+ ip=self.ip,
219
+ user_agent=self.user_agent,
220
+ renewed=_normalize_dt(self.renewed) or self.renewed,
221
+ )
222
+
223
+ @staticmethod
224
+ def from_dataclass(session: _SqlSession):
225
+ return SessionModel(
226
+ key=session.key,
227
+ user_uuid=session.user_uuid.bytes,
228
+ credential_uuid=session.credential_uuid.bytes,
229
+ host=session.host,
230
+ ip=session.ip,
231
+ user_agent=session.user_agent,
232
+ renewed=session.renewed,
233
+ )
234
+
235
+
236
+ class ResetTokenModel(Base):
237
+ __tablename__ = "reset_tokens"
238
+
239
+ key: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True)
240
+ user_uuid: Mapped[bytes] = mapped_column(
241
+ LargeBinary(16), ForeignKey("users.uuid", ondelete="CASCADE"), nullable=False
242
+ )
243
+ token_type: Mapped[str] = mapped_column(String, nullable=False)
244
+ expiry: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
245
+
246
+ def as_dataclass(self) -> ResetToken:
247
+ return ResetToken(
248
+ key=self.key,
249
+ user_uuid=UUID(bytes=self.user_uuid),
250
+ token_type=self.token_type,
251
+ expiry=_normalize_dt(self.expiry) or self.expiry,
252
+ )
253
+
254
+
255
+ class PermissionModel(Base):
256
+ __tablename__ = "permissions"
257
+
258
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
259
+ display_name: Mapped[str] = mapped_column(String, nullable=False)
260
+
261
+ def as_dataclass(self):
262
+ return SqlPermission(self.id, self.display_name)
263
+
264
+ @staticmethod
265
+ def from_dataclass(permission: SqlPermission):
266
+ return PermissionModel(
267
+ id=permission.id,
268
+ display_name=permission.display_name,
269
+ )
270
+
271
+
272
+ class OrgPermission(Base):
273
+ """Permissions each organization is allowed to grant to its roles."""
274
+
275
+ __tablename__ = "org_permissions"
276
+
277
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
278
+ org_uuid: Mapped[bytes] = mapped_column(
279
+ LargeBinary(16), ForeignKey("orgs.uuid", ondelete="CASCADE")
280
+ )
281
+ permission_id: Mapped[str] = mapped_column(
282
+ String(64), ForeignKey("permissions.id", ondelete="CASCADE")
283
+ )
284
+
285
+
286
+ class RolePermission(Base):
287
+ """Permissions that each role grants to its members."""
288
+
289
+ __tablename__ = "role_permissions"
290
+
291
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
292
+ role_uuid: Mapped[bytes] = mapped_column(
293
+ LargeBinary(16), ForeignKey("roles.uuid", ondelete="CASCADE")
294
+ )
295
+ permission_id: Mapped[str] = mapped_column(
296
+ String(64), ForeignKey("permissions.id", ondelete="CASCADE")
297
+ )
298
+
299
+
300
+ class DB:
301
+ """Legacy SQL database class for migration purposes only."""
302
+
303
+ def __init__(self, db_path: str = DB_PATH_DEFAULT):
304
+ """Initialize with database path."""
305
+ self.engine = create_async_engine(db_path, echo=False)
306
+ # Ensure SQLite foreign key enforcement is ON for every new connection
307
+ if db_path.startswith("sqlite"):
308
+
309
+ @event.listens_for(self.engine.sync_engine, "connect")
310
+ def _fk_on(dbapi_connection, connection_record):
311
+ try:
312
+ cursor = dbapi_connection.cursor()
313
+ cursor.execute("PRAGMA foreign_keys=ON;")
314
+ cursor.close()
315
+ except Exception:
316
+ pass
317
+
318
+ self.async_session_factory = async_sessionmaker(
319
+ self.engine, expire_on_commit=False
320
+ )
321
+
322
+ @asynccontextmanager
323
+ async def session(self):
324
+ """Async context manager that provides a database session with transaction."""
325
+ async with self.async_session_factory() as session:
326
+ async with session.begin():
327
+ yield session
328
+ await session.flush()
329
+ await session.commit()
330
+
331
+ async def init_db(self) -> None:
332
+ """Initialize database tables."""
333
+ async with self.engine.begin() as conn:
334
+ await conn.run_sync(Base.metadata.create_all)
335
+
336
+ async def list_permissions(self) -> list[SqlPermission]:
337
+ async with self.session() as session:
338
+ result = await session.execute(select(PermissionModel))
339
+ return [p.as_dataclass() for p in result.scalars().all()]
340
+
341
+ async def list_organizations(self) -> list[Org]:
342
+ async with self.session() as session:
343
+ # Load all orgs
344
+ orgs_result = await session.execute(select(OrgModel))
345
+ org_models = orgs_result.scalars().all()
346
+ if not org_models:
347
+ return []
348
+
349
+ # Preload org permissions mapping
350
+ org_perms_result = await session.execute(select(OrgPermission))
351
+ org_perms = org_perms_result.scalars().all()
352
+ perms_by_org: dict[bytes, list[str]] = {}
353
+ for op in org_perms:
354
+ perms_by_org.setdefault(op.org_uuid, []).append(op.permission_id)
355
+
356
+ # Preload roles
357
+ roles_result = await session.execute(select(RoleModel))
358
+ role_models = roles_result.scalars().all()
359
+
360
+ # Preload role permissions mapping
361
+ rp_result = await session.execute(select(RolePermission))
362
+ rps = rp_result.scalars().all()
363
+ perms_by_role: dict[bytes, list[str]] = {}
364
+ for rp in rps:
365
+ perms_by_role.setdefault(rp.role_uuid, []).append(rp.permission_id)
366
+
367
+ # Build org dataclasses with roles and permission IDs
368
+ roles_by_org: dict[bytes, list[Role]] = {}
369
+ for rm in role_models:
370
+ r_dc = rm.as_dataclass()
371
+ r_dc.permissions = perms_by_role.get(rm.uuid, [])
372
+ roles_by_org.setdefault(rm.org_uuid, []).append(r_dc)
373
+
374
+ orgs: list[Org] = []
375
+ for om in org_models:
376
+ o_dc = om.as_dataclass()
377
+ o_dc.permissions = perms_by_org.get(om.uuid, [])
378
+ o_dc.roles = roles_by_org.get(om.uuid, [])
379
+ orgs.append(o_dc)
380
+
381
+ return orgs
paskia/util/permutil.py CHANGED
@@ -3,9 +3,8 @@
3
3
  from collections.abc import Sequence
4
4
  from fnmatch import fnmatchcase
5
5
 
6
- from paskia.globals import db
6
+ from paskia import db
7
7
  from paskia.util.hostutil import normalize_host
8
- from paskia.util.tokens import session_key
9
8
 
10
9
  __all__ = ["has_any", "has_all", "session_context"]
11
10
 
@@ -17,16 +16,28 @@ def _match(perms: set[str], patterns: Sequence[str]):
17
16
  )
18
17
 
19
18
 
19
+ def _get_effective_scopes(ctx) -> set[str]:
20
+ """Get effective permission scopes from context.
21
+
22
+ Returns scopes from ctx.permissions (filtered by org) if available,
23
+ otherwise falls back to ctx.role.permissions for backwards compatibility.
24
+ """
25
+ if ctx.permissions:
26
+ return {p.scope for p in ctx.permissions}
27
+ # Fallback for contexts without effective permissions computed
28
+ return set(ctx.role.permissions or [])
29
+
30
+
20
31
  def has_any(ctx, patterns: Sequence[str]) -> bool:
21
- return any(_match(ctx.role.permissions, patterns)) if ctx else False
32
+ return any(_match(_get_effective_scopes(ctx), patterns)) if ctx else False
22
33
 
23
34
 
24
35
  def has_all(ctx, patterns: Sequence[str]) -> bool:
25
- return all(_match(ctx.role.permissions, patterns)) if ctx else False
36
+ return all(_match(_get_effective_scopes(ctx), patterns)) if ctx else False
26
37
 
27
38
 
28
39
  async def session_context(auth: str | None, host: str | None = None):
29
40
  if not auth:
30
41
  return None
31
42
  normalized_host = normalize_host(host) if host else None
32
- return await db.instance.get_session_context(session_key(auth), normalized_host)
43
+ return db.get_session_context(auth, normalized_host)
@@ -2,6 +2,7 @@
2
2
 
3
3
  from datetime import datetime, timezone
4
4
 
5
+ from paskia.authsession import EXPIRES
5
6
  from paskia.db import SessionContext
6
7
  from paskia.util.timeutil import parse_duration
7
8
 
@@ -27,11 +28,11 @@ def check_session_age(ctx: SessionContext, max_age: str | None) -> bool:
27
28
 
28
29
  max_age_delta = parse_duration(max_age)
29
30
 
30
- # Use credential's last_used time if available, fall back to session renewed
31
+ # Use credential's last_used time if available, fall back to session renewed time
31
32
  if ctx.credential and ctx.credential.last_used:
32
33
  auth_time = ctx.credential.last_used
33
34
  else:
34
- auth_time = ctx.session.renewed
35
+ auth_time = ctx.session.expiry - EXPIRES
35
36
 
36
37
  time_since_auth = datetime.now(timezone.utc) - auth_time
37
38
  return time_since_auth <= max_age_delta
paskia/util/userinfo.py CHANGED
@@ -2,10 +2,9 @@
2
2
 
3
3
  from datetime import timezone
4
4
 
5
- from paskia import aaguid
6
- from paskia.authsession import session_key
7
- from paskia.globals import db
8
- from paskia.util import hostutil, permutil, tokens, useragent
5
+ from paskia import aaguid, db
6
+ from paskia.authsession import EXPIRES
7
+ from paskia.util import hostutil, permutil, useragent
9
8
 
10
9
 
11
10
  def _format_datetime(dt):
@@ -41,20 +40,15 @@ async def format_user_info(
41
40
  - Sessions list
42
41
  - Permissions
43
42
  """
44
- u = await db.instance.get_user_by_uuid(user_uuid)
43
+ u = db.get_user_by_uuid(user_uuid)
45
44
  ctx = await permutil.session_context(auth, request_host)
46
45
 
47
46
  # Fetch and format credentials
48
- credential_ids = await db.instance.get_credentials_by_user_uuid(user_uuid)
47
+ user_credentials = db.get_credentials_by_user_uuid(user_uuid)
49
48
  credentials: list[dict] = []
50
49
  user_aaguids: set[str] = set()
51
50
 
52
- for cred_id in credential_ids:
53
- try:
54
- c = await db.instance.get_credential_by_id(cred_id)
55
- except ValueError:
56
- continue
57
-
51
+ for c in user_credentials:
58
52
  aaguid_str = str(c.aaguid)
59
53
  user_aaguids.add(aaguid_str)
60
54
  credentials.append(
@@ -76,8 +70,6 @@ async def format_user_info(
76
70
  role_info = None
77
71
  org_info = None
78
72
  effective_permissions: list[str] = []
79
- is_global_admin = False
80
- is_org_admin = False
81
73
 
82
74
  if ctx:
83
75
  role_info = {
@@ -90,27 +82,23 @@ async def format_user_info(
90
82
  "display_name": ctx.org.display_name,
91
83
  "permissions": ctx.org.permissions,
92
84
  }
93
- effective_permissions = [p.id for p in (ctx.permissions or [])]
94
- is_global_admin = "auth:admin" in (role_info["permissions"] or [])
95
- is_org_admin = any(
96
- p.startswith("auth:org:") for p in (role_info["permissions"] or [])
97
- )
85
+ effective_permissions = [p.scope for p in (ctx.permissions or [])]
98
86
 
99
87
  # Format sessions
100
88
  normalized_request_host = hostutil.normalize_host(request_host)
101
- session_records = await db.instance.list_sessions_for_user(user_uuid)
102
- current_session_key = session_key(auth)
89
+ session_records = db.list_sessions_for_user(user_uuid)
90
+ current_session_key = auth
103
91
  sessions_payload: list[dict] = []
104
92
 
105
93
  for entry in session_records:
106
94
  sessions_payload.append(
107
95
  {
108
- "id": tokens.encode_session_key(entry.key),
96
+ "id": entry.key,
109
97
  "credential_uuid": str(entry.credential_uuid),
110
98
  "host": entry.host,
111
99
  "ip": entry.ip,
112
100
  "user_agent": useragent.compact_user_agent(entry.user_agent),
113
- "last_renewed": _format_datetime(entry.renewed),
101
+ "last_renewed": _format_datetime(entry.expiry - EXPIRES),
114
102
  "is_current": entry.key == current_session_key,
115
103
  "is_current_host": bool(
116
104
  normalized_request_host
@@ -132,8 +120,6 @@ async def format_user_info(
132
120
  "org": org_info,
133
121
  "role": role_info,
134
122
  "permissions": effective_permissions,
135
- "is_global_admin": is_global_admin,
136
- "is_org_admin": is_org_admin,
137
123
  "credentials": credentials,
138
124
  "aaguid_info": aaguid_info,
139
125
  "sessions": sessions_payload,
@@ -150,7 +136,7 @@ async def format_reset_user_info(user_uuid, reset_token) -> dict:
150
136
  Returns:
151
137
  Dictionary with minimal user info for password reset flow
152
138
  """
153
- u = await db.instance.get_user_by_uuid(user_uuid)
139
+ u = db.get_user_by_uuid(user_uuid)
154
140
 
155
141
  return {
156
142
  "authenticated": False,
@@ -1,17 +1,18 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: paskia
3
- Version: 0.7.2
3
+ Version: 0.8.0
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
7
7
  Author: Leo Vasanko
8
8
  Keywords: FastAPI,auth_request,forward_auth
9
9
  Requires-Python: >=3.10
10
- Requires-Dist: aiosqlite>=0.19.0
10
+ Requires-Dist: aiofiles>=25.1.0
11
11
  Requires-Dist: base64url>=1.0.0
12
12
  Requires-Dist: fastapi[standard]>=0.104.1
13
+ Requires-Dist: jsondiff>=2.2.1
14
+ Requires-Dist: msgspec>=0.20.0
13
15
  Requires-Dist: pyjwt>=2.8.0
14
- Requires-Dist: sqlalchemy[asyncio]>=2.0.0
15
16
  Requires-Dist: user-agents>=2.2.0
16
17
  Requires-Dist: uuid7-standard>=1.0.0
17
18
  Requires-Dist: webauthn>=1.11.1
@@ -22,6 +23,9 @@ Requires-Dist: httpx>=0.27.0; extra == 'dev'
22
23
  Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
23
24
  Requires-Dist: pytest>=8.0.0; extra == 'dev'
24
25
  Requires-Dist: ruff>=0.1.0; extra == 'dev'
26
+ Provides-Extra: migrate
27
+ Requires-Dist: aiosqlite>=0.19.0; extra == 'migrate'
28
+ Requires-Dist: sqlalchemy[asyncio]>=2.0.0; extra == 'migrate'
25
29
  Description-Content-Type: text/markdown
26
30
 
27
31
  # Paskia
@@ -56,16 +60,9 @@ Install [UV](https://docs.astral.sh/uv/getting-started/installation/) and run:
56
60
  uvx paskia serve --rp-id example.com
57
61
  ```
58
62
 
59
- On the first run it downloads the software and prints a registration link for the Admin. If you are going to be connecting `localhost` directly, for testing, leave out the rp-id.
63
+ On the first run it downloads the software and prints a registration link for the Admin. The server will start up on [localhost:4401](http://localhost:4401) *for authentication required*, serving for `*.example.com`. If you are going to be connecting `localhost` directly, for testing, leave out the rp-id.
60
64
 
61
- The server will start up on [localhost:4401](http://localhost:4401) "for authentication required", serving for `*.example.com`.
62
-
63
- Otherwise you will need a web server such as [Caddy](https://caddyserver.com/) to serve HTTPS on your actual domain names and proxy requests to Paskia and your backend apps.
64
-
65
- A quick example without any config file:
66
- ```fish
67
- sudo caddy reverse-proxy --from example.com --to :4401
68
- ```
65
+ Otherwise you will need a web server such as [Caddy](https://caddyserver.com/) to serve HTTPS on your actual domain names and proxy requests to Paskia and your backend apps (see documentation below).
69
66
 
70
67
  For a permanent install of `paskia` CLI command, not needing `uvx`:
71
68
 
@@ -81,18 +78,17 @@ There is no config file. Pass only the options on CLI:
81
78
  paskia serve [options]
82
79
  ```
83
80
 
84
- Optional options:
85
-
86
- - Listen address (one of):
87
- * `[host]:port`: Address and port (default: `localhost:4401`)
88
- * `unix:/path.sock`: Unix socket
89
- - `--rp-id <domain>`: Main domain (required for production)
90
- - `--rp-name "<text>"`: Name of your company or site (default: same as rp-id)
91
- - `--origin <url>`: Explicit single site (default: `https://<rp-id>`)
92
- - `--auth-host <domain>`: Dedicated authentication site (e.g., `auth.example.com`)
81
+ | Option | Description | Default |
82
+ |--------|-------------|---------|
83
+ | Listen address | One of *host***:***port* (default all hosts, port 4401) or **unix:***path***/paskia.socket** (Unix socket) | **localhost:4401** |
84
+ | --rp-id *domain* | Main/top domain | **localhost** |
85
+ | --rp-name *"text"* | Name of your company or site | Same as rp-id |
86
+ | --origin *url* | Explicitly list the domain names served | **https://**_rp-id_ |
87
+ | --auth-host *domain* | Dedicated authentication site (e.g., **auth.example.com**) | **Unspecified:** we use **/auth/** on **every** site under rp-id.|
93
88
 
94
- ## Documentation
89
+ ## Further Documentation
95
90
 
96
- - `API.md`: Complete HTTP and WebSocket API reference
97
- - `Caddy.md`: Caddy configuration examples
98
- - `Headers.md`: HTTP headers passed to protected applications
91
+ - [Caddy configuration](https://git.zi.fi/LeoVasanko/paskia/src/branch/main/docs/Caddy.md)
92
+ - [Trusted Headers for Backend Apps](https://git.zi.fi/LeoVasanko/paskia/src/branch/main/docs/Headers.md)
93
+ - [Frontend integration](https://git.zi.fi/LeoVasanko/paskia/src/branch/main/docs/Integration.md)
94
+ - [Paskia API](https://git.zi.fi/LeoVasanko/paskia/src/branch/main/docs/API.md)