Fix actor fields and transactions for API operations as they are recorded to DB.

This commit is contained in:
2026-01-23 20:19:33 +00:00
parent c2933d60c2
commit ae4c982a30
7 changed files with 91 additions and 70 deletions
-29
View File
@@ -31,35 +31,6 @@ def reset_expires() -> datetime:
return datetime.now(timezone.utc) + RESET_LIFETIME
async def create_session(
user_uuid: UUID,
credential_uuid: UUID,
*,
host: str,
ip: str,
user_agent: str,
) -> str:
"""Create a new session and return a session token."""
normalized_host = hostutil.normalize_host(host)
if not normalized_host:
raise ValueError("Host required for session creation")
hostname = normalized_host.split(":")[0] # Domain names only, IPs aren't supported
rp_id = passkey.instance.rp_id
if not (hostname == rp_id or hostname.endswith(f".{rp_id}")):
raise ValueError(f"Host must be the same as or a subdomain of {rp_id}")
token = create_token()
db.create_session(
user_uuid=user_uuid,
credential_uuid=credential_uuid,
key=session_key(token),
host=normalized_host,
ip=ip,
user_agent=user_agent,
expiry=expires(),
)
return token
async def get_reset(token: str) -> ResetToken:
"""Validate a credential reset token."""
record = db.get_reset_token(reset_key(token))
+47 -3
View File
@@ -530,7 +530,10 @@ def delete_permission(uuid: str | UUID, actor: str = "system") -> None:
def create_organization(org: Org, actor: str = "system") -> None:
"""Create a new organization."""
"""Create a new organization with an Administration role.
Automatically creates an 'Administration' role with auth:org:admin permission.
"""
if org.uuid in _db._data.orgs:
raise ValueError(f"Organization {org.uuid} already exists")
with _db.transaction(actor):
@@ -542,6 +545,15 @@ def create_organization(org: Org, actor: str = "system") -> None:
for pid, p in _db._data.permissions.items():
if p.scope == scope:
p.orgs[org.uuid] = True
# Create Administration role with org admin permission
import uuid7
admin_role_uuid = uuid7.create()
_db._data.roles[admin_role_uuid] = _RoleData(
org=org.uuid,
display_name="Administration",
permissions={"auth:org:admin": True},
)
def update_organization_name(
@@ -939,8 +951,25 @@ def cleanup_expired(actor: str = "system") -> int:
# -------------------------------------------------------------------------
def login(user_uuid: str | UUID, credential: Credential, actor: str = "system") -> None:
"""Update user last_seen and credential sign_count/last_used on login."""
def login(
user_uuid: str | UUID,
credential: Credential,
session_key: bytes,
host: str | None,
ip: str | None,
user_agent: str | None,
expiry: datetime,
) -> None:
"""Update user/credential on login and create session in a single transaction.
Updates:
- user.last_seen, user.visits
- credential.sign_count, credential.last_used
Creates:
- new session
Actor is set to the user UUID being logged in.
"""
if isinstance(user_uuid, str):
user_uuid = UUID(user_uuid)
now = datetime.now(timezone.utc)
@@ -948,11 +977,26 @@ def login(user_uuid: str | UUID, credential: Credential, actor: str = "system")
raise ValueError(f"User {user_uuid} not found")
if credential.uuid not in _db._data.credentials:
raise ValueError(f"Credential {credential.uuid} not found")
if session_key in _db._data.sessions:
raise ValueError("Session already exists")
actor = str(user_uuid)
with _db.transaction(actor):
# Update user
_db._data.users[user_uuid].last_seen = now
_db._data.users[user_uuid].visits += 1
# Update credential
_db._data.credentials[credential.uuid].sign_count = credential.sign_count
_db._data.credentials[credential.uuid].last_used = now
# Create session
_db._data.sessions[session_key] = _SessionData(
user=user_uuid,
credential=credential.uuid,
host=host,
ip=ip,
user_agent=user_agent,
expiry=expiry,
)
def create_credential_session(
-12
View File
@@ -136,7 +136,6 @@ async def admin_create_org(
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
)
from ..db import Org as OrgDC # local import to avoid cycles
from ..db import Role as RoleDC # local import to avoid cycles
actor = str(ctx.user.uuid)
org_uuid = uuid4()
@@ -145,17 +144,6 @@ async def admin_create_org(
org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions)
db.create_organization(org, actor=actor)
# Automatically create Administration role with org admin permission
# The auth:org:admin permission is automatically created/enabled by create_organization
role_uuid = uuid4()
admin_role = RoleDC(
uuid=role_uuid,
org_uuid=org_uuid,
display_name="Administration",
permissions=["auth:org:admin"],
)
db.create_role(admin_role, actor=actor)
return {"uuid": str(org_uuid)}
+2 -2
View File
@@ -289,11 +289,11 @@ async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE):
if not auth:
return {"message": "Already logged out"}
try:
await get_session(auth, host=request.headers.get("host"))
s = await get_session(auth, host=request.headers.get("host"))
except ValueError:
return {"message": "Already logged out"}
with suppress(Exception):
db.delete_session(session_key(auth))
db.delete_session(session_key(auth), actor=str(s.user_uuid))
session.clear_session_cookie(response)
return {"message": "Logged out successfully"}
+20 -13
View File
@@ -16,7 +16,6 @@ import base64url
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from paskia import db, remoteauth
from paskia.authsession import create_session
from paskia.fastapi.session import infodict
from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
from paskia.globals import passkey
@@ -334,9 +333,6 @@ async def websocket_remote_auth_permit(ws: WebSocket):
credential, webauthn_challenge, stored_cred, origin
)
# Update credential last_used
db.login(stored_cred.user_uuid, stored_cred)
# Create a session for the REQUESTING device
assert stored_cred.uuid is not None
@@ -346,7 +342,7 @@ async def websocket_remote_auth_permit(ws: WebSocket):
if request.action == "register":
# For registration, create a reset token for device addition
from paskia.authsession import expires
from paskia.util import tokens
from paskia.util import hostutil, tokens
token_str = passphrase.generate()
expiry = expires()
@@ -355,25 +351,36 @@ async def websocket_remote_auth_permit(ws: WebSocket):
key=tokens.reset_key(token_str),
expiry=expiry,
token_type="device addition",
actor=str(stored_cred.user_uuid),
)
reset_token = token_str
# Also create a session so the device is logged in?
# User requested: "We can make the flow always create a new session, but make additional tokens for other possibilities."
session_token = await create_session(
# Also create a session so the device is logged in
session_token = passphrase.generate()
normalized_host = hostutil.normalize_host(request.host)
db.login(
user_uuid=stored_cred.user_uuid,
credential_uuid=stored_cred.uuid,
host=request.host,
credential=stored_cred,
session_key=tokens.session_key(session_token),
host=normalized_host,
ip=request.ip,
user_agent=request.user_agent,
expiry=expires(),
)
else:
# Default login action
session_token = await create_session(
from paskia.authsession import expires
from paskia.util import hostutil, tokens
session_token = passphrase.generate()
normalized_host = hostutil.normalize_host(request.host)
db.login(
user_uuid=stored_cred.user_uuid,
credential_uuid=stored_cred.uuid,
host=request.host,
credential=stored_cred,
session_key=tokens.session_key(session_token),
host=normalized_host,
ip=request.ip,
user_agent=request.user_agent,
expiry=expires(),
)
# Complete the remote auth request (notifies the waiting device)
+4 -3
View File
@@ -55,7 +55,7 @@ async def user_update_display_name(
raise HTTPException(status_code=400, detail="display_name required")
if len(new_name) > 64:
raise HTTPException(status_code=400, detail="display_name too long")
db.update_user_display_name(s.user_uuid, new_name)
db.update_user_display_name(s.user_uuid, new_name, actor=str(s.user_uuid))
return {"status": "ok"}
@@ -69,7 +69,7 @@ async def api_logout_all(request: Request, response: Response, auth=AUTH_COOKIE)
raise authz.AuthException(
status_code=401, detail="Session expired", mode="login"
)
db.delete_sessions_for_user(s.user_uuid)
db.delete_sessions_for_user(s.user_uuid, actor=str(s.user_uuid))
session.clear_session_cookie(response)
return {"message": "Logged out from all hosts"}
@@ -103,7 +103,7 @@ async def api_delete_session(
if not target_session or target_session.user_uuid != current_session.user_uuid:
raise HTTPException(status_code=404, detail="Session not found")
db.delete_session(target_key)
db.delete_session(target_key, actor=str(current_session.user_uuid))
current_terminated = target_key == session_key(auth)
if current_terminated:
session.clear_session_cookie(response) # explicit because 200
@@ -149,6 +149,7 @@ async def api_create_link(
key=tokens.reset_key(token),
expiry=expiry,
token_type="device addition",
actor=str(s.user_uuid),
)
url = hostutil.reset_link_url(token)
return {
+18 -8
View File
@@ -3,12 +3,12 @@ from uuid import UUID
from fastapi import FastAPI, WebSocket
from paskia import db
from paskia.authsession import create_session, get_reset, get_session
from paskia.authsession import expires, get_reset, get_session
from paskia.fastapi import authz, remote
from paskia.fastapi.session import AUTH_COOKIE, infodict
from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
from paskia.globals import passkey
from paskia.util import passphrase
from paskia.util import hostutil, passphrase
from paskia.util.tokens import create_token, session_key
# Create a FastAPI subapp for WebSocket endpoints
@@ -89,6 +89,7 @@ async def websocket_register_add(
host=host,
ip=metadata.get("ip"),
user_agent=metadata.get("user_agent"),
actor=str(user_uuid),
)
auth = token
@@ -140,18 +141,27 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
# Verify the credential matches the stored data
passkey.instance.auth_verify(credential, challenge, stored_cred, origin)
# Update both credential and user's last_seen timestamp
db.login(stored_cred.user_uuid, stored_cred)
# Create a session token for the authenticated user
# Create session and update user/credential in a single transaction
assert stored_cred.uuid is not None
metadata = infodict(ws, "auth")
token = await create_session(
token = create_token()
normalized_host = hostutil.normalize_host(host)
if not normalized_host:
raise ValueError("Host required for session creation")
hostname = normalized_host.split(":")[0]
rp_id = passkey.instance.rp_id
if not (hostname == rp_id or hostname.endswith(f".{rp_id}")):
raise ValueError(f"Host must be the same as or a subdomain of {rp_id}")
db.login(
user_uuid=stored_cred.user_uuid,
credential_uuid=stored_cred.uuid,
host=host,
credential=stored_cred,
session_key=session_key(token),
host=normalized_host,
ip=metadata.get("ip") or "",
user_agent=metadata.get("user_agent") or "",
expiry=expires(),
)
await ws.send_json(