Implement full ORM. Various other cleanup.
This commit is contained in:
@@ -8,7 +8,7 @@ independent of any web framework:
|
||||
- Credential management
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID
|
||||
|
||||
@@ -23,11 +23,11 @@ EXPIRES = SESSION_LIFETIME
|
||||
|
||||
|
||||
def expires() -> datetime:
|
||||
return datetime.now(timezone.utc) + EXPIRES
|
||||
return datetime.now(UTC) + EXPIRES
|
||||
|
||||
|
||||
def reset_expires() -> datetime:
|
||||
return datetime.now(timezone.utc) + RESET_LIFETIME
|
||||
return datetime.now(UTC) + RESET_LIFETIME
|
||||
|
||||
|
||||
def get_reset(token: str) -> "ResetToken":
|
||||
|
||||
@@ -6,7 +6,7 @@ Periodically flushes pending changes to disk and cleans up expired items.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from paskia.db.operations import _store, cleanup_expired
|
||||
|
||||
@@ -33,7 +33,7 @@ async def _background_loop():
|
||||
cleanup_expired()
|
||||
await flush()
|
||||
|
||||
last_cleanup = datetime.now(timezone.utc)
|
||||
last_cleanup = datetime.now(UTC)
|
||||
|
||||
while True:
|
||||
try:
|
||||
@@ -42,7 +42,7 @@ async def _background_loop():
|
||||
await flush()
|
||||
|
||||
# Run cleanup periodically
|
||||
now = datetime.now(timezone.utc)
|
||||
now = datetime.now(UTC)
|
||||
if (now - last_cleanup).total_seconds() >= CLEANUP_INTERVAL:
|
||||
cleanup_expired()
|
||||
await flush() # Flush cleanup changes
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@ import logging
|
||||
import sys
|
||||
from collections import deque
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
@@ -97,7 +97,7 @@ def create_change_record(
|
||||
) -> _ChangeRecord:
|
||||
"""Create a change record for persistence."""
|
||||
return _ChangeRecord(
|
||||
ts=datetime.now(timezone.utc),
|
||||
ts=datetime.now(UTC),
|
||||
a=action,
|
||||
u=user,
|
||||
diff=diff,
|
||||
|
||||
+92
-98
@@ -10,7 +10,7 @@ import hashlib
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID
|
||||
|
||||
import uuid7
|
||||
@@ -68,21 +68,18 @@ def get_user_organization(user_uuid: UUID) -> tuple[Org, str]:
|
||||
Raises ValueError if user not found.
|
||||
|
||||
Call sites:
|
||||
- Get user's organization when updating user role (admin.py:493)
|
||||
- Get user's organization for user credential listing (admin.py:530)
|
||||
- Get user's organization for user details API (admin.py:579)
|
||||
- Get user's organization for updating user display name (admin.py:721)
|
||||
- Get user's organization for deleting user credential (admin.py:754)
|
||||
- Get user's organization for deleting user session (admin.py:783)
|
||||
- update_user_role_in_organization: org only
|
||||
- admin_create_user_registration_link: org only
|
||||
- admin_get_user_detail: org and role
|
||||
- admin_update_user_display_name: org only
|
||||
- admin_delete_user_credential: org only
|
||||
- admin_delete_user_session: org only
|
||||
"""
|
||||
if user_uuid not in _db.users:
|
||||
raise ValueError(f"User {user_uuid} not found")
|
||||
role_uuid = _db.users[user_uuid].role
|
||||
if role_uuid not in _db.roles:
|
||||
raise ValueError(f"Role {role_uuid} not found")
|
||||
role_data = _db.roles[role_uuid]
|
||||
org_uuid = role_data.org
|
||||
return _db.orgs[org_uuid], role_data.display_name
|
||||
user = _db.users[user_uuid]
|
||||
role = user.role
|
||||
return role.org, role.display_name
|
||||
|
||||
|
||||
def get_organization_users(org_uuid: UUID) -> list[tuple[User, str]]:
|
||||
@@ -90,10 +87,8 @@ def get_organization_users(org_uuid: UUID) -> list[tuple[User, str]]:
|
||||
|
||||
Returns list of (User, role_display_name) tuples.
|
||||
"""
|
||||
role_map = {
|
||||
rid: r.display_name for rid, r in _db.roles.items() if r.org == org_uuid
|
||||
}
|
||||
return [(u, role_map[u.role]) for u in _db.users.values() if u.role in role_map]
|
||||
org = _db.orgs[org_uuid]
|
||||
return [(u, u.role.display_name) for role in org.roles for u in role.users]
|
||||
|
||||
|
||||
def get_user_credential_ids(user_uuid: UUID) -> list[bytes]:
|
||||
@@ -101,7 +96,8 @@ def get_user_credential_ids(user_uuid: UUID) -> list[bytes]:
|
||||
|
||||
Returns empty list if user has no credentials.
|
||||
"""
|
||||
return [c.credential_id for c in _db.credentials.values() if c.user == user_uuid]
|
||||
assert user_uuid
|
||||
return [c.credential_id for c in _db.users[user_uuid].credentials]
|
||||
|
||||
|
||||
def _reset_key(passphrase: str) -> bytes:
|
||||
@@ -177,9 +173,9 @@ def create_org(org: Org, *, ctx: SessionContext | None = None) -> None:
|
||||
if org.uuid in _db.orgs:
|
||||
raise ValueError(f"Organization {org.uuid} already exists")
|
||||
with _db.transaction("admin:create_org", ctx):
|
||||
new_org = Org(display_name=org.display_name)
|
||||
_db.orgs[org.uuid] = new_org
|
||||
new_org = Org.create(display_name=org.display_name)
|
||||
new_org.uuid = org.uuid
|
||||
_db.orgs[org.uuid] = new_org
|
||||
# Create Administration role with org admin permission
|
||||
|
||||
admin_role_uuid = uuid7.create()
|
||||
@@ -191,7 +187,7 @@ def create_org(org: Org, *, ctx: SessionContext | None = None) -> None:
|
||||
break
|
||||
role_permissions = {org_admin_perm_uuid: True} if org_admin_perm_uuid else {}
|
||||
admin_role = Role(
|
||||
org=org.uuid,
|
||||
org_uuid=org.uuid,
|
||||
display_name="Administration",
|
||||
permissions=role_permissions,
|
||||
)
|
||||
@@ -217,17 +213,15 @@ def delete_org(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
|
||||
if uuid not in _db.orgs:
|
||||
raise ValueError(f"Organization {uuid} not found")
|
||||
with _db.transaction("admin:delete_org", ctx):
|
||||
org = _db.orgs[uuid]
|
||||
# Remove org from all permissions
|
||||
for p in _db.permissions.values():
|
||||
p.orgs.pop(uuid, None)
|
||||
# Delete roles in this org
|
||||
role_uuids = [rid for rid, r in _db.roles.items() if r.org == uuid]
|
||||
for rid in role_uuids:
|
||||
del _db.roles[rid]
|
||||
# Delete users with those roles
|
||||
user_uuids = [uid for uid, u in _db.users.items() if u.role in role_uuids]
|
||||
for uid in user_uuids:
|
||||
del _db.users[uid]
|
||||
# Delete roles in this org and their users
|
||||
for role in org.roles:
|
||||
for user in role.users:
|
||||
del _db.users[user.uuid]
|
||||
del _db.roles[role.uuid]
|
||||
del _db.orgs[uuid]
|
||||
|
||||
|
||||
@@ -269,8 +263,8 @@ def create_role(role: Role, *, ctx: SessionContext | None = None) -> None:
|
||||
"""Create a new role."""
|
||||
if role.uuid in _db.roles:
|
||||
raise ValueError(f"Role {role.uuid} already exists")
|
||||
if role.org not in _db.orgs:
|
||||
raise ValueError(f"Organization {role.org} not found")
|
||||
if role.org_uuid not in _db.orgs:
|
||||
raise ValueError(f"Organization {role.org_uuid} not found")
|
||||
with _db.transaction("admin:create_role", ctx):
|
||||
_db.roles[role.uuid] = role
|
||||
|
||||
@@ -321,7 +315,8 @@ def delete_role(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
|
||||
if uuid not in _db.roles:
|
||||
raise ValueError(f"Role {uuid} not found")
|
||||
# Check no users have this role
|
||||
if any(u.role == uuid for u in _db.users.values()):
|
||||
role = _db.roles[uuid]
|
||||
if role.users:
|
||||
raise ValueError(f"Cannot delete role {uuid}: users still assigned")
|
||||
with _db.transaction("admin:delete_role", ctx):
|
||||
del _db.roles[uuid]
|
||||
@@ -331,8 +326,8 @@ def create_user(new_user: User, *, ctx: SessionContext | None = None) -> None:
|
||||
"""Create a new user."""
|
||||
if new_user.uuid in _db.users:
|
||||
raise ValueError(f"User {new_user.uuid} already exists")
|
||||
if new_user.role not in _db.roles:
|
||||
raise ValueError(f"Role {new_user.role} not found")
|
||||
if new_user.role_uuid not in _db.roles:
|
||||
raise ValueError(f"Role {new_user.role_uuid} not found")
|
||||
with _db.transaction("admin:create_user", ctx):
|
||||
_db.users[new_user.uuid] = new_user
|
||||
|
||||
@@ -369,7 +364,7 @@ def update_user_role(
|
||||
if role_uuid not in _db.roles:
|
||||
raise ValueError(f"Role {role_uuid} not found")
|
||||
with _db.transaction("admin:update_user_role", ctx):
|
||||
_db.users[uuid].role = role_uuid
|
||||
_db.users[uuid].role_uuid = role_uuid
|
||||
|
||||
|
||||
def update_user_role_in_organization(
|
||||
@@ -381,39 +376,35 @@ def update_user_role_in_organization(
|
||||
"""Update user's role by role name within their current organization."""
|
||||
if user_uuid not in _db.users:
|
||||
raise ValueError(f"User {user_uuid} not found")
|
||||
current_role_uuid = _db.users[user_uuid].role
|
||||
if current_role_uuid not in _db.roles:
|
||||
raise ValueError("Current role not found")
|
||||
org_uuid = _db.roles[current_role_uuid].org
|
||||
user = _db.users[user_uuid]
|
||||
org = user.org
|
||||
# Find role by name in the same org
|
||||
new_role_uuid = None
|
||||
for rid, r in _db.roles.items():
|
||||
if r.org == org_uuid and r.display_name == role_name:
|
||||
new_role_uuid = rid
|
||||
for r in org.roles:
|
||||
if r.display_name == role_name:
|
||||
new_role_uuid = r.uuid
|
||||
break
|
||||
if new_role_uuid is None:
|
||||
raise ValueError(f"Role '{role_name}' not found in organization")
|
||||
with _db.transaction("admin:update_user_role", ctx):
|
||||
_db.users[user_uuid].role = new_role_uuid
|
||||
_db.users[user_uuid].role_uuid = new_role_uuid
|
||||
|
||||
|
||||
def delete_user(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
|
||||
"""Delete user and their credentials/sessions."""
|
||||
if uuid not in _db.users:
|
||||
raise ValueError(f"User {uuid} not found")
|
||||
user = _db.users[uuid]
|
||||
with _db.transaction("admin:delete_user", ctx):
|
||||
# Delete credentials
|
||||
cred_uuids = [cid for cid, c in _db.credentials.items() if c.user == uuid]
|
||||
for cid in cred_uuids:
|
||||
del _db.credentials[cid]
|
||||
for cred in user.credentials:
|
||||
del _db.credentials[cred.uuid]
|
||||
# Delete sessions
|
||||
sess_keys = [k for k, s in _db.sessions.items() if s.user == uuid]
|
||||
for k in sess_keys:
|
||||
del _db.sessions[k]
|
||||
for sess in user.sessions:
|
||||
del _db.sessions[sess.key]
|
||||
# Delete reset tokens
|
||||
token_keys = [k for k, t in _db.reset_tokens.items() if t.user == uuid]
|
||||
for k in token_keys:
|
||||
del _db.reset_tokens[k]
|
||||
for token in user.reset_tokens:
|
||||
del _db.reset_tokens[token.key]
|
||||
del _db.users[uuid]
|
||||
|
||||
|
||||
@@ -421,8 +412,8 @@ def create_credential(cred: Credential, *, ctx: SessionContext | None = None) ->
|
||||
"""Create a new credential."""
|
||||
if cred.uuid in _db.credentials:
|
||||
raise ValueError(f"Credential {cred.uuid} already exists")
|
||||
if cred.user not in _db.users:
|
||||
raise ValueError(f"User {cred.user} not found")
|
||||
if cred.user_uuid not in _db.users:
|
||||
raise ValueError(f"User {cred.user_uuid} not found")
|
||||
with _db.transaction("create_credential", ctx):
|
||||
_db.credentials[cred.uuid] = cred
|
||||
|
||||
@@ -455,20 +446,19 @@ def delete_credential(
|
||||
"""
|
||||
if uuid not in _db.credentials:
|
||||
raise ValueError(f"Credential {uuid} not found")
|
||||
cred = _db.credentials[uuid]
|
||||
if user_uuid is not None:
|
||||
cred_user = _db.credentials[uuid].user
|
||||
if cred_user != user_uuid:
|
||||
if cred.user_uuid != user_uuid:
|
||||
raise ValueError(f"Credential {uuid} does not belong to user {user_uuid}")
|
||||
with _db.transaction("delete_credential", ctx):
|
||||
# Delete all sessions using this credential
|
||||
keys = [k for k, s in _db.sessions.items() if s.credential == uuid]
|
||||
for k in keys:
|
||||
del _db.sessions[k]
|
||||
for sess in cred.sessions:
|
||||
print(sess, repr(sess.key))
|
||||
del _db.sessions[sess.key]
|
||||
del _db.credentials[uuid]
|
||||
|
||||
|
||||
def create_session(
|
||||
key: str,
|
||||
user_uuid: UUID,
|
||||
credential_uuid: UUID,
|
||||
host: str,
|
||||
@@ -477,16 +467,13 @@ def create_session(
|
||||
expiry: datetime,
|
||||
*,
|
||||
ctx: SessionContext | None = None,
|
||||
) -> None:
|
||||
"""Create a new session."""
|
||||
if key in _db.sessions:
|
||||
raise ValueError("Session already exists")
|
||||
) -> str:
|
||||
"""Create a new session. Returns the session key."""
|
||||
if user_uuid not in _db.users:
|
||||
raise ValueError(f"User {user_uuid} not found")
|
||||
if credential_uuid not in _db.credentials:
|
||||
raise ValueError(f"Credential {credential_uuid} not found")
|
||||
with _db.transaction("create_session", ctx):
|
||||
_db.sessions[key] = Session(
|
||||
session = Session.create(
|
||||
user=user_uuid,
|
||||
credential=credential_uuid,
|
||||
host=host,
|
||||
@@ -494,6 +481,11 @@ def create_session(
|
||||
user_agent=user_agent,
|
||||
expiry=expiry,
|
||||
)
|
||||
if session.key in _db.sessions:
|
||||
raise ValueError("Session already exists")
|
||||
with _db.transaction("create_session", ctx):
|
||||
_db.sessions[session.key] = session
|
||||
return session.key
|
||||
|
||||
|
||||
def update_session(
|
||||
@@ -547,10 +539,12 @@ def delete_sessions_for_user(
|
||||
For user logout-all, pass ctx of the user's session.
|
||||
For admin bulk termination, pass admin's ctx.
|
||||
"""
|
||||
user = _db.users.get(user_uuid)
|
||||
if not user:
|
||||
return
|
||||
with _db.transaction("admin:delete_sessions_for_user", ctx):
|
||||
keys = [k for k, s in _db.sessions.items() if s.user == user_uuid]
|
||||
for k in keys:
|
||||
del _db.sessions[k]
|
||||
for sess in user.sessions:
|
||||
del _db.sessions[sess.key]
|
||||
|
||||
|
||||
def create_reset_token(
|
||||
@@ -575,7 +569,7 @@ def create_reset_token(
|
||||
raise ValueError(f"User {user_uuid} not found")
|
||||
with _db.transaction("create_reset_token", ctx):
|
||||
_db.reset_tokens[key] = ResetToken(
|
||||
user=user_uuid, expiry=expiry, token_type=token_type
|
||||
user_uuid=user_uuid, expiry=expiry, token_type=token_type
|
||||
)
|
||||
|
||||
|
||||
@@ -594,7 +588,7 @@ def delete_reset_token(key: bytes, *, ctx: SessionContext | None = None) -> None
|
||||
|
||||
def cleanup_expired() -> int:
|
||||
"""Remove expired sessions and reset tokens. Returns count removed."""
|
||||
now = datetime.now(timezone.utc)
|
||||
now = datetime.now(UTC)
|
||||
count = 0
|
||||
with _db.transaction("expiry"):
|
||||
expired_sessions = [k for k, s in _db.sessions.items() if s.expiry < now]
|
||||
@@ -639,13 +633,20 @@ def login(
|
||||
"""
|
||||
if isinstance(user_uuid, str):
|
||||
user_uuid = UUID(user_uuid)
|
||||
now = datetime.now(timezone.utc)
|
||||
now = datetime.now(UTC)
|
||||
if user_uuid not in _db.users:
|
||||
raise ValueError(f"User {user_uuid} not found")
|
||||
if credential_uuid not in _db.credentials:
|
||||
raise ValueError(f"Credential {credential_uuid} not found")
|
||||
|
||||
session_key = _create_token()
|
||||
session = Session.create(
|
||||
user=user_uuid,
|
||||
credential=credential_uuid,
|
||||
host=host,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
expiry=expiry,
|
||||
)
|
||||
user_str = str(user_uuid)
|
||||
with _db.transaction("login", user=user_str):
|
||||
# Update user
|
||||
@@ -655,15 +656,8 @@ def login(
|
||||
_db.credentials[credential_uuid].sign_count = sign_count
|
||||
_db.credentials[credential_uuid].last_used = now
|
||||
# Create session
|
||||
_db.sessions[session_key] = Session(
|
||||
user=user_uuid,
|
||||
credential=credential_uuid,
|
||||
host=host,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
expiry=expiry,
|
||||
)
|
||||
return session_key
|
||||
_db.sessions[session.key] = session
|
||||
return session.key
|
||||
|
||||
|
||||
def create_credential_session(
|
||||
@@ -686,13 +680,20 @@ def create_credential_session(
|
||||
Returns the generated session token.
|
||||
"""
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
now = datetime.now(UTC)
|
||||
expiry = now + SESSION_LIFETIME
|
||||
session_key = _create_token()
|
||||
|
||||
if user_uuid not in _db.users:
|
||||
raise ValueError(f"User {user_uuid} not found")
|
||||
|
||||
session = Session.create(
|
||||
user=user_uuid,
|
||||
credential=credential.uuid,
|
||||
host=host,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
expiry=expiry,
|
||||
)
|
||||
user_str = str(user_uuid)
|
||||
with _db.transaction("create_credential_session", user=user_str):
|
||||
# Update display name if provided
|
||||
@@ -703,20 +704,13 @@ def create_credential_session(
|
||||
_db.credentials[credential.uuid] = credential
|
||||
|
||||
# Create session
|
||||
_db.sessions[session_key] = Session(
|
||||
user=user_uuid,
|
||||
credential=credential.uuid,
|
||||
host=host,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
expiry=expiry,
|
||||
)
|
||||
_db.sessions[session.key] = session
|
||||
|
||||
# Delete reset token if provided
|
||||
if reset_key:
|
||||
if reset_key in _db.reset_tokens:
|
||||
del _db.reset_tokens[reset_key]
|
||||
return session_key
|
||||
return session.key
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -775,7 +769,7 @@ def bootstrap(
|
||||
reset_expiry = reset_expires()
|
||||
reset_key = _reset_key(reset_passphrase)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
now = datetime.now(UTC)
|
||||
|
||||
with _db.transaction("bootstrap"):
|
||||
# Create auth:admin permission
|
||||
@@ -797,13 +791,13 @@ def bootstrap(
|
||||
_db.permissions[perm_org_admin_uuid] = perm_org_admin
|
||||
|
||||
# Create organization
|
||||
new_org = Org(display_name=org_name)
|
||||
new_org = Org.create(display_name=org_name)
|
||||
new_org.uuid = org_uuid
|
||||
_db.orgs[org_uuid] = new_org
|
||||
|
||||
# Create Administration role with both permissions
|
||||
admin_role = Role(
|
||||
org=org_uuid,
|
||||
org_uuid=org_uuid,
|
||||
display_name="Administration",
|
||||
permissions={perm_admin_uuid: True, perm_org_admin_uuid: True},
|
||||
)
|
||||
@@ -813,7 +807,7 @@ def bootstrap(
|
||||
# Create admin user
|
||||
admin_user = User(
|
||||
display_name=admin_name,
|
||||
role=role_uuid,
|
||||
role_uuid=role_uuid,
|
||||
created_at=now,
|
||||
last_seen=None,
|
||||
visits=0,
|
||||
@@ -823,7 +817,7 @@ def bootstrap(
|
||||
|
||||
# Create reset token
|
||||
_db.reset_tokens[reset_key] = ResetToken(
|
||||
user=user_uuid,
|
||||
user_uuid=user_uuid,
|
||||
expiry=reset_expiry,
|
||||
token_type="admin bootstrap",
|
||||
)
|
||||
|
||||
+166
-46
@@ -1,9 +1,13 @@
|
||||
from datetime import datetime, timezone
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID
|
||||
|
||||
import msgspec
|
||||
import uuid7
|
||||
|
||||
from paskia import db
|
||||
from paskia.util.hostutil import normalize_host
|
||||
|
||||
# Sentinel for uuid fields before they are set by create() or DB post init
|
||||
@@ -31,13 +35,22 @@ class Permission(msgspec.Struct, dict=True, omit_defaults=True):
|
||||
"""Get orgs that can grant this permission as a set."""
|
||||
return set(self.orgs.keys())
|
||||
|
||||
@property
|
||||
def orgs_list(self) -> list[Org]:
|
||||
"""Get list of Org objects that can grant this permission."""
|
||||
return [
|
||||
db.data().orgs[org_uuid]
|
||||
for org_uuid in self.orgs.keys()
|
||||
if org_uuid in db.data().orgs
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
scope: str,
|
||||
display_name: str,
|
||||
domain: str | None = None,
|
||||
) -> "Permission":
|
||||
) -> Permission:
|
||||
"""Create a new Permission with auto-generated uuid7."""
|
||||
perm = cls(
|
||||
scope=scope,
|
||||
@@ -48,15 +61,41 @@ class Permission(msgspec.Struct, dict=True, omit_defaults=True):
|
||||
return perm
|
||||
|
||||
|
||||
class Org(msgspec.Struct, dict=True):
|
||||
"""Organization data structure."""
|
||||
|
||||
display_name: str
|
||||
|
||||
def __post_init__(self):
|
||||
self.uuid: UUID = _UUID_UNSET # Convenience field, not serialized
|
||||
|
||||
@property
|
||||
def roles(self) -> list[Role]:
|
||||
"""Get all roles that belong to this organization."""
|
||||
return [r for r in db.data().roles.values() if r.org_uuid == self.uuid]
|
||||
|
||||
@property
|
||||
def permissions(self) -> list[Permission]:
|
||||
"""Get all permissions that this organization can grant."""
|
||||
return [p for p in db.data().permissions.values() if self.uuid in p.orgs]
|
||||
|
||||
@classmethod
|
||||
def create(cls, display_name: str) -> Org:
|
||||
"""Create a new Org with auto-generated uuid7."""
|
||||
org = cls(display_name=display_name)
|
||||
org.uuid = uuid7.create()
|
||||
return org
|
||||
|
||||
|
||||
class Role(msgspec.Struct, dict=True, omit_defaults=True):
|
||||
"""Role data structure.
|
||||
|
||||
Mutable fields: display_name, permissions
|
||||
Immutable fields: org (set at creation, never modified)
|
||||
Immutable fields: org_uuid (set at creation, never modified)
|
||||
uuid is generated at creation.
|
||||
"""
|
||||
|
||||
org: UUID
|
||||
org_uuid: UUID = msgspec.field(name="org")
|
||||
display_name: str
|
||||
permissions: dict[UUID, bool] = {} # permission_uuid -> True
|
||||
|
||||
@@ -68,16 +107,36 @@ class Role(msgspec.Struct, dict=True, omit_defaults=True):
|
||||
"""Get permissions as a set of UUIDs."""
|
||||
return set(self.permissions.keys())
|
||||
|
||||
@property
|
||||
def permissions_list(self) -> list[Permission]:
|
||||
"""Get list of Permission objects for this role."""
|
||||
return [
|
||||
db.data().permissions[perm_uuid]
|
||||
for perm_uuid in self.permissions.keys()
|
||||
if perm_uuid in db.data().permissions
|
||||
]
|
||||
|
||||
@property
|
||||
def org(self) -> Org:
|
||||
"""Get the organization object this role belongs to."""
|
||||
return db.data().orgs[self.org_uuid]
|
||||
|
||||
@property
|
||||
def users(self) -> list[User]:
|
||||
"""Get all users that have this role."""
|
||||
return [u for u in db.data().users.values() if u.role_uuid == self.uuid]
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
org: UUID,
|
||||
org: UUID | Org,
|
||||
display_name: str,
|
||||
permissions: set[UUID] | None = None,
|
||||
) -> "Role":
|
||||
) -> Role:
|
||||
"""Create a new Role with auto-generated uuid7."""
|
||||
org_uuid = org if isinstance(org, UUID) else org.uuid
|
||||
role = cls(
|
||||
org=org,
|
||||
org_uuid=org_uuid,
|
||||
display_name=display_name,
|
||||
permissions={p: True for p in (permissions or set())},
|
||||
)
|
||||
@@ -85,32 +144,16 @@ class Role(msgspec.Struct, dict=True, omit_defaults=True):
|
||||
return role
|
||||
|
||||
|
||||
class Org(msgspec.Struct, dict=True):
|
||||
"""Organization data structure."""
|
||||
|
||||
display_name: str
|
||||
|
||||
def __post_init__(self):
|
||||
self.uuid: UUID = _UUID_UNSET # Convenience field, not serialized
|
||||
|
||||
@classmethod
|
||||
def create(cls, display_name: str) -> "Org":
|
||||
"""Create a new Org with auto-generated uuid7."""
|
||||
org = cls(display_name=display_name)
|
||||
org.uuid = uuid7.create()
|
||||
return org
|
||||
|
||||
|
||||
class User(msgspec.Struct, dict=True):
|
||||
"""User data structure.
|
||||
|
||||
Mutable fields: display_name, role, last_seen, visits
|
||||
Mutable fields: display_name, role_uuid, last_seen, visits
|
||||
Immutable fields: created_at (set at creation, never modified)
|
||||
uuid is derived from created_at using uuid7.
|
||||
"""
|
||||
|
||||
display_name: str
|
||||
role: UUID
|
||||
role_uuid: UUID = msgspec.field(name="role")
|
||||
created_at: datetime
|
||||
last_seen: datetime | None = None
|
||||
visits: int = 0
|
||||
@@ -118,19 +161,44 @@ class User(msgspec.Struct, dict=True):
|
||||
def __post_init__(self):
|
||||
self.uuid: UUID = _UUID_UNSET # Convenience field, not serialized
|
||||
|
||||
@property
|
||||
def role(self) -> Role:
|
||||
"""Get the role object this user has."""
|
||||
return db.data().roles[self.role_uuid]
|
||||
|
||||
@property
|
||||
def org(self) -> Org:
|
||||
"""Get the organization this user belongs to (via role)."""
|
||||
return self.role.org
|
||||
|
||||
@property
|
||||
def credentials(self) -> list[Credential]:
|
||||
"""Get all credentials for this user."""
|
||||
return [c for c in db.data().credentials.values() if c.user_uuid == self.uuid]
|
||||
|
||||
@property
|
||||
def sessions(self) -> list[Session]:
|
||||
"""Get all sessions for this user."""
|
||||
return [s for s in db.data().sessions.values() if s.user_uuid == self.uuid]
|
||||
|
||||
@property
|
||||
def reset_tokens(self) -> list[ResetToken]:
|
||||
"""Get all reset tokens for this user."""
|
||||
return [t for t in db.data().reset_tokens.values() if t.user_uuid == self.uuid]
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
display_name: str,
|
||||
role: UUID,
|
||||
role: UUID | Role,
|
||||
created_at: datetime | None = None,
|
||||
) -> "User":
|
||||
) -> User:
|
||||
"""Create a new User with auto-generated uuid7."""
|
||||
|
||||
role_uuid = role if isinstance(role, UUID) else role.uuid
|
||||
user = cls(
|
||||
display_name=display_name,
|
||||
role=role,
|
||||
created_at=created_at or datetime.now(timezone.utc),
|
||||
role_uuid=role_uuid,
|
||||
created_at=created_at or datetime.now(UTC),
|
||||
)
|
||||
user.uuid = uuid7.create(user.created_at)
|
||||
return user
|
||||
@@ -145,7 +213,7 @@ class Credential(msgspec.Struct, dict=True):
|
||||
"""
|
||||
|
||||
credential_id: bytes # Long binary ID from the authenticator
|
||||
user: UUID
|
||||
user_uuid: UUID = msgspec.field(name="user")
|
||||
aaguid: UUID
|
||||
public_key: bytes
|
||||
sign_count: int
|
||||
@@ -156,21 +224,34 @@ class Credential(msgspec.Struct, dict=True):
|
||||
def __post_init__(self):
|
||||
self.uuid: UUID = _UUID_UNSET # Convenience field, not serialized
|
||||
|
||||
@property
|
||||
def user(self) -> User:
|
||||
"""Get the User object for this credential."""
|
||||
return db.data().users[self.user_uuid]
|
||||
|
||||
@property
|
||||
def sessions(self) -> list[Session]:
|
||||
"""Get all sessions using this credential."""
|
||||
return [
|
||||
s for s in db.data().sessions.values() if s.credential_uuid == self.uuid
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
credential_id: bytes,
|
||||
user: UUID,
|
||||
user: UUID | User,
|
||||
aaguid: UUID,
|
||||
public_key: bytes,
|
||||
sign_count: int,
|
||||
created_at: datetime | None = None,
|
||||
) -> "Credential":
|
||||
) -> Credential:
|
||||
"""Create a new Credential with auto-generated uuid7."""
|
||||
now = created_at or datetime.now(timezone.utc)
|
||||
user_uuid = user if isinstance(user, UUID) else user.uuid
|
||||
now = created_at or datetime.now(UTC)
|
||||
cred = cls(
|
||||
credential_id=credential_id,
|
||||
user=user,
|
||||
user_uuid=user_uuid,
|
||||
aaguid=aaguid,
|
||||
public_key=public_key,
|
||||
sign_count=sign_count,
|
||||
@@ -186,12 +267,12 @@ class Session(msgspec.Struct, dict=True):
|
||||
"""Session data structure.
|
||||
|
||||
Mutable fields: expiry (updated on session refresh)
|
||||
Immutable fields: user, credential, host, ip, user_agent
|
||||
Immutable fields: user_uuid, credential_uuid, host, ip, user_agent
|
||||
key is stored in the dict key, not in the struct.
|
||||
"""
|
||||
|
||||
user: UUID
|
||||
credential: UUID
|
||||
user_uuid: UUID = msgspec.field(name="user")
|
||||
credential_uuid: UUID = msgspec.field(name="credential")
|
||||
host: str
|
||||
ip: str
|
||||
user_agent: str
|
||||
@@ -200,6 +281,16 @@ class Session(msgspec.Struct, dict=True):
|
||||
def __post_init__(self):
|
||||
self.key: str = "" # Convenience field, not serialized
|
||||
|
||||
@property
|
||||
def user(self) -> User:
|
||||
"""Get the User object for this session."""
|
||||
return db.data().users[self.user_uuid]
|
||||
|
||||
@property
|
||||
def credential(self) -> Credential:
|
||||
"""Get the Credential object for this session."""
|
||||
return db.data().credentials[self.credential_uuid]
|
||||
|
||||
def metadata(self) -> dict:
|
||||
"""Return session metadata for backwards compatibility."""
|
||||
return {
|
||||
@@ -208,6 +299,32 @@ class Session(msgspec.Struct, dict=True):
|
||||
"expiry": self.expiry.isoformat(),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
user: UUID | User,
|
||||
credential: UUID | Credential,
|
||||
host: str,
|
||||
ip: str,
|
||||
user_agent: str,
|
||||
expiry: datetime,
|
||||
) -> Session:
|
||||
"""Create a new Session with auto-generated key."""
|
||||
user_uuid = user if isinstance(user, UUID) else user.uuid
|
||||
credential_uuid = (
|
||||
credential if isinstance(credential, UUID) else credential.uuid
|
||||
)
|
||||
session = cls(
|
||||
user_uuid=user_uuid,
|
||||
credential_uuid=credential_uuid,
|
||||
host=host,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
expiry=expiry,
|
||||
)
|
||||
session.key = secrets.token_urlsafe(12)
|
||||
return session
|
||||
|
||||
|
||||
class ResetToken(msgspec.Struct, dict=True):
|
||||
"""Reset/device-addition token data structure.
|
||||
@@ -216,13 +333,18 @@ class ResetToken(msgspec.Struct, dict=True):
|
||||
key is stored in the dict key, not in the struct.
|
||||
"""
|
||||
|
||||
user: UUID
|
||||
user_uuid: UUID = msgspec.field(name="user")
|
||||
expiry: datetime
|
||||
token_type: str
|
||||
|
||||
def __post_init__(self):
|
||||
self.key: bytes = b"" # Convenience field, not serialized
|
||||
|
||||
@property
|
||||
def user(self) -> User:
|
||||
"""Get the User object for this reset token."""
|
||||
return db.data().users[self.user_uuid]
|
||||
|
||||
|
||||
class SessionContext(msgspec.Struct):
|
||||
session: Session
|
||||
@@ -296,18 +418,16 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
||||
return None
|
||||
|
||||
try:
|
||||
user = self.users[s.user]
|
||||
role = self.roles[user.role]
|
||||
org = self.orgs[role.org]
|
||||
credential = self.credentials[s.credential]
|
||||
user = s.user
|
||||
role = user.role
|
||||
org = role.org
|
||||
credential = s.credential
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
# Effective permissions: role's permissions that the org can grant
|
||||
# Also filter by domain if host is provided
|
||||
org_perm_uuids = {
|
||||
pid for pid, p in self.permissions.items() if org.uuid in p.orgs
|
||||
}
|
||||
org_perm_uuids = {p.uuid for p in org.permissions}
|
||||
normalized_host = normalize_host(host)
|
||||
host_without_port = (
|
||||
normalized_host.rsplit(":", 1)[0] if normalized_host else None
|
||||
|
||||
+43
-85
@@ -1,5 +1,5 @@
|
||||
import logging
|
||||
from datetime import timezone
|
||||
from datetime import UTC
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Body, FastAPI, HTTPException, Query, Request, Response
|
||||
@@ -28,38 +28,18 @@ from paskia.util.hostutil import normalize_host
|
||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||
|
||||
|
||||
def is_global_admin(ctx) -> bool:
|
||||
"""Check if user has global admin permission."""
|
||||
effective_scopes = (
|
||||
{p.scope for p in (ctx.permissions or [])}
|
||||
if ctx.permissions
|
||||
else set(ctx.role.permissions or [])
|
||||
def master_admin(ctx) -> bool:
|
||||
return any(p.scope == "auth:admin" for p in ctx.permissions)
|
||||
|
||||
|
||||
def org_admin(ctx, org_uuid: UUID) -> bool:
|
||||
return ctx.org.uuid == org_uuid and any(
|
||||
p.scope == "auth:org:admin" for p in ctx.permissions
|
||||
)
|
||||
return "auth:admin" in effective_scopes
|
||||
|
||||
|
||||
def is_org_admin(ctx, org_uuid: UUID | None = None) -> bool:
|
||||
"""Check if user has org admin permission.
|
||||
|
||||
If org_uuid is provided, checks if user is admin of that specific org.
|
||||
If org_uuid is None, checks if user is admin of their own org.
|
||||
"""
|
||||
effective_scopes = (
|
||||
{p.scope for p in (ctx.permissions or [])}
|
||||
if ctx.permissions
|
||||
else set(ctx.role.permissions or [])
|
||||
)
|
||||
if "auth:org:admin" not in effective_scopes:
|
||||
return False
|
||||
if org_uuid is None:
|
||||
return True
|
||||
# User must belong to the target org (via their role)
|
||||
return ctx.org.uuid == org_uuid
|
||||
|
||||
|
||||
def can_manage_org(ctx, org_uuid: UUID) -> bool:
|
||||
"""Check if user can manage the specified organization."""
|
||||
return is_global_admin(ctx) or is_org_admin(ctx, org_uuid)
|
||||
return master_admin(ctx) or org_admin(ctx, org_uuid)
|
||||
|
||||
|
||||
@app.exception_handler(ValueError)
|
||||
@@ -99,14 +79,14 @@ async def admin_list_orgs(request: Request, auth=AUTH_COOKIE):
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
orgs = list(db.data().orgs.values())
|
||||
if not is_global_admin(ctx):
|
||||
if not master_admin(ctx):
|
||||
# Org admins can only see their own organization
|
||||
orgs = [o for o in orgs if o.uuid == ctx.org.uuid]
|
||||
|
||||
def role_to_dict(r):
|
||||
return {
|
||||
"uuid": str(r.uuid),
|
||||
"org": str(r.org),
|
||||
"org": str(r.org_uuid),
|
||||
"display_name": r.display_name,
|
||||
"permissions": list(r.permissions.keys()),
|
||||
}
|
||||
@@ -116,12 +96,8 @@ async def admin_list_orgs(request: Request, auth=AUTH_COOKIE):
|
||||
return {
|
||||
"uuid": str(o.uuid),
|
||||
"display_name": o.display_name,
|
||||
"permissions": {
|
||||
pid for pid, p in db.data().permissions.items() if o.uuid in p.orgs
|
||||
},
|
||||
"roles": [
|
||||
role_to_dict(r) for r in db.data().roles.values() if r.org == o.uuid
|
||||
],
|
||||
"permissions": {p.uuid for p in o.permissions},
|
||||
"roles": [role_to_dict(r) for r in o.roles],
|
||||
"users": [
|
||||
{
|
||||
"uuid": str(u.uuid),
|
||||
@@ -283,7 +259,8 @@ async def admin_create_role(
|
||||
perms = payload.get("permissions") or []
|
||||
if org_uuid not in db.data().orgs:
|
||||
raise HTTPException(status_code=404, detail="Organization not found")
|
||||
grantable = {pid for pid, p in db.data().permissions.items() if org_uuid in p.orgs}
|
||||
org = db.data().orgs[org_uuid]
|
||||
grantable = {p.uuid for p in org.permissions}
|
||||
|
||||
# Normalize permission IDs to UUIDs
|
||||
permission_uuids: set[UUID] = set()
|
||||
@@ -324,7 +301,7 @@ async def admin_update_role_name(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
role = db.data().roles.get(role_uuid)
|
||||
if not role or role.org != org_uuid:
|
||||
if not role or role.org_uuid != org_uuid:
|
||||
raise HTTPException(status_code=404, detail="Role not found in organization")
|
||||
|
||||
display_name = payload.get("display_name")
|
||||
@@ -356,7 +333,7 @@ async def admin_add_role_permission(
|
||||
)
|
||||
|
||||
role = db.data().roles.get(role_uuid)
|
||||
if not role or role.org != org_uuid:
|
||||
if not role or role.org_uuid != org_uuid:
|
||||
raise HTTPException(status_code=404, detail="Role not found in organization")
|
||||
|
||||
# Verify permission exists and org can grant it
|
||||
@@ -391,7 +368,7 @@ async def admin_remove_role_permission(
|
||||
)
|
||||
|
||||
role = db.data().roles.get(role_uuid)
|
||||
if not role or role.org != org_uuid:
|
||||
if not role or role.org_uuid != org_uuid:
|
||||
raise HTTPException(status_code=404, detail="Role not found in organization")
|
||||
|
||||
# Sanity check: prevent admin from removing their own access
|
||||
@@ -432,7 +409,7 @@ async def admin_delete_role(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
role = db.data().roles.get(role_uuid)
|
||||
if not role or role.org != org_uuid:
|
||||
if not role or role.org_uuid != org_uuid:
|
||||
raise HTTPException(status_code=404, detail="Role not found in organization")
|
||||
|
||||
# Sanity check: prevent admin from deleting their own role
|
||||
@@ -468,8 +445,11 @@ async def admin_create_user(
|
||||
if not display_name or not role_name:
|
||||
raise ValueError("display_name and role are required")
|
||||
|
||||
roles = [r for r in db.data().roles.values() if r.org == org_uuid]
|
||||
role_obj = next((r for r in roles if r.display_name == role_name), None)
|
||||
org = db.data().orgs[org_uuid]
|
||||
role_obj = next(
|
||||
(r for r in org.roles if r.display_name == role_name),
|
||||
None,
|
||||
)
|
||||
if not role_obj:
|
||||
raise ValueError("Role not found in organization")
|
||||
user = UserDC.create(
|
||||
@@ -507,7 +487,7 @@ async def admin_update_user_role(
|
||||
raise ValueError("User not found")
|
||||
if user_org.uuid != org_uuid:
|
||||
raise ValueError("User does not belong to this organization")
|
||||
roles = [r for r in db.data().roles.values() if r.org == org_uuid]
|
||||
roles = user_org.roles
|
||||
if not any(r.display_name == new_role for r in roles):
|
||||
raise ValueError("Role not found in organization")
|
||||
|
||||
@@ -573,9 +553,9 @@ async def admin_create_user_registration_link(
|
||||
return {
|
||||
"url": url,
|
||||
"expires": (
|
||||
expiry.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
expiry.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
||||
if expiry.tzinfo
|
||||
else expiry.replace(tzinfo=timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
else expiry.replace(tzinfo=UTC).isoformat().replace("+00:00", "Z")
|
||||
),
|
||||
}
|
||||
|
||||
@@ -604,7 +584,7 @@ async def admin_get_user_detail(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
user = db.data().users.get(user_uuid)
|
||||
user_creds = [c for c in db.data().credentials.values() if c.user == user_uuid]
|
||||
user_creds = user.credentials
|
||||
creds: list[dict] = []
|
||||
aaguids: set[str] = set()
|
||||
for c in user_creds:
|
||||
@@ -615,21 +595,17 @@ async def admin_get_user_detail(
|
||||
"credential": str(c.uuid),
|
||||
"aaguid": aaguid_str,
|
||||
"created_at": (
|
||||
c.created_at.astimezone(timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
c.created_at.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
||||
if c.created_at.tzinfo
|
||||
else c.created_at.replace(tzinfo=timezone.utc)
|
||||
else c.created_at.replace(tzinfo=UTC)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
),
|
||||
"last_used": (
|
||||
c.last_used.astimezone(timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
c.last_used.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
||||
if c.last_used and c.last_used.tzinfo
|
||||
else (
|
||||
c.last_used.replace(tzinfo=timezone.utc)
|
||||
c.last_used.replace(tzinfo=UTC)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
if c.last_used
|
||||
@@ -637,12 +613,10 @@ async def admin_get_user_detail(
|
||||
)
|
||||
),
|
||||
"last_verified": (
|
||||
c.last_verified.astimezone(timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
c.last_verified.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
||||
if c.last_verified and c.last_verified.tzinfo
|
||||
else (
|
||||
c.last_verified.replace(tzinfo=timezone.utc)
|
||||
c.last_verified.replace(tzinfo=UTC)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
if c.last_verified
|
||||
@@ -659,7 +633,7 @@ async def admin_get_user_detail(
|
||||
|
||||
# Get sessions for the user
|
||||
normalized_request_host = hostutil.normalize_host(request.headers.get("host"))
|
||||
session_records = [s for s in db.data().sessions.values() if s.user == user_uuid]
|
||||
session_records = user.sessions
|
||||
current_session_key = auth
|
||||
sessions_payload: list[dict] = []
|
||||
for entry in session_records:
|
||||
@@ -672,11 +646,9 @@ async def admin_get_user_detail(
|
||||
"ip": entry.ip,
|
||||
"user_agent": useragent.compact_user_agent(entry.user_agent),
|
||||
"last_renewed": (
|
||||
renewed.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
renewed.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
||||
if renewed.tzinfo
|
||||
else renewed.replace(tzinfo=timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
else renewed.replace(tzinfo=UTC).isoformat().replace("+00:00", "Z")
|
||||
),
|
||||
"is_current": entry.key == current_session_key,
|
||||
"is_current_host": bool(
|
||||
@@ -693,23 +665,19 @@ async def admin_get_user_detail(
|
||||
"role": role_name,
|
||||
"visits": user.visits,
|
||||
"created_at": (
|
||||
user.created_at.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
user.created_at.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
||||
if user.created_at and user.created_at.tzinfo
|
||||
else (
|
||||
user.created_at.replace(tzinfo=timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
user.created_at.replace(tzinfo=UTC).isoformat().replace("+00:00", "Z")
|
||||
if user.created_at
|
||||
else None
|
||||
)
|
||||
),
|
||||
"last_seen": (
|
||||
user.last_seen.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
user.last_seen.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
||||
if user.last_seen and user.last_seen.tzinfo
|
||||
else (
|
||||
user.last_seen.replace(tzinfo=timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
user.last_seen.replace(tzinfo=UTC).isoformat().replace("+00:00", "Z")
|
||||
if user.last_seen
|
||||
else None
|
||||
)
|
||||
@@ -808,7 +776,7 @@ async def admin_delete_user_session(
|
||||
)
|
||||
|
||||
target_session = db.data().sessions.get(session_id)
|
||||
if not target_session or target_session.user != user_uuid:
|
||||
if not target_session or target_session.user_uuid != user_uuid:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
db.delete_session(session_id, ctx=ctx)
|
||||
@@ -919,19 +887,9 @@ async def admin_list_permissions(request: Request, auth=AUTH_COOKIE):
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
perms = list(db.data().permissions.values())
|
||||
|
||||
# Global admins see all permissions
|
||||
if is_global_admin(ctx):
|
||||
perms = db.data().permissions.values() if master_admin(ctx) else ctx.org.permissions
|
||||
return [_perm_to_dict(p) for p in perms]
|
||||
|
||||
# Org admins only see permissions their org can grant (by UUID)
|
||||
grantable = {
|
||||
pid for pid, p in db.data().permissions.items() if ctx.org.uuid in p.orgs
|
||||
}
|
||||
filtered_perms = [p for p in perms if p.uuid in grantable]
|
||||
return [_perm_to_dict(p) for p in filtered_perms]
|
||||
|
||||
|
||||
@app.post("/permissions")
|
||||
async def admin_create_permission(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
from contextlib import suppress
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from fastapi import (
|
||||
Depends,
|
||||
@@ -90,7 +90,7 @@ async def validate_token(
|
||||
raise
|
||||
renewed = False
|
||||
if auth:
|
||||
consumed = EXPIRES - (ctx.session.expiry - datetime.now(timezone.utc))
|
||||
consumed = EXPIRES - (ctx.session.expiry - datetime.now(UTC))
|
||||
if not timedelta(0) < consumed < _REFRESH_INTERVAL:
|
||||
try:
|
||||
refresh_session_token(
|
||||
@@ -123,7 +123,7 @@ async def token_info(credentials=Depends(bearer_auth)):
|
||||
except ValueError as e:
|
||||
raise HTTPException(401, str(e))
|
||||
|
||||
u = db.data().users.get(reset_token.user)
|
||||
u = reset_token.user
|
||||
return {
|
||||
"token_type": reset_token.token_type,
|
||||
"display_name": u.display_name,
|
||||
@@ -170,11 +170,9 @@ async def forward_authentication(
|
||||
"Remote-Role": str(ctx.role.uuid),
|
||||
"Remote-Role-Name": ctx.role.display_name,
|
||||
"Remote-Session-Expires": (
|
||||
ctx.session.expiry.astimezone(timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
ctx.session.expiry.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
||||
if ctx.session.expiry.tzinfo
|
||||
else ctx.session.expiry.replace(tzinfo=timezone.utc)
|
||||
else ctx.session.expiry.replace(tzinfo=UTC)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
),
|
||||
|
||||
@@ -324,7 +324,7 @@ async def websocket_remote_auth_permit(ws: WebSocket):
|
||||
token_str = passphrase.generate()
|
||||
expiry = expires()
|
||||
db.create_reset_token(
|
||||
user_uuid=cred.user,
|
||||
user_uuid=cred.user_uuid,
|
||||
passphrase=token_str,
|
||||
expiry=expiry,
|
||||
token_type="device addition",
|
||||
@@ -333,7 +333,7 @@ async def websocket_remote_auth_permit(ws: WebSocket):
|
||||
# Also create a session so the device is logged in
|
||||
normalized_host = hostutil.normalize_host(request.host)
|
||||
session_token = db.login(
|
||||
user_uuid=cred.user,
|
||||
user_uuid=cred.user_uuid,
|
||||
credential_uuid=cred.uuid,
|
||||
sign_count=new_sign_count,
|
||||
host=normalized_host,
|
||||
@@ -346,7 +346,7 @@ async def websocket_remote_auth_permit(ws: WebSocket):
|
||||
|
||||
normalized_host = hostutil.normalize_host(request.host)
|
||||
session_token = db.login(
|
||||
user_uuid=cred.user,
|
||||
user_uuid=cred.user_uuid,
|
||||
credential_uuid=cred.uuid,
|
||||
sign_count=new_sign_count,
|
||||
host=normalized_host,
|
||||
@@ -359,7 +359,7 @@ async def websocket_remote_auth_permit(ws: WebSocket):
|
||||
completed = await remoteauth.instance.complete_request(
|
||||
token=request.key,
|
||||
session_token=session_token,
|
||||
user_uuid=cred.user,
|
||||
user_uuid=cred.user_uuid,
|
||||
credential_uuid=cred.uuid,
|
||||
reset_token=reset_token,
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import timezone
|
||||
from datetime import UTC
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import (
|
||||
@@ -91,7 +91,7 @@ async def api_delete_session(
|
||||
)
|
||||
|
||||
target_session = db.data().sessions.get(session_id)
|
||||
if not target_session or target_session.user != ctx.user.uuid:
|
||||
if not target_session or target_session.user_uuid != ctx.user.uuid:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
db.delete_session(session_id, ctx=ctx)
|
||||
@@ -141,8 +141,8 @@ async def api_create_link(
|
||||
"message": "Registration link generated successfully",
|
||||
"url": url,
|
||||
"expires": (
|
||||
expiry.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
expiry.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
||||
if expiry.tzinfo
|
||||
else expiry.replace(tzinfo=timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
else expiry.replace(tzinfo=UTC).isoformat().replace("+00:00", "Z")
|
||||
),
|
||||
}
|
||||
|
||||
@@ -38,11 +38,11 @@ async def websocket_register_add(
|
||||
f"The reset link for {passkey.instance.rp_name} is invalid or has expired"
|
||||
)
|
||||
s = get_reset(reset)
|
||||
user_uuid = s.user
|
||||
user_uuid = s.user_uuid
|
||||
else:
|
||||
# Require recent authentication for adding a new passkey
|
||||
ctx = await authz.verify(auth, perm=[], host=host, max_age="5m")
|
||||
user_uuid = ctx.session.user
|
||||
user_uuid = ctx.session.user_uuid
|
||||
s = ctx.session
|
||||
|
||||
# Get user information and determine effective user_name for this registration
|
||||
@@ -99,7 +99,7 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
|
||||
cred, new_sign_count = await authenticate_chat(ws, origin, credential_ids)
|
||||
|
||||
# If reauth mode, verify the credential belongs to the session's user
|
||||
if session_user_uuid and cred.user != session_user_uuid:
|
||||
if session_user_uuid and cred.user_uuid != session_user_uuid:
|
||||
raise ValueError("This passkey belongs to a different account")
|
||||
|
||||
# Create session and update user/credential in a single transaction
|
||||
@@ -114,7 +114,7 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
|
||||
raise ValueError(f"Host must be the same as or a subdomain of {rp_id}")
|
||||
|
||||
token = db.login(
|
||||
user_uuid=cred.user,
|
||||
user_uuid=cred.user_uuid,
|
||||
credential_uuid=cred.uuid,
|
||||
sign_count=new_sign_count,
|
||||
host=normalized_host,
|
||||
@@ -125,7 +125,7 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
|
||||
|
||||
await ws.send_json(
|
||||
{
|
||||
"user": str(cred.user),
|
||||
"user": str(cred.user_uuid),
|
||||
"session_token": token,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@ Or via the CLI entry point (if installed):
|
||||
import argparse
|
||||
import asyncio
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID
|
||||
|
||||
import base64url
|
||||
@@ -173,7 +173,7 @@ async def migrate_from_sql(
|
||||
new_user = User(
|
||||
display_name=legacy_user.display_name,
|
||||
role=legacy_user.role_uuid,
|
||||
created_at=legacy_user.created_at or datetime.now(timezone.utc),
|
||||
created_at=legacy_user.created_at or datetime.now(UTC),
|
||||
last_seen=legacy_user.last_seen,
|
||||
visits=legacy_user.visits,
|
||||
)
|
||||
@@ -190,7 +190,7 @@ async def migrate_from_sql(
|
||||
cred_key: UUID = legacy_cred.uuid
|
||||
new_cred = Credential(
|
||||
credential_id=legacy_cred.credential_id,
|
||||
user=legacy_cred.user_uuid,
|
||||
user_uuid=legacy_cred.user_uuid,
|
||||
aaguid=legacy_cred.aaguid,
|
||||
public_key=legacy_cred.public_key,
|
||||
sign_count=legacy_cred.sign_count,
|
||||
@@ -217,8 +217,8 @@ async def migrate_from_sql(
|
||||
# Already in new format or unknown - try to use as-is
|
||||
session_key = base64url.enc(old_key[:12])
|
||||
db.sessions[session_key] = Session(
|
||||
user=sess.user_uuid,
|
||||
credential=sess.credential_uuid,
|
||||
user_uuid=sess.user_uuid,
|
||||
credential_uuid=sess.credential_uuid,
|
||||
host=sess.host,
|
||||
ip=sess.ip,
|
||||
user_agent=sess.user_agent,
|
||||
@@ -241,7 +241,7 @@ async def migrate_from_sql(
|
||||
# Already in new format or unknown - truncate to 9 bytes
|
||||
token_key = old_key[:9]
|
||||
db.reset_tokens[token_key] = ResetToken(
|
||||
user=token.user_uuid,
|
||||
user_uuid=token.user_uuid,
|
||||
expiry=token.expiry,
|
||||
token_type=token.token_type,
|
||||
)
|
||||
|
||||
@@ -9,7 +9,7 @@ DO NOT use this module for new code. Use paskia.db instead.
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import (
|
||||
@@ -112,8 +112,8 @@ def _normalize_dt(value: datetime | None) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
return value.replace(tzinfo=UTC)
|
||||
return value.astimezone(UTC)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
@@ -172,7 +172,7 @@ class UserModel(Base):
|
||||
LargeBinary(16), ForeignKey("roles.uuid", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
DateTime(timezone=True), default=lambda: datetime.now(UTC)
|
||||
)
|
||||
last_seen: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
@@ -195,7 +195,7 @@ class UserModel(Base):
|
||||
uuid=user.uuid.bytes,
|
||||
display_name=user.display_name,
|
||||
role_uuid=user.role_uuid.bytes,
|
||||
created_at=user.created_at or datetime.now(timezone.utc),
|
||||
created_at=user.created_at or datetime.now(UTC),
|
||||
last_seen=user.last_seen,
|
||||
visits=user.visits,
|
||||
)
|
||||
@@ -215,7 +215,7 @@ class CredentialModel(Base):
|
||||
public_key: Mapped[bytes] = mapped_column(BLOB, nullable=False)
|
||||
sign_count: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
DateTime(timezone=True), default=lambda: datetime.now(UTC)
|
||||
)
|
||||
last_used: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
@@ -255,7 +255,7 @@ class SessionModel(Base):
|
||||
user_agent: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
renewed: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
default=lambda: datetime.now(UTC),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
@@ -19,9 +19,9 @@ The first 3 words of the token serve as the pairing code for manual entry.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import UUID
|
||||
|
||||
from paskia.util import passphrase, pow
|
||||
@@ -94,7 +94,7 @@ class RemoteAuthManager:
|
||||
|
||||
async def _cleanup_expired(self):
|
||||
"""Remove expired requests and notify waiting clients."""
|
||||
now = datetime.now(timezone.utc)
|
||||
now = datetime.now(UTC)
|
||||
expired_keys = []
|
||||
async with self._lock:
|
||||
for key, req in self._requests.items():
|
||||
@@ -123,7 +123,7 @@ class RemoteAuthManager:
|
||||
Returns:
|
||||
(code, expiry) - The 3-word passphrase code and expiration time
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
now = datetime.now(UTC)
|
||||
expiry = now + REMOTE_AUTH_LIFETIME
|
||||
|
||||
async with self._lock:
|
||||
@@ -160,7 +160,7 @@ class RemoteAuthManager:
|
||||
req = self._requests.get(normalized)
|
||||
if req is None:
|
||||
return None
|
||||
now = datetime.now(timezone.utc)
|
||||
now = datetime.now(UTC)
|
||||
if now > req.created_at + REMOTE_AUTH_LIFETIME:
|
||||
# Expired
|
||||
del self._requests[normalized]
|
||||
@@ -331,7 +331,7 @@ class RemoteAuthManager:
|
||||
req = self._requests.get(token)
|
||||
if req is None:
|
||||
return None
|
||||
now = datetime.now(timezone.utc)
|
||||
now = datetime.now(UTC)
|
||||
if now > req.created_at + REMOTE_AUTH_LIFETIME:
|
||||
del self._requests[token]
|
||||
return None
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Utility functions for session validation and checking."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from paskia.authsession import EXPIRES
|
||||
from paskia.db import SessionContext
|
||||
@@ -34,5 +34,5 @@ def check_session_age(ctx: SessionContext, max_age: str | None) -> bool:
|
||||
else:
|
||||
auth_time = ctx.session.expiry - EXPIRES
|
||||
|
||||
time_since_auth = datetime.now(timezone.utc) - auth_time
|
||||
time_since_auth = datetime.now(UTC) - auth_time
|
||||
return time_since_auth <= max_age_delta
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""User information formatting and retrieval logic."""
|
||||
|
||||
from datetime import timezone
|
||||
from datetime import UTC
|
||||
|
||||
from paskia import aaguid, db
|
||||
from paskia.authsession import EXPIRES
|
||||
@@ -13,9 +13,9 @@ def _format_datetime(dt):
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo:
|
||||
return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
return dt.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
||||
else:
|
||||
return dt.replace(tzinfo=timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
return dt.replace(tzinfo=UTC).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def format_session_context(ctx: SessionContext) -> dict:
|
||||
@@ -48,9 +48,8 @@ async def format_user_info(
|
||||
ctx = await permutil.session_context(auth, request_host)
|
||||
|
||||
# Fetch and format credentials
|
||||
user_credentials = [
|
||||
c for c in db.data().credentials.values() if c.user == user_uuid
|
||||
]
|
||||
user = db.data().users[user_uuid]
|
||||
user_credentials = user.credentials
|
||||
credentials: list[dict] = []
|
||||
user_aaguids: set[str] = set()
|
||||
|
||||
@@ -74,7 +73,7 @@ async def format_user_info(
|
||||
|
||||
# Format sessions
|
||||
normalized_request_host = hostutil.normalize_host(request_host)
|
||||
session_records = [s for s in db.data().sessions.values() if s.user == user_uuid]
|
||||
session_records = user.sessions
|
||||
current_session_key = auth
|
||||
sessions_payload: list[dict] = []
|
||||
|
||||
|
||||
@@ -74,10 +74,6 @@ filterwarnings = [
|
||||
"ignore::DeprecationWarning",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py39"
|
||||
line-length = 88
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "N", "W", "UP", "PLC0415"]
|
||||
ignore = ["E501"] # Line too long
|
||||
|
||||
+3
-9
@@ -38,7 +38,7 @@ from paskia.db import (
|
||||
create_user,
|
||||
)
|
||||
from paskia.db.jsonl import JsonlStore
|
||||
from paskia.db.operations import DB, _create_token
|
||||
from paskia.db.operations import DB
|
||||
from paskia.fastapi.mainapp import app
|
||||
from paskia.fastapi.session import AUTH_COOKIE_NAME
|
||||
from paskia.sansio import Passkey
|
||||
@@ -193,17 +193,14 @@ async def session_token(
|
||||
test_db: DB, test_user: User, test_credential: Credential
|
||||
) -> str:
|
||||
"""Create a session for the admin user and return the token."""
|
||||
token = _create_token()
|
||||
create_session(
|
||||
return create_session(
|
||||
user_uuid=test_user.uuid,
|
||||
credential_uuid=test_credential.uuid,
|
||||
key=token,
|
||||
host="localhost:4401",
|
||||
ip="127.0.0.1",
|
||||
user_agent="pytest",
|
||||
expiry=expires(),
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
@@ -211,17 +208,14 @@ async def regular_session_token(
|
||||
test_db: DB, regular_user: User, regular_credential: Credential
|
||||
) -> str:
|
||||
"""Create a session for a regular user and return the token."""
|
||||
token = _create_token()
|
||||
create_session(
|
||||
return create_session(
|
||||
user_uuid=regular_user.uuid,
|
||||
credential_uuid=regular_credential.uuid,
|
||||
key=token,
|
||||
host="localhost:4401",
|
||||
ip="127.0.0.1",
|
||||
user_agent="pytest",
|
||||
expiry=expires(),
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
|
||||
+8
-15
@@ -12,7 +12,8 @@ These tests cover:
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
import secrets
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
@@ -36,7 +37,7 @@ from paskia.db import (
|
||||
create_session,
|
||||
create_user,
|
||||
)
|
||||
from paskia.db.operations import DB, _create_token
|
||||
from paskia.db.operations import DB
|
||||
from tests.conftest import auth_headers
|
||||
|
||||
# -------------------- Additional Fixtures --------------------
|
||||
@@ -97,17 +98,14 @@ async def second_org_session_token(
|
||||
test_db: DB, second_org_user: User, second_org_credential: Credential
|
||||
) -> str:
|
||||
"""Create a session for the second org admin user."""
|
||||
token = _create_token()
|
||||
create_session(
|
||||
return create_session(
|
||||
user_uuid=second_org_user.uuid,
|
||||
credential_uuid=second_org_credential.uuid,
|
||||
key=token,
|
||||
host="localhost:4401",
|
||||
ip="127.0.0.1",
|
||||
user_agent="pytest",
|
||||
expiry=expires(),
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
@@ -132,7 +130,7 @@ async def org_admin_user(test_db: DB, org_admin_role: Role) -> User:
|
||||
role=org_admin_role.uuid,
|
||||
)
|
||||
user.visits = 5
|
||||
user.last_seen = datetime.now(timezone.utc)
|
||||
user.last_seen = datetime.now(UTC)
|
||||
create_user(user)
|
||||
return user
|
||||
|
||||
@@ -157,17 +155,14 @@ async def org_admin_session_token(
|
||||
test_db: DB, org_admin_user: User, org_admin_credential: Credential
|
||||
) -> str:
|
||||
"""Create a session for the org admin user."""
|
||||
token = _create_token()
|
||||
create_session(
|
||||
return create_session(
|
||||
user_uuid=org_admin_user.uuid,
|
||||
credential_uuid=org_admin_credential.uuid,
|
||||
key=token,
|
||||
host="localhost:4401",
|
||||
ip="127.0.0.1",
|
||||
user_agent="pytest",
|
||||
expiry=expires(),
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
@@ -1170,11 +1165,9 @@ class TestAdminSessions:
|
||||
):
|
||||
"""Admin should be able to delete a user's session."""
|
||||
# Create an additional session to delete
|
||||
extra_token = _create_token()
|
||||
create_session(
|
||||
extra_token = create_session(
|
||||
user_uuid=test_user.uuid,
|
||||
credential_uuid=test_credential.uuid,
|
||||
key=extra_token,
|
||||
host="other.host:4401",
|
||||
ip="192.168.1.1",
|
||||
user_agent="other-agent",
|
||||
@@ -1255,7 +1248,7 @@ class TestAdminSessions:
|
||||
):
|
||||
"""Deleting non-existent session should fail."""
|
||||
# Use a valid format but non-existent key
|
||||
fake_token = _create_token()
|
||||
fake_token = secrets.token_urlsafe(12)
|
||||
response = await client.delete(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/sessions/{fake_token}",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
|
||||
+5
-7
@@ -10,14 +10,14 @@ These tests cover:
|
||||
- /auth/api/set-session - Set session from bearer token
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import secrets
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from paskia.authsession import EXPIRES
|
||||
from paskia.db import create_session, delete_session
|
||||
from paskia.db.operations import _create_token
|
||||
from paskia.util.passphrase import generate
|
||||
from tests.conftest import auth_headers
|
||||
|
||||
@@ -503,7 +503,7 @@ class TestValidateSessionRefresh:
|
||||
"""Validate should handle session expiry during refresh attempt."""
|
||||
|
||||
# Create a token but don't create a session for it
|
||||
token = _create_token()
|
||||
token = secrets.token_urlsafe(12)
|
||||
response = await client.post(
|
||||
"/auth/api/validate",
|
||||
headers={**auth_headers(token), "Host": "localhost:4401"},
|
||||
@@ -522,12 +522,10 @@ class TestValidateSessionRefresh:
|
||||
"""Validate should return 401 if session disappears during refresh."""
|
||||
|
||||
# Create a session with an old expiry time to trigger refresh
|
||||
token = _create_token()
|
||||
old_expiry = datetime.now(timezone.utc) + EXPIRES - timedelta(minutes=10)
|
||||
create_session(
|
||||
old_expiry = datetime.now(UTC) + EXPIRES - timedelta(minutes=10)
|
||||
token = create_session(
|
||||
user_uuid=test_user.uuid,
|
||||
credential_uuid=test_credential.uuid,
|
||||
key=token,
|
||||
host="localhost:4401",
|
||||
ip="127.0.0.1",
|
||||
user_agent="pytest",
|
||||
|
||||
Reference in New Issue
Block a user