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/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
|
|
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
|
|
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
|
|
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
|
|
43
|
+
return db.get_session_context(auth, normalized_host)
|
paskia/util/sessionutil.py
CHANGED
|
@@ -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.
|
|
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
|
|
7
|
-
from paskia.
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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.
|
|
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 =
|
|
102
|
-
current_session_key =
|
|
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":
|
|
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.
|
|
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 =
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
-
|
|
97
|
-
-
|
|
98
|
-
-
|
|
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)
|