Move imports to top of file.
This commit is contained in:
@@ -9,13 +9,16 @@ independent of any web framework:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from paskia import db
|
from paskia import db
|
||||||
from paskia.config import SESSION_LIFETIME
|
from paskia.config import RESET_LIFETIME, SESSION_LIFETIME
|
||||||
from paskia.db import ResetToken, Session
|
|
||||||
from paskia.util import hostutil
|
from paskia.util import hostutil
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from paskia.db import ResetToken, Session
|
||||||
|
|
||||||
EXPIRES = SESSION_LIFETIME
|
EXPIRES = SESSION_LIFETIME
|
||||||
|
|
||||||
|
|
||||||
@@ -24,21 +27,21 @@ def expires() -> datetime:
|
|||||||
|
|
||||||
|
|
||||||
def reset_expires() -> datetime:
|
def reset_expires() -> datetime:
|
||||||
from .config import RESET_LIFETIME
|
|
||||||
|
|
||||||
return datetime.now(timezone.utc) + RESET_LIFETIME
|
return datetime.now(timezone.utc) + RESET_LIFETIME
|
||||||
|
|
||||||
|
|
||||||
async def get_reset(token: str) -> ResetToken:
|
async def get_reset(token: str) -> "ResetToken":
|
||||||
"""Validate a credential reset token."""
|
"""Validate a credential reset token."""
|
||||||
|
|
||||||
record = db.get_reset_token(token)
|
record = db.get_reset_token(token)
|
||||||
if record:
|
if record:
|
||||||
return record
|
return record
|
||||||
raise ValueError("This authentication link is no longer valid.")
|
raise ValueError("This authentication link is no longer valid.")
|
||||||
|
|
||||||
|
|
||||||
async def get_session(token: str, host: str | None = None) -> Session:
|
async def get_session(token: str, host: str | None = None) -> "Session":
|
||||||
"""Validate a session token and return session data if valid."""
|
"""Validate a session token and return session data if valid."""
|
||||||
|
|
||||||
host = hostutil.normalize_host(host)
|
host = hostutil.normalize_host(host)
|
||||||
if not host:
|
if not host:
|
||||||
raise ValueError("Invalid host")
|
raise ValueError("Invalid host")
|
||||||
|
|||||||
+2
-6
@@ -9,8 +9,8 @@ generating a reset link for initial admin setup.
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from paskia import db
|
from paskia import authsession, db, globals
|
||||||
from paskia.util import hostutil
|
from paskia.util import hostutil, passphrase
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -72,8 +72,6 @@ async def check_admin_credentials() -> bool:
|
|||||||
|
|
||||||
if not db.get_user_credential_ids(admin_user.uuid):
|
if not db.get_user_credential_ids(admin_user.uuid):
|
||||||
# Admin exists but has no credentials, create reset link
|
# Admin exists but has no credentials, create reset link
|
||||||
from paskia import authsession
|
|
||||||
from paskia.util import passphrase
|
|
||||||
|
|
||||||
token = passphrase.generate()
|
token = passphrase.generate()
|
||||||
expiry = authsession.reset_expires()
|
expiry = authsession.reset_expires()
|
||||||
@@ -118,8 +116,6 @@ async def main():
|
|||||||
# Configure logging for CLI usage
|
# Configure logging for CLI usage
|
||||||
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
|
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
|
||||||
|
|
||||||
from paskia import globals
|
|
||||||
|
|
||||||
await globals.init()
|
await globals.init()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ Usage:
|
|||||||
db.create_user(user)
|
db.create_user(user)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import paskia.db.operations as operations
|
||||||
from paskia.db.background import (
|
from paskia.db.background import (
|
||||||
start_background,
|
start_background,
|
||||||
start_cleanup,
|
start_cleanup,
|
||||||
@@ -81,9 +82,7 @@ from paskia.db.structs import (
|
|||||||
|
|
||||||
def data() -> DB:
|
def data() -> DB:
|
||||||
"""Get the database instance for direct read access."""
|
"""Get the database instance for direct read access."""
|
||||||
from paskia.db.operations import _db
|
return operations._db
|
||||||
|
|
||||||
return _db
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from paskia.db.operations import _db, _store
|
||||||
|
|
||||||
# Flush changes to disk every N seconds
|
# Flush changes to disk every N seconds
|
||||||
FLUSH_INTERVAL = 1
|
FLUSH_INTERVAL = 1
|
||||||
# Cleanup expired items every N seconds (cheap when nothing to remove)
|
# Cleanup expired items every N seconds (cheap when nothing to remove)
|
||||||
@@ -20,7 +22,6 @@ _background_task: asyncio.Task | None = None
|
|||||||
|
|
||||||
def cleanup() -> None:
|
def cleanup() -> None:
|
||||||
"""Remove expired sessions and reset tokens from the database."""
|
"""Remove expired sessions and reset tokens from the database."""
|
||||||
from paskia.db.operations import _db
|
|
||||||
|
|
||||||
if _db is None:
|
if _db is None:
|
||||||
return
|
return
|
||||||
@@ -45,7 +46,6 @@ def cleanup() -> None:
|
|||||||
|
|
||||||
async def flush() -> None:
|
async def flush() -> None:
|
||||||
"""Write all pending database changes to disk."""
|
"""Write all pending database changes to disk."""
|
||||||
from paskia.db.operations import _store
|
|
||||||
|
|
||||||
if _store is None:
|
if _store is None:
|
||||||
_logger.warning("flush() called but _store is None")
|
_logger.warning("flush() called but _store is None")
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import secrets
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
|
import uuid7
|
||||||
|
|
||||||
|
from paskia.config import SESSION_LIFETIME
|
||||||
from paskia.db.jsonl import (
|
from paskia.db.jsonl import (
|
||||||
DB_PATH_DEFAULT,
|
DB_PATH_DEFAULT,
|
||||||
JsonlStore,
|
JsonlStore,
|
||||||
@@ -28,6 +31,8 @@ from paskia.db.structs import (
|
|||||||
SessionContext,
|
SessionContext,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
|
from paskia.util.hostutil import normalize_host
|
||||||
|
from paskia.util.passphrase import generate as generate_passphrase
|
||||||
from paskia.util.passphrase import is_well_formed as _is_passphrase
|
from paskia.util.passphrase import is_well_formed as _is_passphrase
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
@@ -137,7 +142,6 @@ def get_session_context(
|
|||||||
- Example usage in docstring (db/__init__.py:16)
|
- Example usage in docstring (db/__init__.py:16)
|
||||||
- Get session context from auth token (util/permutil.py:43)
|
- Get session context from auth token (util/permutil.py:43)
|
||||||
"""
|
"""
|
||||||
from paskia.util.hostutil import normalize_host
|
|
||||||
|
|
||||||
if session_key not in _db.sessions:
|
if session_key not in _db.sessions:
|
||||||
return None
|
return None
|
||||||
@@ -279,7 +283,6 @@ def create_organization(org: Org, *, ctx: SessionContext | None = None) -> None:
|
|||||||
_db.orgs[org.uuid] = new_org
|
_db.orgs[org.uuid] = new_org
|
||||||
new_org.uuid = org.uuid
|
new_org.uuid = org.uuid
|
||||||
# Create Administration role with org admin permission
|
# Create Administration role with org admin permission
|
||||||
import uuid7
|
|
||||||
|
|
||||||
admin_role_uuid = uuid7.create()
|
admin_role_uuid = uuid7.create()
|
||||||
# Find the auth:org:admin permission UUID
|
# Find the auth:org:admin permission UUID
|
||||||
@@ -786,7 +789,6 @@ def create_credential_session(
|
|||||||
|
|
||||||
Returns the generated session token.
|
Returns the generated session token.
|
||||||
"""
|
"""
|
||||||
from paskia.config import SESSION_LIFETIME
|
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
expiry = now + SESSION_LIFETIME
|
expiry = now + SESSION_LIFETIME
|
||||||
@@ -853,10 +855,6 @@ def bootstrap(
|
|||||||
Returns:
|
Returns:
|
||||||
The reset passphrase for admin registration.
|
The reset passphrase for admin registration.
|
||||||
"""
|
"""
|
||||||
import uuid7
|
|
||||||
|
|
||||||
from paskia.authsession import reset_expires
|
|
||||||
from paskia.util.passphrase import generate as generate_passphrase
|
|
||||||
|
|
||||||
# Check if system is already bootstrapped
|
# Check if system is already bootstrapped
|
||||||
for p in _db.permissions.values():
|
for p in _db.permissions.values():
|
||||||
@@ -876,6 +874,7 @@ def bootstrap(
|
|||||||
if reset_passphrase is None:
|
if reset_passphrase is None:
|
||||||
reset_passphrase = generate_passphrase()
|
reset_passphrase = generate_passphrase()
|
||||||
if reset_expiry is None:
|
if reset_expiry is None:
|
||||||
|
from paskia.util.timeutil import reset_expires # noqa: PLC0415
|
||||||
reset_expiry = reset_expires()
|
reset_expiry = reset_expires()
|
||||||
reset_key = _reset_key(reset_passphrase)
|
reset_key = _reset_key(reset_passphrase)
|
||||||
|
|
||||||
|
|||||||
+7
-10
@@ -5,10 +5,16 @@ from uuid import UUID
|
|||||||
from fastapi import Body, FastAPI, HTTPException, Query, Request, Response
|
from fastapi import Body, FastAPI, HTTPException, Query, Request, Response
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from paskia import aaguid as aaguid_mod
|
||||||
from paskia import db
|
from paskia import db
|
||||||
from paskia.authsession import EXPIRES, reset_expires
|
from paskia.authsession import EXPIRES, reset_expires
|
||||||
|
from paskia.db import Org as OrgDC
|
||||||
|
from paskia.db import Permission as PermDC
|
||||||
|
from paskia.db import Role as RoleDC
|
||||||
|
from paskia.db import User as UserDC
|
||||||
from paskia.fastapi import authz
|
from paskia.fastapi import authz
|
||||||
from paskia.fastapi.session import AUTH_COOKIE
|
from paskia.fastapi.session import AUTH_COOKIE
|
||||||
|
from paskia.globals import passkey
|
||||||
from paskia.util import (
|
from paskia.util import (
|
||||||
hostutil,
|
hostutil,
|
||||||
passphrase,
|
passphrase,
|
||||||
@@ -17,6 +23,7 @@ from paskia.util import (
|
|||||||
useragent,
|
useragent,
|
||||||
vitedev,
|
vitedev,
|
||||||
)
|
)
|
||||||
|
from paskia.util.hostutil import normalize_host
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
@@ -137,7 +144,6 @@ async def admin_create_org(
|
|||||||
ctx = await authz.verify(
|
ctx = await authz.verify(
|
||||||
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
|
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
|
||||||
)
|
)
|
||||||
from ..db import Org as OrgDC # local import to avoid cycles
|
|
||||||
|
|
||||||
display_name = payload.get("display_name") or "New Organization"
|
display_name = payload.get("display_name") or "New Organization"
|
||||||
permissions = payload.get("permissions") or []
|
permissions = payload.get("permissions") or []
|
||||||
@@ -296,7 +302,6 @@ async def admin_create_role(
|
|||||||
raise authz.AuthException(
|
raise authz.AuthException(
|
||||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||||
)
|
)
|
||||||
from ..db import Role as RoleDC
|
|
||||||
|
|
||||||
display_name = payload.get("display_name") or "New Role"
|
display_name = payload.get("display_name") or "New Role"
|
||||||
perms = payload.get("permissions") or []
|
perms = payload.get("permissions") or []
|
||||||
@@ -486,7 +491,6 @@ async def admin_create_user(
|
|||||||
role_name = payload.get("role")
|
role_name = payload.get("role")
|
||||||
if not display_name or not role_name:
|
if not display_name or not role_name:
|
||||||
raise ValueError("display_name and role are required")
|
raise ValueError("display_name and role are required")
|
||||||
from ..db import User as UserDC
|
|
||||||
|
|
||||||
roles = [r for r in db.data().roles.values() if r.org == org_uuid]
|
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)
|
role_obj = next((r for r in roles if r.display_name == role_name), None)
|
||||||
@@ -674,7 +678,6 @@ async def admin_get_user_detail(
|
|||||||
"sign_count": c.sign_count,
|
"sign_count": c.sign_count,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
from .. import aaguid as aaguid_mod
|
|
||||||
|
|
||||||
aaguid_info = aaguid_mod.filter(aaguids)
|
aaguid_info = aaguid_mod.filter(aaguids)
|
||||||
|
|
||||||
@@ -854,7 +857,6 @@ def _validate_permission_domain(domain: str | None) -> None:
|
|||||||
"""Validate that domain is rp_id or a subdomain of it."""
|
"""Validate that domain is rp_id or a subdomain of it."""
|
||||||
if domain is None:
|
if domain is None:
|
||||||
return
|
return
|
||||||
from paskia.globals import passkey
|
|
||||||
|
|
||||||
rp_id = passkey.instance.rp_id
|
rp_id = passkey.instance.rp_id
|
||||||
if domain == rp_id or domain.endswith(f".{rp_id}"):
|
if domain == rp_id or domain.endswith(f".{rp_id}"):
|
||||||
@@ -870,7 +872,6 @@ def _check_admin_lockout(
|
|||||||
Raises ValueError if this change would result in no auth:admin permissions
|
Raises ValueError if this change would result in no auth:admin permissions
|
||||||
being accessible from the current host.
|
being accessible from the current host.
|
||||||
"""
|
"""
|
||||||
from paskia.util.hostutil import normalize_host
|
|
||||||
|
|
||||||
normalized_host = normalize_host(current_host)
|
normalized_host = normalize_host(current_host)
|
||||||
host_without_port = normalized_host.rsplit(":", 1)[0] if normalized_host else None
|
host_without_port = normalized_host.rsplit(":", 1)[0] if normalized_host else None
|
||||||
@@ -905,7 +906,6 @@ def _check_admin_lockout_on_delete(perm_uuid: str, current_host: str | None) ->
|
|||||||
Raises ValueError if this deletion would result in no auth:admin permissions
|
Raises ValueError if this deletion would result in no auth:admin permissions
|
||||||
being accessible from the current host.
|
being accessible from the current host.
|
||||||
"""
|
"""
|
||||||
from paskia.util.hostutil import normalize_host
|
|
||||||
|
|
||||||
normalized_host = normalize_host(current_host)
|
normalized_host = normalize_host(current_host)
|
||||||
host_without_port = normalized_host.rsplit(":", 1)[0] if normalized_host else None
|
host_without_port = normalized_host.rsplit(":", 1)[0] if normalized_host else None
|
||||||
@@ -970,7 +970,6 @@ async def admin_create_permission(
|
|||||||
match=permutil.has_all,
|
match=permutil.has_all,
|
||||||
max_age="5m",
|
max_age="5m",
|
||||||
)
|
)
|
||||||
from ..db import Permission as PermDC
|
|
||||||
|
|
||||||
scope = payload.get("scope") or payload.get(
|
scope = payload.get("scope") or payload.get(
|
||||||
"id"
|
"id"
|
||||||
@@ -1018,8 +1017,6 @@ async def admin_update_permission(
|
|||||||
if perm.scope == "auth:admin" or new_scope == "auth:admin":
|
if perm.scope == "auth:admin" or new_scope == "auth:admin":
|
||||||
_check_admin_lockout(str(perm.uuid), domain_value, request.headers.get("host"))
|
_check_admin_lockout(str(perm.uuid), domain_value, request.headers.get("host"))
|
||||||
|
|
||||||
from ..db import Permission as PermDC
|
|
||||||
|
|
||||||
updated_perm = PermDC(
|
updated_perm = PermDC(
|
||||||
scope=new_scope,
|
scope=new_scope,
|
||||||
display_name=new_display_name,
|
display_name=new_display_name,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
@@ -7,6 +8,7 @@ from fastapi import FastAPI, HTTPException, Request, Response
|
|||||||
from fastapi.responses import FileResponse, RedirectResponse
|
from fastapi.responses import FileResponse, RedirectResponse
|
||||||
from fastapi_vue import Frontend
|
from fastapi_vue import Frontend
|
||||||
|
|
||||||
|
from paskia import globals
|
||||||
from paskia.fastapi import admin, api, auth_host, ws
|
from paskia.fastapi import admin, api, auth_host, ws
|
||||||
from paskia.fastapi.session import AUTH_COOKIE
|
from paskia.fastapi.session import AUTH_COOKIE
|
||||||
from paskia.util import hostutil, passphrase, vitedev
|
from paskia.util import hostutil, passphrase, vitedev
|
||||||
@@ -30,9 +32,6 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
|
|||||||
so that uvicorn reload / multiprocess workers inherit the settings.
|
so that uvicorn reload / multiprocess workers inherit the settings.
|
||||||
All keys are guaranteed to exist; values are already normalized by __main__.py.
|
All keys are guaranteed to exist; values are already normalized by __main__.py.
|
||||||
"""
|
"""
|
||||||
import json
|
|
||||||
|
|
||||||
from paskia import globals
|
|
||||||
|
|
||||||
config = json.loads(os.environ["PASKIA_CONFIG"])
|
config = json.loads(os.environ["PASKIA_CONFIG"])
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,11 @@ import base64url
|
|||||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
from paskia import db, remoteauth
|
from paskia import db, remoteauth
|
||||||
|
from paskia.authsession import expires
|
||||||
from paskia.fastapi.session import infodict
|
from paskia.fastapi.session import infodict
|
||||||
from paskia.fastapi.wschat import authenticate_chat
|
from paskia.fastapi.wschat import authenticate_chat
|
||||||
from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
|
from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
|
||||||
from paskia.util import passphrase, pow
|
from paskia.util import hostutil, passphrase, pow, useragent
|
||||||
|
|
||||||
# Create a FastAPI subapp for remote auth WebSocket endpoints
|
# Create a FastAPI subapp for remote auth WebSocket endpoints
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
@@ -268,7 +269,6 @@ async def websocket_remote_auth_permit(ws: WebSocket):
|
|||||||
6. Client sends WebAuthn response
|
6. Client sends WebAuthn response
|
||||||
7. Server sends {status: "success", message: "..."}
|
7. Server sends {status: "success", message: "..."}
|
||||||
"""
|
"""
|
||||||
from paskia.util import useragent
|
|
||||||
|
|
||||||
origin = validate_origin(ws)
|
origin = validate_origin(ws)
|
||||||
|
|
||||||
@@ -289,7 +289,6 @@ async def websocket_remote_auth_permit(ws: WebSocket):
|
|||||||
)
|
)
|
||||||
|
|
||||||
request = None
|
request = None
|
||||||
webauthn_challenge = None
|
|
||||||
explicitly_denied = False
|
explicitly_denied = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -321,8 +320,6 @@ async def websocket_remote_auth_permit(ws: WebSocket):
|
|||||||
|
|
||||||
if request.action == "register":
|
if request.action == "register":
|
||||||
# For registration, create a reset token for device addition
|
# For registration, create a reset token for device addition
|
||||||
from paskia.authsession import expires
|
|
||||||
from paskia.util import hostutil
|
|
||||||
|
|
||||||
token_str = passphrase.generate()
|
token_str = passphrase.generate()
|
||||||
expiry = expires()
|
expiry = expires()
|
||||||
@@ -345,8 +342,6 @@ async def websocket_remote_auth_permit(ws: WebSocket):
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Default login action
|
# Default login action
|
||||||
from paskia.authsession import expires
|
|
||||||
from paskia.util import hostutil
|
|
||||||
|
|
||||||
normalized_host = hostutil.normalize_host(request.host)
|
normalized_host = hostutil.normalize_host(request.host)
|
||||||
session_token = db.login(
|
session_token = db.login(
|
||||||
|
|||||||
+3
-3
@@ -1,5 +1,8 @@
|
|||||||
from typing import Generic, TypeVar
|
from typing import Generic, TypeVar
|
||||||
|
|
||||||
|
from paskia import db, remoteauth
|
||||||
|
from paskia.bootstrap import bootstrap_if_needed
|
||||||
|
from paskia.db import start_background
|
||||||
from paskia.sansio import Passkey
|
from paskia.sansio import Passkey
|
||||||
|
|
||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
@@ -42,7 +45,6 @@ async def init(
|
|||||||
Set PASKIA_DB environment variable to specify the JSONL database file path.
|
Set PASKIA_DB environment variable to specify the JSONL database file path.
|
||||||
Default: paskia.jsonl
|
Default: paskia.jsonl
|
||||||
"""
|
"""
|
||||||
from . import db, remoteauth
|
|
||||||
|
|
||||||
# Initialize passkey instance with provided parameters
|
# Initialize passkey instance with provided parameters
|
||||||
passkey.instance = Passkey(
|
passkey.instance = Passkey(
|
||||||
@@ -59,12 +61,10 @@ async def init(
|
|||||||
|
|
||||||
if bootstrap:
|
if bootstrap:
|
||||||
# Bootstrap system if needed
|
# Bootstrap system if needed
|
||||||
from .bootstrap import bootstrap_if_needed
|
|
||||||
|
|
||||||
await bootstrap_if_needed()
|
await bootstrap_if_needed()
|
||||||
|
|
||||||
# Start background flush/cleanup task after bootstrap
|
# Start background flush/cleanup task after bootstrap
|
||||||
from .db import start_background
|
|
||||||
|
|
||||||
await start_background()
|
await start_background()
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ Or via the CLI entry point (if installed):
|
|||||||
paskia-migrate --sql sqlite+aiosqlite:///paskia.sqlite --json paskia.jsonl
|
paskia-migrate --sql sqlite+aiosqlite:///paskia.sqlite --json paskia.jsonl
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import re
|
import re
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -257,7 +258,6 @@ async def migrate_from_sql(
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""CLI entry point for migration."""
|
"""CLI entry point for migration."""
|
||||||
import argparse
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Migrate Paskia database from SQL to JSON"
|
description="Migrate Paskia database from SQL to JSON"
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from typing import Callable
|
from typing import Callable
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from paskia.util import passphrase
|
from paskia.util import passphrase, pow
|
||||||
|
|
||||||
# Remote auth requests expire after this duration
|
# Remote auth requests expire after this duration
|
||||||
REMOTE_AUTH_LIFETIME = timedelta(minutes=5)
|
REMOTE_AUTH_LIFETIME = timedelta(minutes=5)
|
||||||
@@ -319,7 +319,6 @@ class RemoteAuthManager:
|
|||||||
Returns:
|
Returns:
|
||||||
PoW work units (pow.NORMAL or pow.HARD)
|
PoW work units (pow.NORMAL or pow.HARD)
|
||||||
"""
|
"""
|
||||||
from paskia.util import pow
|
|
||||||
|
|
||||||
count = self.get_connection_count()
|
count = self.get_connection_count()
|
||||||
return pow.HARD if count >= 10 else pow.NORMAL
|
return pow.HARD if count >= 10 else pow.NORMAL
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlparse, urlsplit
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
@lru_cache(maxsize=1)
|
||||||
@@ -24,7 +24,6 @@ def dedicated_auth_host() -> str | None:
|
|||||||
auth_host = _load_config().get("auth_host")
|
auth_host = _load_config().get("auth_host")
|
||||||
if not auth_host:
|
if not auth_host:
|
||||||
return None
|
return None
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
parsed = urlparse(auth_host if "://" in auth_host else f"//{auth_host}")
|
parsed = urlparse(auth_host if "://" in auth_host else f"//{auth_host}")
|
||||||
return parsed.netloc or parsed.path or None
|
return parsed.netloc or parsed.path or None
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ but doesn't provide server-side fetching of HTML content.
|
|||||||
import asyncio
|
import asyncio
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
|
from importlib import resources
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -24,7 +25,6 @@ def _get_dev_server() -> str | None:
|
|||||||
|
|
||||||
def _resolve_static_dir() -> Path:
|
def _resolve_static_dir() -> Path:
|
||||||
"""Resolve the static files directory."""
|
"""Resolve the static files directory."""
|
||||||
from importlib import resources
|
|
||||||
|
|
||||||
# Try packaged path via importlib.resources (works for wheel/installed).
|
# Try packaged path via importlib.resources (works for wheel/installed).
|
||||||
try: # pragma: no cover - trivial path resolution
|
try: # pragma: no cover - trivial path resolution
|
||||||
|
|||||||
+1
-1
@@ -79,7 +79,7 @@ target-version = "py39"
|
|||||||
line-length = 88
|
line-length = 88
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = ["E", "F", "I", "N", "W", "UP"]
|
select = ["E", "F", "I", "N", "W", "UP", "PLC0415"]
|
||||||
ignore = ["E501"] # Line too long
|
ignore = ["E501"] # Line too long
|
||||||
isort.known-first-party = ["paskia"]
|
isort.known-first-party = ["paskia"]
|
||||||
|
|
||||||
|
|||||||
+5
-6
@@ -19,8 +19,9 @@ import httpx
|
|||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
|
|
||||||
|
import paskia.db.operations as ops_db
|
||||||
from paskia import globals as paskia_globals
|
from paskia import globals as paskia_globals
|
||||||
from paskia.authsession import expires
|
from paskia.authsession import expires, reset_expires
|
||||||
from paskia.db import (
|
from paskia.db import (
|
||||||
Credential,
|
Credential,
|
||||||
Org,
|
Org,
|
||||||
@@ -36,9 +37,12 @@ from paskia.db import (
|
|||||||
create_session,
|
create_session,
|
||||||
create_user,
|
create_user,
|
||||||
)
|
)
|
||||||
|
from paskia.db.jsonl import JsonlStore
|
||||||
from paskia.db.operations import DB, _create_token
|
from paskia.db.operations import DB, _create_token
|
||||||
|
from paskia.fastapi.mainapp import app
|
||||||
from paskia.fastapi.session import AUTH_COOKIE_NAME
|
from paskia.fastapi.session import AUTH_COOKIE_NAME
|
||||||
from paskia.sansio import Passkey
|
from paskia.sansio import Passkey
|
||||||
|
from paskia.util.passphrase import generate
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
@@ -52,8 +56,6 @@ def event_loop():
|
|||||||
@pytest_asyncio.fixture(scope="function")
|
@pytest_asyncio.fixture(scope="function")
|
||||||
async def test_db() -> AsyncGenerator[DB, None]:
|
async def test_db() -> AsyncGenerator[DB, None]:
|
||||||
"""Create an in-memory JSON database for testing."""
|
"""Create an in-memory JSON database for testing."""
|
||||||
import paskia.db.operations as ops_db
|
|
||||||
from paskia.db.jsonl import JsonlStore
|
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(suffix=".jsonl", delete=True) as f:
|
with tempfile.NamedTemporaryFile(suffix=".jsonl", delete=True) as f:
|
||||||
db = DB()
|
db = DB()
|
||||||
@@ -225,8 +227,6 @@ async def regular_session_token(
|
|||||||
@pytest_asyncio.fixture(scope="function")
|
@pytest_asyncio.fixture(scope="function")
|
||||||
async def reset_token(test_db: DB, test_user: User, test_credential: Credential) -> str:
|
async def reset_token(test_db: DB, test_user: User, test_credential: Credential) -> str:
|
||||||
"""Create a reset token for the test user."""
|
"""Create a reset token for the test user."""
|
||||||
from paskia.authsession import reset_expires
|
|
||||||
from paskia.util.passphrase import generate
|
|
||||||
|
|
||||||
token = generate()
|
token = generate()
|
||||||
create_reset_token(
|
create_reset_token(
|
||||||
@@ -248,7 +248,6 @@ async def client(
|
|||||||
initialized first.
|
initialized first.
|
||||||
"""
|
"""
|
||||||
# Import app after globals are set
|
# Import app after globals are set
|
||||||
from paskia.fastapi.mainapp import app
|
|
||||||
|
|
||||||
transport = httpx.ASGITransport(app=app)
|
transport = httpx.ASGITransport(app=app)
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
|
|||||||
+2
-8
@@ -11,6 +11,7 @@ These tests cover:
|
|||||||
- Credential management
|
- Credential management
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ import pytest
|
|||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
import uuid7
|
import uuid7
|
||||||
|
|
||||||
|
from paskia import db
|
||||||
from paskia.authsession import expires
|
from paskia.authsession import expires
|
||||||
from paskia.db import (
|
from paskia.db import (
|
||||||
Credential,
|
Credential,
|
||||||
@@ -78,7 +80,6 @@ async def second_org_user(test_db: DB, second_org_role: Role) -> User:
|
|||||||
@pytest_asyncio.fixture(scope="function")
|
@pytest_asyncio.fixture(scope="function")
|
||||||
async def second_org_credential(test_db: DB, second_org_user: User) -> Credential:
|
async def second_org_credential(test_db: DB, second_org_user: User) -> Credential:
|
||||||
"""Create a credential for the second org user."""
|
"""Create a credential for the second org user."""
|
||||||
import os
|
|
||||||
|
|
||||||
credential = Credential.create(
|
credential = Credential.create(
|
||||||
credential_id=os.urandom(32),
|
credential_id=os.urandom(32),
|
||||||
@@ -139,7 +140,6 @@ async def org_admin_user(test_db: DB, org_admin_role: Role) -> User:
|
|||||||
@pytest_asyncio.fixture(scope="function")
|
@pytest_asyncio.fixture(scope="function")
|
||||||
async def org_admin_credential(test_db: DB, org_admin_user: User) -> Credential:
|
async def org_admin_credential(test_db: DB, org_admin_user: User) -> Credential:
|
||||||
"""Create a credential for the org admin user."""
|
"""Create a credential for the org admin user."""
|
||||||
import os
|
|
||||||
|
|
||||||
credential = Credential.create(
|
credential = Credential.create(
|
||||||
credential_id=os.urandom(32),
|
credential_id=os.urandom(32),
|
||||||
@@ -1423,7 +1423,6 @@ class TestAdminPermissions:
|
|||||||
):
|
):
|
||||||
"""Cannot rename the auth:admin permission."""
|
"""Cannot rename the auth:admin permission."""
|
||||||
# Get the auth:admin permission
|
# Get the auth:admin permission
|
||||||
from paskia import db
|
|
||||||
|
|
||||||
perms = list(db.data().permissions.values())
|
perms = list(db.data().permissions.values())
|
||||||
admin_perm = next(p for p in perms if p.scope == "auth:admin")
|
admin_perm = next(p for p in perms if p.scope == "auth:admin")
|
||||||
@@ -1478,7 +1477,6 @@ class TestAdminPermissions:
|
|||||||
):
|
):
|
||||||
"""Cannot delete the only auth:admin permission (would lock out admin)."""
|
"""Cannot delete the only auth:admin permission (would lock out admin)."""
|
||||||
# Get the auth:admin permission
|
# Get the auth:admin permission
|
||||||
from paskia import db
|
|
||||||
|
|
||||||
perms = list(db.data().permissions.values())
|
perms = list(db.data().permissions.values())
|
||||||
admin_perm = next(p for p in perms if p.scope == "auth:admin")
|
admin_perm = next(p for p in perms if p.scope == "auth:admin")
|
||||||
@@ -1496,14 +1494,12 @@ class TestAdminPermissions:
|
|||||||
self, client: httpx.AsyncClient, session_token: str, test_db: DB
|
self, client: httpx.AsyncClient, session_token: str, test_db: DB
|
||||||
):
|
):
|
||||||
"""Can delete an auth:admin permission if another accessible one exists."""
|
"""Can delete an auth:admin permission if another accessible one exists."""
|
||||||
from paskia.db import Permission
|
|
||||||
|
|
||||||
# Create a second auth:admin permission (no domain restriction)
|
# Create a second auth:admin permission (no domain restriction)
|
||||||
perm2 = Permission.create(scope="auth:admin", display_name="Secondary Admin")
|
perm2 = Permission.create(scope="auth:admin", display_name="Secondary Admin")
|
||||||
create_permission(perm2)
|
create_permission(perm2)
|
||||||
|
|
||||||
# Get the original auth:admin permission (the one created in setup)
|
# Get the original auth:admin permission (the one created in setup)
|
||||||
from paskia import db
|
|
||||||
|
|
||||||
perms = list(db.data().permissions.values())
|
perms = list(db.data().permissions.values())
|
||||||
admin_perms = [p for p in perms if p.scope == "auth:admin"]
|
admin_perms = [p for p in perms if p.scope == "auth:admin"]
|
||||||
@@ -1524,7 +1520,6 @@ class TestAdminPermissions:
|
|||||||
self, client: httpx.AsyncClient, session_token: str, test_db: DB
|
self, client: httpx.AsyncClient, session_token: str, test_db: DB
|
||||||
):
|
):
|
||||||
"""Cannot delete auth:admin if remaining one has mismatched domain."""
|
"""Cannot delete auth:admin if remaining one has mismatched domain."""
|
||||||
from paskia.db import Permission
|
|
||||||
|
|
||||||
# Create a second auth:admin permission with a different domain
|
# Create a second auth:admin permission with a different domain
|
||||||
perm2 = Permission.create(
|
perm2 = Permission.create(
|
||||||
@@ -1536,7 +1531,6 @@ class TestAdminPermissions:
|
|||||||
|
|
||||||
# Cannot delete the original one because the remaining one is not accessible
|
# Cannot delete the original one because the remaining one is not accessible
|
||||||
# Get the original auth:admin permission
|
# Get the original auth:admin permission
|
||||||
from paskia import db
|
|
||||||
|
|
||||||
perms = list(db.data().permissions.values())
|
perms = list(db.data().permissions.values())
|
||||||
admin_perms = [p for p in perms if p.scope == "auth:admin" and p.domain is None]
|
admin_perms = [p for p in perms if p.scope == "auth:admin" and p.domain is None]
|
||||||
|
|||||||
+4
-7
@@ -10,12 +10,15 @@ These tests cover:
|
|||||||
- /auth/api/set-session - Set session from bearer token
|
- /auth/api/set-session - Set session from bearer token
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from paskia.authsession import EXPIRES
|
||||||
from paskia.db import create_session, delete_session
|
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
|
from tests.conftest import auth_headers
|
||||||
|
|
||||||
|
|
||||||
@@ -410,7 +413,6 @@ class TestTokenInfoEndpoint:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_token_info_with_nonexistent_token(self, client: httpx.AsyncClient):
|
async def test_token_info_with_nonexistent_token(self, client: httpx.AsyncClient):
|
||||||
"""Token info with well-formed but non-existent token should return 401."""
|
"""Token info with well-formed but non-existent token should return 401."""
|
||||||
from paskia.util.passphrase import generate
|
|
||||||
|
|
||||||
fake_token = generate()
|
fake_token = generate()
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
@@ -499,7 +501,6 @@ class TestValidateSessionRefresh:
|
|||||||
self, client: httpx.AsyncClient, test_db
|
self, client: httpx.AsyncClient, test_db
|
||||||
):
|
):
|
||||||
"""Validate should handle session expiry during refresh attempt."""
|
"""Validate should handle session expiry during refresh attempt."""
|
||||||
from paskia.db.operations import _create_token
|
|
||||||
|
|
||||||
# Create a token but don't create a session for it
|
# Create a token but don't create a session for it
|
||||||
token = _create_token()
|
token = _create_token()
|
||||||
@@ -519,10 +520,6 @@ class TestValidateSessionRefresh:
|
|||||||
test_credential,
|
test_credential,
|
||||||
):
|
):
|
||||||
"""Validate should return 401 if session disappears during refresh."""
|
"""Validate should return 401 if session disappears during refresh."""
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
from paskia.authsession import EXPIRES
|
|
||||||
from paskia.db.operations import _create_token
|
|
||||||
|
|
||||||
# Create a session with an old expiry time to trigger refresh
|
# Create a session with an old expiry time to trigger refresh
|
||||||
token = _create_token()
|
token = _create_token()
|
||||||
|
|||||||
Reference in New Issue
Block a user