-
Notifications
You must be signed in to change notification settings - Fork 0
Add refresh token rate limit and remove outdated audit docs #166
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
18f7552
8d0e80b
66b12fb
fff77e7
cb44195
69c1b70
d7a5e26
c6d240a
241f0eb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,13 +15,16 @@ | |
|
|
||
| from liminallm.api.error_handling import register_exception_handlers | ||
| from liminallm.api.routes import get_admin_user, router | ||
| from liminallm.config import Settings | ||
| from liminallm.logging import get_logger, set_correlation_id | ||
|
|
||
| logger = get_logger(__name__) | ||
|
|
||
| _settings = Settings.from_env() | ||
|
|
||
| # Version info per SPEC §18 | ||
| __version__ = "0.1.0" | ||
| __build__ = os.getenv("BUILD_SHA", "dev") | ||
| __build__ = _settings.build_sha | ||
|
|
||
|
|
||
| _cleanup_task: asyncio.Task | None = None | ||
|
|
@@ -77,9 +80,8 @@ async def lifespan(app: FastAPI): | |
|
|
||
|
|
||
| def _allowed_origins() -> List[str]: | ||
| env_value = os.getenv("CORS_ALLOW_ORIGINS") | ||
| if env_value: | ||
| return [origin.strip() for origin in env_value.split(",") if origin.strip()] | ||
| if _settings.cors_allow_origins: | ||
| return _settings.cors_allow_origins | ||
| # Default to common local dev hosts; avoid wildcard when credentials are enabled. | ||
| return [ | ||
| "http://localhost", | ||
|
|
@@ -91,10 +93,7 @@ def _allowed_origins() -> List[str]: | |
|
|
||
|
|
||
| def _allow_credentials() -> bool: | ||
| flag = os.getenv("CORS_ALLOW_CREDENTIALS") | ||
| if flag is None: | ||
| return False | ||
| return flag.lower() in {"1", "true", "yes", "on"} | ||
| return _settings.cors_allow_credentials | ||
|
|
||
|
|
||
| app.add_middleware( | ||
|
|
@@ -199,12 +198,7 @@ async def add_security_headers(request, call_next): | |
| response.headers.setdefault( | ||
| "Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()" | ||
| ) | ||
| if request.url.scheme == "https" and os.getenv("ENABLE_HSTS", "false").lower() in { | ||
| "1", | ||
| "true", | ||
| "yes", | ||
| "on", | ||
| }: | ||
| if request.url.scheme == "https" and _settings.enable_hsts: | ||
| response.headers.setdefault( | ||
| "Strict-Transport-Security", "max-age=63072000; includeSubDomains" | ||
| ) | ||
|
|
@@ -432,6 +426,38 @@ async def metrics() -> Response: | |
| lines.append('# TYPE liminallm_database_healthy gauge') | ||
| lines.append(f'liminallm_database_healthy {db_healthy}') | ||
|
|
||
| # Training job activity | ||
| list_jobs = getattr(runtime.store, "list_training_jobs", None) | ||
| if callable(list_jobs): | ||
| try: | ||
| jobs = list_jobs() | ||
| active = len([j for j in jobs if j.status in {"queued", "running"}]) | ||
| lines.append('# HELP liminallm_training_jobs_active Active training jobs') | ||
| lines.append('# TYPE liminallm_training_jobs_active gauge') | ||
| lines.append(f'liminallm_training_jobs_active {active}') | ||
| except Exception as exc: | ||
| logger.warning("metrics_training_jobs_failed", error=str(exc)) | ||
|
|
||
| # Preference event ingestion rate proxy | ||
| if hasattr(runtime.store, "list_preference_events"): | ||
| try: | ||
| events = runtime.store.list_preference_events(user_id=None) # type: ignore[arg-type] | ||
| lines.append('# HELP liminallm_preference_events_total Total recorded preference events') | ||
| lines.append('# TYPE liminallm_preference_events_total counter') | ||
| lines.append(f'liminallm_preference_events_total {len(events)}') | ||
| except Exception as exc: | ||
| logger.warning("metrics_preference_events_failed", error=str(exc)) | ||
|
|
||
| # Adapter usage counts | ||
| if hasattr(runtime.store, "list_artifacts"): | ||
| try: | ||
| adapters = runtime.store.list_artifacts(kind="adapter", owner_user_id=None) # type: ignore[arg-type] | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: Wrong parameter name in metrics adapter count queryThe call |
||
| lines.append('# HELP liminallm_adapters_total Adapters stored in system') | ||
| lines.append('# TYPE liminallm_adapters_total gauge') | ||
| lines.append(f'liminallm_adapters_total {len(adapters)}') | ||
| except Exception as exc: | ||
| logger.warning("metrics_adapters_failed", error=str(exc)) | ||
|
|
||
| except Exception as exc: | ||
| logger.error("metrics_collection_failed", error=str(exc)) | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,7 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import base64 | ||
| import contextlib | ||
| import hashlib | ||
| import hmac | ||
| import json | ||
|
|
@@ -130,6 +131,7 @@ def __init__( | |
| # Issue 28.4: Thread-safe lock for mutable state dictionaries | ||
| # Protects all in-memory fallback state from concurrent access | ||
| import threading | ||
|
|
||
| self._state_lock = threading.Lock() | ||
| self._mfa_challenges: dict[str, tuple[str, datetime]] = {} | ||
| # Issue 11.1: In-memory fallback for MFA lockout when Redis unavailable | ||
|
|
@@ -153,6 +155,13 @@ def _now(self) -> datetime: | |
|
|
||
| return datetime.now(timezone.utc) | ||
|
|
||
| @contextlib.contextmanager | ||
| def _with_state_lock(self): | ||
| """Context manager for thread-safe state dictionary access (Issue 28.4).""" | ||
|
|
||
| with self._state_lock: | ||
| yield | ||
|
|
||
| def cleanup_expired_states(self) -> int: | ||
| """Clean up expired OAuth states, MFA challenges, and email verification tokens. | ||
|
|
||
|
|
@@ -406,7 +415,8 @@ async def start_oauth( | |
| expires_at = self._now() + timedelta(minutes=10) | ||
| # Issue 28.4: Thread-safe state mutation | ||
| with self._state_lock: | ||
| self._oauth_states[state] = (provider, expires_at, tenant_id) | ||
| with self._with_state_lock(): | ||
| self._oauth_states[state] = (provider, expires_at, tenant_id) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: Double lock acquisition causes deadlockThe code uses Additional Locations (2) |
||
| if self.cache: | ||
| await self.cache.set_oauth_state(state, provider, expires_at, tenant_id) | ||
|
|
||
|
|
@@ -644,14 +654,17 @@ async def complete_oauth( | |
| # Issue 28.4: Thread-safe state mutation | ||
| with self._state_lock: | ||
| if stored is None: | ||
| stored = self._oauth_states.pop(state, None) | ||
| with self._with_state_lock(): | ||
| stored = self._oauth_states.pop(state, None) | ||
| else: | ||
| self._oauth_states.pop(state, None) | ||
| with self._with_state_lock(): | ||
| self._oauth_states.pop(state, None) | ||
| now = self._now() | ||
|
|
||
| async def _clear_oauth_state() -> None: | ||
| with self._state_lock: | ||
| self._oauth_states.pop(state, None) | ||
| with self._with_state_lock(): | ||
| self._oauth_states.pop(state, None) | ||
| if self.cache and not cache_state_used: | ||
| await self.cache.pop_oauth_state(state) | ||
|
|
||
|
|
@@ -1201,7 +1214,8 @@ async def initiate_password_reset(self, email: str) -> str: | |
| else: | ||
| # Issue 11.2: In-memory fallback for password reset tokens | ||
| with self._state_lock: | ||
| self._password_reset_tokens[token] = (email, expires_at) | ||
| with self._with_state_lock(): | ||
| self._password_reset_tokens[token] = (email, expires_at) | ||
| self.logger.info( | ||
| "password_reset_requested", | ||
| email_hash=hashlib.sha256(email.encode()).hexdigest(), | ||
|
|
@@ -1215,15 +1229,18 @@ async def complete_password_reset(self, token: str, new_password: str) -> bool: | |
| else: | ||
| # Issue 11.2: In-memory fallback for password reset tokens | ||
| with self._state_lock: | ||
| stored = self._password_reset_tokens.get(token) | ||
| with self._with_state_lock(): | ||
| stored = self._password_reset_tokens.get(token) | ||
| if stored: | ||
| stored_email, expires_at = stored | ||
| if expires_at <= self._now() - self._clock_skew_leeway: | ||
| # Remove expired token to prevent memory leak | ||
| self._password_reset_tokens.pop(token, None) | ||
| with self._with_state_lock(): | ||
| self._password_reset_tokens.pop(token, None) | ||
| else: | ||
| email = stored_email | ||
| self._password_reset_tokens.pop(token, None) | ||
| with self._with_state_lock(): | ||
| self._password_reset_tokens.pop(token, None) | ||
| if not email: | ||
| self.logger.warning("password_reset_invalid_token", token_prefix=token[:8]) | ||
| return False | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.