Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 61 additions & 35 deletions src/authsome/audit/__init__.py
Original file line number Diff line number Diff line change
@@ -1,57 +1,83 @@
"""Audit logging for Authsome operations."""
"""Structured server-side event logging helpers."""

from __future__ import annotations

import json
import threading
import uuid
from datetime import datetime
from pathlib import Path
from typing import Any

from loguru import logger
from pydantic import BaseModel, Field

from authsome.utils import utc_now


class AuditLogger:
"""Append-only structured audit logger."""
class AuditEvent(BaseModel):
"""Structured server-side event record."""

event_id: str = Field(default_factory=lambda: f"audit_{uuid.uuid4().hex}")
timestamp: datetime = Field(default_factory=utc_now)
event: str
provider: str | None = None
connection: str | None = None
identity: str | None = None
status: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)

def __init__(self, filepath: Path) -> None:
self.filepath = filepath

def log(self, event_type: str, **kwargs: Any) -> None:
"""Write an event to the audit log."""
_log_path: Path | None = None
_lock = threading.Lock()

# Ensure directory exists
if not self.filepath.parent.exists():
try:
self.filepath.parent.mkdir(parents=True, exist_ok=True)
except Exception as e:
logger.error("Failed to create audit log directory {}: {}", self.filepath.parent, e)
return

# Filter out None values to keep the log clean
filtered_kwargs = {k: v for k, v in kwargs.items() if v is not None}
def _build_event(event_type: str, **kwargs: Any) -> AuditEvent:
filtered_kwargs = {k: v for k, v in kwargs.items() if v is not None}
return AuditEvent(
event=event_type,
provider=filtered_kwargs.pop("provider", None),
connection=filtered_kwargs.pop("connection", None),
identity=filtered_kwargs.pop("identity", None),
status=filtered_kwargs.pop("status", None),
metadata=filtered_kwargs,
)

entry = {
"timestamp": utc_now().isoformat(),
"event": event_type,
**filtered_kwargs,
}

try:
with open(self.filepath, "a", encoding="utf-8") as f:
f.write(json.dumps(entry) + "\n")
except Exception as e:
logger.error("Failed to write to audit log at {}: {}", self.filepath, e)
def setup(path: Path) -> None:
"""Configure the server-side structured log path."""
global _log_path
path.parent.mkdir(parents=True, exist_ok=True)
if not path.exists():
path.touch()
_log_path = path


_logger_instance: AuditLogger | None = None
def clear() -> None:
"""Clear configured server-side log state."""
global _log_path
_log_path = None


def setup(filepath: Path) -> None:
"""Initialize the global audit logger singleton."""
global _logger_instance
_logger_instance = AuditLogger(filepath)
def _serialize_event(event: AuditEvent) -> str:
payload = event.model_dump(mode="json")
metadata = payload.pop("metadata", {})
if isinstance(metadata, dict):
payload.update(metadata)
return json.dumps(payload, separators=(",", ":"))


def log(event_type: str, **kwargs: Any) -> None:
"""Write an event to the global audit log."""
if _logger_instance is not None:
_logger_instance.log(event_type, **kwargs)
"""Append a structured server event to the configured log file."""
if _log_path is None:
return
line = _serialize_event(_build_event(event_type, **kwargs))
with _lock:
_log_path.parent.mkdir(parents=True, exist_ok=True)
with _log_path.open("a", encoding="utf-8") as handle:
handle.write(line)
handle.write("\n")


async def alog(event_type: str, **kwargs: Any) -> None:
"""Async wrapper around structured server event logging."""
log(event_type, **kwargs)
4 changes: 2 additions & 2 deletions src/authsome/auth/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""auth.models — re-exports all model types used by the auth layer."""

from authsome.auth.models.config import EncryptionConfig, GlobalConfig
from authsome.auth.models.config import EncryptionConfig, ServerConfig
from authsome.auth.models.connection import (
AccountInfo,
ConnectionRecord,
Expand Down Expand Up @@ -32,11 +32,11 @@
"ExportConfig",
"ExportFormat",
"FlowType",
"GlobalConfig",
"OAuthConfig",
"ProviderClientRecord",
"ProviderDefinition",
"ProviderMetadataRecord",
"ProviderStateRecord",
"ServerConfig",
"Sensitive",
]
19 changes: 5 additions & 14 deletions src/authsome/auth/models/config.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
"""Global configuration models."""
"""Authsome configuration models and helpers."""

from __future__ import annotations

from importlib.metadata import PackageNotFoundError, version
from typing import Any

from pydantic import BaseModel, Field

Expand All @@ -24,23 +23,15 @@ def current_spec_version() -> int:


class EncryptionConfig(BaseModel):
"""
Encryption configuration block.

Modes:
- "local_key": master key stored at ~/.authsome/server/master.key
- "keyring": master key stored in the OS keyring
"""
"""Vault encryption backend settings for the daemon."""

mode: str = "local_key"


class GlobalConfig(BaseModel):
"""Daemon configuration for the local Authsome install."""
class ServerConfig(BaseModel):
"""Daemon-owned server configuration."""

spec_version: int = Field(default_factory=current_spec_version)
encryption: EncryptionConfig | None = Field(default_factory=EncryptionConfig)

extra_fields: dict[str, Any] = Field(default_factory=dict, exclude=True)
encryption: EncryptionConfig = Field(default_factory=EncryptionConfig)

model_config = {"extra": "allow"}
8 changes: 4 additions & 4 deletions src/authsome/auth/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,8 +231,8 @@ async def remove_provider(self, name: str) -> bool:
"""Remove a custom provider. Returns True if removed."""
return await self._vault.delete(name, collection="providers")

def _iter_registered_identity_handles(self) -> list[str]:
handles = list_registered_identity_handles(self._vault.home)
async def _iter_registered_identity_handles(self) -> list[str]:
handles = await list_registered_identity_handles(self._vault.home)
return handles or [self._identity]

def _ensure_local_provider_admin_operation_allowed(self, operation: str, provider: str) -> None:
Expand Down Expand Up @@ -700,7 +700,7 @@ async def logout(self, provider: str, connection: str = "default") -> None:
async def revoke(self, provider: str) -> None:
self._ensure_local_provider_admin_operation_allowed("revoke", provider)
await self.get_provider(provider)
for identity in self._iter_registered_identity_handles():
for identity in await self._iter_registered_identity_handles():
identity_service = AuthService(
vault=self._vault,
identity=identity,
Expand Down Expand Up @@ -894,7 +894,7 @@ async def _get_oauth_token(self, record: ConnectionRecord, provider: str, connec
return refreshed.access_token
except RefreshFailedError as exc:
fallback_available = record.expires_at and now < record.expires_at
audit.log(
await audit.alog(
"refresh_failed",
provider=provider,
connection=connection,
Expand Down
33 changes: 24 additions & 9 deletions src/authsome/auth/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,13 @@ def is_expired(self) -> bool:


class AuthSessionStore:
"""In-memory auth session store for the daemon process."""
"""In-memory auth session state for the daemon process."""

def __init__(self) -> None:
self._sessions: dict[str, AuthSession] = {}
self._state_index: dict[str, str] = {}

def create(
async def create(
self,
*,
provider: str,
Expand All @@ -77,37 +77,52 @@ def create(
self._sessions[session.session_id] = session
return session

def get(self, session_id: str) -> AuthSession:
async def get(self, session_id: str) -> AuthSession:
self.cleanup_expired()
session = self._sessions.get(session_id)
if session is None:
raise KeyError(f"Session not found: {session_id}")
if session.is_expired:
self.delete(session_id)
session.state = AuthSessionStatus.EXPIRED
await self.delete(session_id)
raise KeyError(f"Session expired: {session_id}")
return session

def delete(self, session_id: str) -> None:
async def save(self, session: AuthSession) -> None:
session.updated_at = utc_now()
self._sessions[session.session_id] = session
oauth_state = session.payload.get("internal_state")
if oauth_state:
self._state_index[str(oauth_state)] = session.session_id

async def delete(self, session_id: str) -> None:
session = self._sessions.pop(session_id, None)
if session:
oauth_state = session.payload.get("internal_state")
if oauth_state:
self._state_index.pop(str(oauth_state), None)

def index_oauth_state(self, session: AuthSession) -> None:
async def index_oauth_state(self, session: AuthSession) -> None:
oauth_state = session.payload.get("internal_state")
if oauth_state:
self._state_index[str(oauth_state)] = session.session_id
await self.save(session)

def get_by_oauth_state(self, state: str) -> AuthSession:
async def get_by_oauth_state(self, state: str) -> AuthSession:
self.cleanup_expired()
session_id = self._state_index.get(state)
if session_id is None:
raise KeyError(f"Session not found for OAuth state: {state}")
return self.get(session_id)
return await self.get(session_id)

def cleanup_expired(self) -> None:
expired = [session_id for session_id, session in self._sessions.items() if session.is_expired]
for session_id in expired:
self.delete(session_id)
session = self._sessions.get(session_id)
if session is not None:
session.state = AuthSessionStatus.EXPIRED
self._sessions.pop(session_id, None)
if session is not None:
oauth_state = session.payload.get("internal_state")
if oauth_state:
self._state_index.pop(str(oauth_state), None)
Original file line number Diff line number Diff line change
@@ -1,21 +1,26 @@
"""Client-side identity selection config."""
"""Caller-local CLI config helpers."""

from __future__ import annotations

from pathlib import Path

from pydantic import BaseModel

from authsome import __version__
from authsome.paths import get_client_home


class ClientConfig(BaseModel):
"""Caller-local config that should not live in daemon-owned storage."""

version: str = __version__
active_identity: str | None = None
proxy_ca_installed: bool = False


def client_config_path(home: Path) -> Path:
"""Return the caller-local config file path."""
return home / "config.json"
return get_client_home(home) / "config.json"


def load_client_config(home: Path) -> ClientConfig:
Expand All @@ -31,5 +36,6 @@ def load_client_config(home: Path) -> ClientConfig:

def save_client_config(home: Path, config: ClientConfig) -> None:
"""Persist caller-local config."""
home.mkdir(parents=True, exist_ok=True)
client_config_path(home).write_text(config.model_dump_json(indent=2), encoding="utf-8")
path = client_config_path(home)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(config.model_dump_json(indent=2), encoding="utf-8")
4 changes: 1 addition & 3 deletions src/authsome/cli/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@

import click

from authsome import audit
from authsome.cli.client import AuthsomeApiClient
from authsome.cli.daemon_control import resolve_runtime_client
from authsome.proxy.runner import ProxyRunner
Expand All @@ -25,7 +24,7 @@ async def doctor(self) -> dict[str, Any]:
return await self.runtime_client.doctor()

def require_local_proxy(self) -> ProxyRunner:
return ProxyRunner(client=self.runtime_client)
return ProxyRunner(client=self.runtime_client, home=self.home)


class ContextObj:
Expand All @@ -40,7 +39,6 @@ def __init__(self, json_output: bool, quiet: bool, no_color: bool):
async def initialize(self) -> CliRuntime:
if self._ctx is None:
self._ctx = CliRuntime(await resolve_runtime_client())
audit.setup(self._ctx.home / "audit.log")
return self._ctx

def print_json(self, data: Any) -> None:
Expand Down
6 changes: 3 additions & 3 deletions src/authsome/cli/daemon_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
import subprocess
import sys
import time
from pathlib import Path
from typing import Any

from authsome.cli.client import (
Expand All @@ -18,10 +17,11 @@
is_managed_local_daemon_url,
resolve_daemon_url,
)
from authsome.paths import get_authsome_home, get_server_home
from authsome.server.daemon import DEFAULT_HOST, DEFAULT_PORT

AUTHSOME_HOME = Path(os.environ.get("AUTHSOME_HOME", str(Path.home() / ".authsome")))
DAEMON_DIR = AUTHSOME_HOME / "server" / "daemon"
AUTHSOME_HOME = get_authsome_home()
DAEMON_DIR = get_server_home(AUTHSOME_HOME) / "daemon"
PID_FILE = DAEMON_DIR / "daemon.pid"
LOG_FILE = DAEMON_DIR / "daemon.log"
STATE_FILE = DAEMON_DIR / "daemon.json"
Expand Down
Loading
Loading