diff --git a/.github/workflows/app-ci.yml b/.github/workflows/app-ci.yml index b85cd9fb5..1f75f4241 100644 --- a/.github/workflows/app-ci.yml +++ b/.github/workflows/app-ci.yml @@ -65,8 +65,18 @@ jobs: - name: Run frontend lint run: cd frontend && npm run lint + - name: Cache Next.js build + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: frontend/.next/cache + key: ${{ runner.os }}-nextjs-${{ hashFiles('frontend/package-lock.json') }}-${{ hashFiles('frontend/**/*.js', 'frontend/**/*.jsx', 'frontend/**/*.ts', 'frontend/**/*.tsx') }} + restore-keys: | + ${{ runner.os }}-nextjs-${{ hashFiles('frontend/package-lock.json') }}- + - name: Build frontend env: NEXT_TELEMETRY_DISABLED: "1" NODE_OPTIONS: "--max-old-space-size=4096" - run: cd frontend && npm run build + run: | + rm -f ~/package-lock.json + cd frontend && npm run build diff --git a/.github/workflows/strix.yml b/.github/workflows/strix.yml index de3fd7370..f25133823 100644 --- a/.github/workflows/strix.yml +++ b/.github/workflows/strix.yml @@ -81,17 +81,17 @@ jobs: - name: Gate Strix secrets id: gate env: - STRIX_GITHUB_MODELS_MODEL: ${{ secrets.STRIX_LLM || 'openai/gpt-5' }} + STRIX_GITHUB_MODELS_MODEL: ${{ secrets.STRIX_LLM || 'openai/gpt-5.4' }} STRIX_OPENAI_API_KEY: ${{ secrets.STRIX_OPENAI_API_KEY }} run: | strix_model="$(printf '%s' "$STRIX_GITHUB_MODELS_MODEL" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" case "$strix_model" in - openai/gpt-5 | openai/gpt-5-* | openai/gpt-5.[0-9]* | openai/gpt-[6-9]* | openai/gpt-[1-9][0-9]* | \ - openai/openai/gpt-5 | openai/openai/gpt-5-* | openai/openai/gpt-5.[0-9]* | openai/openai/gpt-[6-9]* | openai/openai/gpt-[1-9][0-9]*) + openai/gpt-5.[4-9]* | openai/gpt-5.[1-9][0-9]* | openai/gpt-[6-9]* | openai/gpt-[1-9][0-9]* | \ + openai/openai/gpt-5.[4-9]* | openai/openai/gpt-5.[1-9][0-9]* | openai/openai/gpt-[6-9]* | openai/openai/gpt-[1-9][0-9]*) echo 'enabled=true' >> "$GITHUB_OUTPUT" ;; *) - echo '::error::STRIX_LLM must select an OpenAI GPT-5 or newer model, for example openai/gpt-5.' + echo '::error::STRIX_LLM must select an OpenAI GPT-5.4 or newer model, for example openai/gpt-5.4.' exit 1 ;; esac @@ -118,7 +118,7 @@ jobs: id: auth_gate if: steps.gate.outputs.enabled == 'true' env: - STRIX_GITHUB_MODELS_MODEL: ${{ secrets.STRIX_LLM || 'openai/gpt-5' }} + STRIX_GITHUB_MODELS_MODEL: ${{ secrets.STRIX_LLM || 'openai/gpt-5.4' }} DEFAULT_PROVIDER: openai run: | . "$TRUSTED_WORKSPACE/scripts/ci/strix_model_utils.sh" @@ -228,7 +228,7 @@ jobs: - name: Prepare Strix model input file if: steps.gate.outputs.enabled == 'true' && steps.auth_gate.outputs.can_run == 'true' env: - STRIX_GITHUB_MODELS_MODEL: ${{ secrets.STRIX_LLM || 'openai/gpt-5' }} + STRIX_GITHUB_MODELS_MODEL: ${{ secrets.STRIX_LLM || 'openai/gpt-5.4' }} run: | umask 077 strix_llm_file="$RUNNER_TEMP/strix_llm.txt" @@ -242,20 +242,20 @@ jobs: printf '%s' "$strix_model" > "$strix_llm_file" ;; *) - echo '::error::STRIX_LLM must select an OpenAI GPT-5 or newer model, for example openai/gpt-5.' + echo '::error::STRIX_LLM must select an OpenAI GPT-5.4 or newer model, for example openai/gpt-5.4.' exit 1 ;; esac else case "$strix_model" in - openai/gpt-5 | openai/gpt-5-* | openai/gpt-5.[0-9]* | openai/gpt-[6-9]* | openai/gpt-[1-9][0-9]*) + openai/gpt-5.[4-9]* | openai/gpt-5.[1-9][0-9]* | openai/gpt-[6-9]* | openai/gpt-[1-9][0-9]*) printf 'openai/%s' "$strix_model" > "$strix_llm_file" ;; - openai/openai/gpt-5 | openai/openai/gpt-5-* | openai/openai/gpt-5.[0-9]* | openai/openai/gpt-[6-9]* | openai/openai/gpt-[1-9][0-9]*) + openai/openai/gpt-5.[4-9]* | openai/openai/gpt-5.[1-9][0-9]* | openai/openai/gpt-[6-9]* | openai/openai/gpt-[1-9][0-9]*) printf '%s' "$strix_model" > "$strix_llm_file" ;; *) - echo '::error::STRIX_LLM must select a GitHub Models OpenAI GPT-5 or newer model, for example openai/gpt-5.' + echo '::error::STRIX_LLM must select a GitHub Models OpenAI GPT-5.4 or newer model, for example openai/gpt-5.4.' exit 1 ;; esac diff --git a/AGENTS.md b/AGENTS.md index 253249892..04ebb9e3f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,10 +10,10 @@ explicit `if: ${{ always() }}` upload steps when needed. - Prefer upgrading or removing vulnerable dependencies over downgrading patched packages unless compatibility evidence is recorded in the PR. -- Strix Security Scan must use an OpenAI GPT-5-or-newer model. Prefer GitHub +- Strix Security Scan must use an OpenAI GPT-5.4-or-newer model. Prefer GitHub Models with `models: read`, but verify actual inference availability, not only - catalog presence. If the repo token cannot invoke GitHub Models GPT-5, use - only an explicitly named `STRIX_OPENAI_API_KEY` direct GPT-5 credential rather + catalog presence. If the repo token cannot invoke GitHub Models GPT-5.4, use + only an explicitly named `STRIX_OPENAI_API_KEY` direct GPT-5.4 credential rather than downgrading to Gemini, GPT-4o, or GPT-4.1; record the inference evidence in the PR. Do not repurpose generic `LLM_API_KEY` for direct OpenAI Strix calls, because it may hold a GitHub Models token or PAT. @@ -54,7 +54,10 @@ tests/mocks must exercise the signed-session path. - Private backend `/api/*` routers must be registered with the default `get_auth_context` signed-session dependency; only explicitly documented - public endpoints such as `/api/runtime-config` and `/` may omit it. + public endpoints such as `/` may omit it. Keep runtime feature/configuration + endpoints signed-session protected; if the browser needs unauthenticated + bootstrap data, add a narrowly scoped non-`/api` endpoint that cannot reveal + operational feature flags or provider state. Prometheus `/metrics` must stay disabled by default and, when enabled, sit behind a trusted scrape path or reverse proxy access policy. Admin/provider registry endpoints must enforce role checks in addition to authentication. LLM diff --git a/backend/api/auth.py b/backend/api/auth.py index 76279f50f..5cfc5d719 100644 --- a/backend/api/auth.py +++ b/backend/api/auth.py @@ -3,22 +3,49 @@ import hashlib import hmac import json +import logging import math import time from dataclasses import dataclass from typing import Annotated, Any, Literal, cast +import jwt +from jwt import PyJWKClient from fastapi import Depends, Header, HTTPException from core.config import settings, validate_auth_session_hmac_secret_value -RoleName = Literal["platform_admin", "organization_admin", "group_admin", "member"] +logger = logging.getLogger(__name__) +OIDC_JWKS_TIMEOUT_SECONDS = 5 + +jwks_client = None +if settings.OIDC_JWKS_URL: + jwks_client = PyJWKClient( + settings.OIDC_JWKS_URL, + cache_keys=True, + timeout=OIDC_JWKS_TIMEOUT_SECONDS, + ) +_cached_oidc_signing_keys: tuple[Any, ...] = () + +RoleName = Literal[ + "system_admin", + "tenant_admin", + "platform_admin", + "organization_admin", + "group_admin", + "member", +] ALLOWED_ROLES: set[str] = { + "system_admin", + "tenant_admin", "platform_admin", "organization_admin", "group_admin", "member", } +SYSTEM_ADMIN_ROLES = frozenset({"system_admin", "platform_admin"}) +TENANT_ADMIN_ROLES = frozenset({"tenant_admin", "organization_admin"}) +ADMIN_ROLES = SYSTEM_ADMIN_ROLES | TENANT_ADMIN_ROLES SESSION_ISSUER = "naruon-control-plane" SESSION_AUDIENCE = "naruon-api" SESSION_SIGNING_ALGORITHM = "HS256" @@ -35,7 +62,7 @@ class AuthContext: def ensure_organization_access(auth_context: AuthContext, organization_id: str) -> None: - if auth_context.role == "platform_admin": + if is_system_admin_role(auth_context.role): return if auth_context.organization_id != organization_id: raise HTTPException( @@ -43,6 +70,18 @@ def ensure_organization_access(auth_context: AuthContext, organization_id: str) ) +def is_system_admin_role(role: str) -> bool: + return role in SYSTEM_ADMIN_ROLES + + +def is_tenant_admin_role(role: str) -> bool: + return role in TENANT_ADMIN_ROLES + + +def is_admin_role(role: str) -> bool: + return role in ADMIN_ROLES + + async def get_auth_context( authorization: Annotated[str | None, Header(alias="Authorization")] = None, ) -> AuthContext: @@ -67,6 +106,36 @@ def _authentication_error() -> HTTPException: return HTTPException(status_code=401, detail="Authentication required") +def preload_oidc_jwks() -> None: + """Populate OIDC signing keys outside the request authentication path.""" + global _cached_oidc_signing_keys + if jwks_client is None: + _cached_oidc_signing_keys = () + return + try: + jwk_set = jwks_client.get_jwk_set(refresh=True) + _cached_oidc_signing_keys = tuple(jwk_set.keys) + except Exception: + _cached_oidc_signing_keys = () + logger.exception("OIDC JWKS preload failed; requests will fail closed.") + + +def _cached_oidc_signing_key_from_jwt(token: str) -> Any: + if not _cached_oidc_signing_keys: + raise _authentication_error() + try: + header = jwt.get_unverified_header(token) + except Exception: + raise _authentication_error() from None + key_id = header.get("kid") + if not isinstance(key_id, str) or not key_id.strip(): + raise _authentication_error() + for signing_key in _cached_oidc_signing_keys: + if getattr(signing_key, "key_id", None) == key_id: + return signing_key + raise _authentication_error() + + def _session_secret_bytes() -> bytes: configured = settings.AUTH_SESSION_HMAC_SECRET if configured is None: @@ -120,6 +189,25 @@ def _json_object_from_base64url_segment(segment: str) -> dict[str, Any]: def _verify_signed_session_payload(authorization: str | None) -> dict[str, Any]: token = _extract_bearer_token(authorization) + + # OIDC RS256 verification is authoritative when configured. + if settings.OIDC_ISSUER_URL: + if jwks_client is None: + raise _authentication_error() + try: + signing_key = _cached_oidc_signing_key_from_jwt(token) + payload = jwt.decode( + token, + signing_key.key, + algorithms=["RS256"], + audience=settings.OIDC_CLIENT_ID, + issuer=settings.OIDC_ISSUER_URL + ) + return payload + except Exception: + raise _authentication_error() from None + + # Legacy HMAC HS256 Fallback token_segments = token.split(".") if len(token_segments) != 3: raise _authentication_error() @@ -172,12 +260,14 @@ def _tuple_string_claim(payload: dict[str, Any], name: str) -> tuple[str, ...]: def _validate_session_metadata(payload: dict[str, Any]) -> None: - if payload.get("ver") != 1: - raise _authentication_error() - if payload.get("iss") != SESSION_ISSUER: - raise _authentication_error() - if payload.get("aud") != SESSION_AUDIENCE: - raise _authentication_error() + # If OIDC is configured, the issuer/audience might be verified by jwt.decode + if not settings.OIDC_ISSUER_URL: + if payload.get("ver") != 1: + raise _authentication_error() + if payload.get("iss") != SESSION_ISSUER: + raise _authentication_error() + if payload.get("aud") != SESSION_AUDIENCE: + raise _authentication_error() expires_at = payload.get("exp") if isinstance(expires_at, bool) or not isinstance(expires_at, (int, float)): raise _authentication_error() @@ -194,7 +284,7 @@ def _auth_context_from_session_payload(payload: dict[str, Any]) -> AuthContext: raise _authentication_error() role = cast(RoleName, role_value) organization_id = _optional_string_claim(payload, "org") - if role != "platform_admin" and organization_id is None: + if not is_system_admin_role(role) and organization_id is None: raise _authentication_error() return AuthContext( user_id=_required_string_claim(payload, "sub"), diff --git a/backend/api/calendar.py b/backend/api/calendar.py index ffda11f92..99bcf659f 100644 --- a/backend/api/calendar.py +++ b/backend/api/calendar.py @@ -3,7 +3,12 @@ from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, ConfigDict -from api.auth import AuthContext, get_auth_context +from api.auth import ( + AuthContext, + get_auth_context, + is_system_admin_role, + is_tenant_admin_role, +) from services.calendar_service import create_calendar_event, validate_calendar_todo_text from services.exceptions import CalendarServiceError, UnsafeCalendarTodoError @@ -113,11 +118,11 @@ def _find_writeback_source_by_id( def _can_target_writeback_source( target_source: WritebackSource, auth_context: AuthContext ) -> bool: - if auth_context.role == "platform_admin": + if is_system_admin_role(auth_context.role): return True if target_source.organization_id != auth_context.organization_id: return False - if auth_context.role == "organization_admin": + if is_tenant_admin_role(auth_context.role): return True return target_source.owner_id == auth_context.user_id diff --git a/backend/api/llm_providers.py b/backend/api/llm_providers.py index 28887ab3b..82daa00e6 100644 --- a/backend/api/llm_providers.py +++ b/backend/api/llm_providers.py @@ -7,7 +7,7 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession -from api.auth import AuthContext, get_auth_context +from api.auth import AuthContext, get_auth_context, is_admin_role from db.models import AuditLog, LLMProvider from db.session import get_db from services.llm_provider_urls import ( @@ -65,7 +65,7 @@ async def _validated_base_url(value: str | None) -> str | None: async def check_admin_access( auth_context: AuthContext = Depends(get_auth_context), ) -> AuthContext: - if auth_context.role not in {"platform_admin", "organization_admin"}: + if not is_admin_role(auth_context.role): raise HTTPException( status_code=403, detail="Organization admin access required" ) diff --git a/backend/api/ontology.py b/backend/api/ontology.py index 3413315a3..fbacd4bc2 100644 --- a/backend/api/ontology.py +++ b/backend/api/ontology.py @@ -13,6 +13,7 @@ class RelationshipResponse(BaseModel): sender_email: str + parent_sender_email: str | None = None relationship_type: str confidence_score: float next_action: str @@ -20,6 +21,7 @@ class RelationshipResponse(BaseModel): class RelationshipCreate(BaseModel): sender_email: str + parent_sender_email: str | None = None relationship_type: str confidence_score: float = 1.0 @@ -35,6 +37,7 @@ async def get_relationships( return [ RelationshipResponse( sender_email=r.sender_email, + parent_sender_email=r.parent_sender_email, relationship_type=r.relationship_type, confidence_score=r.confidence_score, **ontology_service.next_action_for_relationship(r.relationship_type), @@ -60,10 +63,13 @@ async def create_relationship( if rel: rel.relationship_type = req.relationship_type rel.confidence_score = req.confidence_score + if "parent_sender_email" in req.model_fields_set: + rel.parent_sender_email = req.parent_sender_email else: rel = SenderRelationship( user_id=user_id, sender_email=req.sender_email, + parent_sender_email=req.parent_sender_email, relationship_type=req.relationship_type, confidence_score=req.confidence_score ) @@ -74,6 +80,7 @@ async def create_relationship( return RelationshipResponse( sender_email=rel.sender_email, + parent_sender_email=rel.parent_sender_email, relationship_type=rel.relationship_type, confidence_score=rel.confidence_score, **ontology_service.next_action_for_relationship(rel.relationship_type), diff --git a/backend/api/runner_config.py b/backend/api/runner_config.py index f0ba40546..db7102503 100644 --- a/backend/api/runner_config.py +++ b/backend/api/runner_config.py @@ -7,7 +7,13 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from api.auth import AuthContext, ensure_organization_access, get_auth_context +from api.auth import ( + AuthContext, + ensure_organization_access, + get_auth_context, + is_admin_role, + is_tenant_admin_role, +) from core.config import settings from db.models import WorkspaceRunnerConfig from db.session import get_db @@ -43,9 +49,9 @@ def _connector_manifest() -> dict[str, object]: def _check_org_admin(auth_context: AuthContext = Depends(get_auth_context)) -> AuthContext: - if auth_context.role not in {"platform_admin", "organization_admin"}: + if not is_admin_role(auth_context.role): raise HTTPException(status_code=403, detail="Organization admin access required") - if auth_context.role == "organization_admin" and not auth_context.organization_id: + if is_tenant_admin_role(auth_context.role) and not auth_context.organization_id: raise HTTPException(status_code=403, detail="Organization scope is required") return auth_context diff --git a/backend/api/tenant_config.py b/backend/api/tenant_config.py index 1b46fbb24..66c7a1ee8 100644 --- a/backend/api/tenant_config.py +++ b/backend/api/tenant_config.py @@ -6,7 +6,7 @@ from db.models import TenantConfig from db.session import get_db -from api.auth import get_current_user, get_current_user_role +from api.auth import get_current_user, get_current_user_role, is_admin_role from services.email_client import ( validate_imap_destination, validate_imap_port, @@ -23,7 +23,7 @@ async def get_global_config( role: str = Depends(get_current_user_role) ): - if role not in ["platform_admin", "organization_admin"]: + if not is_admin_role(role): raise HTTPException(status_code=403, detail="Not enough privileges") return {"status": "ok", "global_settings": {}} diff --git a/backend/api/webdav.py b/backend/api/webdav.py index c49f85752..17bba6289 100644 --- a/backend/api/webdav.py +++ b/backend/api/webdav.py @@ -1,8 +1,10 @@ from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession from typing import List from api.auth import get_auth_context, AuthContext +from db.session import get_db from services.webdav_service import webdav_service router = APIRouter(prefix="/api/webdav", tags=["webdav"]) @@ -17,12 +19,46 @@ class ProjectFolderResponse(BaseModel): project_name: str webdav_path: str +class WritebackIntentRequest(BaseModel): + target_account_id: int | None = None + +class WritebackIntentResponse(BaseModel): + intent: str + source_id: int | None + server_url: str | None + requires_if_match: bool + provenance: str + status: str | None = None + message: str | None = None + @router.get("/accounts", response_model=List[WebdavAccountResponse]) -async def get_webdav_accounts(auth_context: AuthContext = Depends(get_auth_context)): +async def get_webdav_accounts( + auth_context: AuthContext = Depends(get_auth_context), + db: AsyncSession = Depends(get_db), +): user_id = auth_context.user_id - return webdav_service.get_connected_accounts(user_id) + return await webdav_service.get_connected_accounts_from_db(db, user_id) @router.get("/folders", response_model=List[ProjectFolderResponse]) -async def get_project_folders(auth_context: AuthContext = Depends(get_auth_context)): +async def get_project_folders( + auth_context: AuthContext = Depends(get_auth_context), + db: AsyncSession = Depends(get_db), +): + user_id = auth_context.user_id + return await webdav_service.get_project_folders_from_db(db, user_id) + +@router.post("/writeback-intent", response_model=WritebackIntentResponse) +async def get_webdav_writeback_intent( + req: WritebackIntentRequest, + auth_context: AuthContext = Depends(get_auth_context), + db: AsyncSession = Depends(get_db), +): user_id = auth_context.user_id - return webdav_service.get_project_folders(user_id) + result = await webdav_service.determine_webdav_writeback_intent_from_db( + db, + user_id, + target_account_id=req.target_account_id, + ) + if result.get("status") == "error": + raise HTTPException(status_code=422, detail=result.get("message")) + return WritebackIntentResponse(**result) diff --git a/backend/core/config.py b/backend/core/config.py index 93216a425..290d0668a 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -45,6 +45,11 @@ class Settings(BaseSettings): OPENAI_EMBEDDING_MODEL: str = "text-embedding-3-small" OPENAI_MODEL: str = "gpt-4o" + # OIDC Settings + OIDC_ISSUER_URL: str | None = None + OIDC_CLIENT_ID: str | None = None + OIDC_JWKS_URL: str | None = None + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") @model_validator(mode="after") @@ -56,6 +61,20 @@ def validate_session_secret(self) -> "Settings": ) validate_auth_session_hmac_secret_value(configured.get_secret_value()) + oidc_values = { + "OIDC_ISSUER_URL": self.OIDC_ISSUER_URL, + "OIDC_CLIENT_ID": self.OIDC_CLIENT_ID, + "OIDC_JWKS_URL": self.OIDC_JWKS_URL, + } + configured_oidc_values = { + setting_name: setting_value + for setting_name, setting_value in oidc_values.items() + if setting_value + } + if configured_oidc_values and len(configured_oidc_values) != len(oidc_values): + raise ValueError( + "OIDC_ISSUER_URL, OIDC_CLIENT_ID, and OIDC_JWKS_URL must be set together" + ) return self diff --git a/backend/core/rbac.py b/backend/core/rbac.py index bf03452d1..330b125eb 100644 --- a/backend/core/rbac.py +++ b/backend/core/rbac.py @@ -20,15 +20,17 @@ def check_tenant_access(user_role: RoleName, required_role: RoleName) -> bool: Check if the user's role satisfies the required role. Higher privileges include lower privileges. Universal mapping: - - platform_admin: SaaS Provider / System Admin - - organization_admin: Corporate Admin / Tenant Owner + - system_admin/platform_admin: SaaS Provider / System Admin + - tenant_admin/organization_admin: Corporate Admin / Tenant Owner - group_admin: IT Operator / Department Head - member: B2B2C User or B2C Individual """ hierarchy = { "member": 0, "group_admin": 1, + "tenant_admin": 2, "organization_admin": 2, + "system_admin": 3, "platform_admin": 3 } diff --git a/backend/core/telemetry.py b/backend/core/telemetry.py new file mode 100644 index 000000000..3ab5f4980 --- /dev/null +++ b/backend/core/telemetry.py @@ -0,0 +1,58 @@ +import logging +import os +from fastapi import FastAPI + +logger = logging.getLogger(__name__) +_TELEMETRY_STATE_KEY = "naruon_telemetry_configured" + + +def _env_flag(name: str, default: bool = False) -> bool: + raw_value = os.getenv(name) + if raw_value is None: + return default + return raw_value.strip().lower() in {"1", "true", "yes", "on"} + + +def setup_telemetry(app: FastAPI): + if getattr(app.state, _TELEMETRY_STATE_KEY, False): + logger.debug("OpenTelemetry instrumentation is already configured.") + return + + # Only set up tracing when ENABLE_OTEL is true and an OTLP endpoint is set. + enable_otel = _env_flag("ENABLE_OTEL") + otel_endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT") + + if not enable_otel or not otel_endpoint: + logger.info("OpenTelemetry is disabled.") + return + + otlp_insecure = _env_flag("OTEL_EXPORTER_OTLP_INSECURE") + + try: + from opentelemetry import trace + from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( + OTLPSpanExporter, + ) + from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor + from opentelemetry.sdk.resources import SERVICE_NAME, Resource + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import BatchSpanProcessor + + logger.info("Setting up OpenTelemetry export.") + resource = Resource(attributes={SERVICE_NAME: "naruon-backend"}) + + provider = TracerProvider(resource=resource) + trace.set_tracer_provider(provider) + + otlp_exporter = OTLPSpanExporter( + endpoint=otel_endpoint, + insecure=otlp_insecure, + ) + span_processor = BatchSpanProcessor(otlp_exporter) + provider.add_span_processor(span_processor) + + FastAPIInstrumentor.instrument_app(app) + setattr(app.state, _TELEMETRY_STATE_KEY, True) + logger.info("OpenTelemetry instrumentation completed successfully.") + except Exception: + logger.exception("OpenTelemetry setup failed; continuing without tracing.") diff --git a/backend/db/models.py b/backend/db/models.py index 754435510..75a7f8c70 100644 --- a/backend/db/models.py +++ b/backend/db/models.py @@ -334,6 +334,7 @@ class SenderRelationship(Base): user_id: Mapped[str] = mapped_column(String, index=True, nullable=False) organization_id: Mapped[str | None] = mapped_column(String, index=True, nullable=True) sender_email: Mapped[str] = mapped_column(String, index=True, nullable=False) + parent_sender_email: Mapped[str | None] = mapped_column(String, index=True, nullable=True) relationship_type: Mapped[str] = mapped_column(String, nullable=False) confidence_score: Mapped[float] = mapped_column(default=1.0) created_at: Mapped[datetime.datetime] = mapped_column( @@ -347,6 +348,39 @@ class SenderRelationship(Base): ) +class CaldavAccount(Base): + __tablename__ = "caldav_accounts" + + id: Mapped[int] = mapped_column("account_id", primary_key=True) + user_id: Mapped[str] = mapped_column(String, index=True, nullable=False) + server_url: Mapped[str] = mapped_column(String, nullable=False) + username: Mapped[str] = mapped_column(String, nullable=False) + credentials_encrypted: Mapped[str] = mapped_column(EncryptedString, nullable=False) + created_at: Mapped[datetime.datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.datetime.now(datetime.timezone.utc), + ) + + +class ReplyTracker(Base): + __tablename__ = "reply_trackers" + + id: Mapped[int] = mapped_column("tracker_id", primary_key=True) + user_id: Mapped[str] = mapped_column(String, index=True, nullable=False) + message_id: Mapped[str] = mapped_column(String, index=True, nullable=False) + status_code: Mapped[str] = mapped_column(String, default="waiting", index=True) + follow_up_date: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime.datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.datetime.now(datetime.timezone.utc), + ) + updated_at: Mapped[datetime.datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.datetime.now(datetime.timezone.utc), + onupdate=lambda: datetime.datetime.now(datetime.timezone.utc), + ) + + class WebdavAccount(Base): __tablename__ = "webdav_accounts" diff --git a/backend/main.py b/backend/main.py index b1cc24ac5..801ec8ee2 100644 --- a/backend/main.py +++ b/backend/main.py @@ -2,7 +2,7 @@ from contextlib import asynccontextmanager from fastapi import Depends, FastAPI from fastapi.middleware.cors import CORSMiddleware -from api.auth import get_auth_context +from api.auth import get_auth_context, preload_oidc_jwks from api.search import router as search_router from api.llm import router as llm_router from api.calendar import router as calendar_router @@ -20,9 +20,9 @@ from api.accounts import router as accounts_router from api.webdav import router as webdav_router from core.config import settings +from core.telemetry import setup_telemetry from services.imap_worker import ImapSyncWorker from prometheus_fastapi_instrumentator import Instrumentator -from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor imap_worker = ImapSyncWorker() @@ -32,6 +32,7 @@ @asynccontextmanager async def lifespan(app: FastAPI): + preload_oidc_jwks() if not DISABLE_WORKERS: await imap_worker.start() yield @@ -45,20 +46,7 @@ async def lifespan(app: FastAPI): lifespan=lifespan, ) -# OpenTelemetry Tracing Setup (if enabled) -if os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT"): - from opentelemetry import trace - from opentelemetry.sdk.trace import TracerProvider - from opentelemetry.sdk.trace.export import BatchSpanProcessor - from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter - from opentelemetry.sdk.resources import Resource, SERVICE_NAME - - resource = Resource(attributes={SERVICE_NAME: "naruon-backend"}) - - trace_provider = TracerProvider(resource=resource) - processor = BatchSpanProcessor(OTLPSpanExporter()) - trace_provider.add_span_processor(processor) - trace.set_tracer_provider(trace_provider) +setup_telemetry(app) # Prometheus metrics are operational telemetry. Keep them opt-in so public # deployments do not expose route labels and process details by default. @@ -67,9 +55,6 @@ async def lifespan(app: FastAPI): app, include_in_schema=False, should_gzip=True ) -# Instrument OpenTelemetry -FastAPIInstrumentor.instrument_app(app) - app.add_middleware( CORSMiddleware, allow_origins=[ @@ -90,7 +75,7 @@ async def lifespan(app: FastAPI): app.include_router(emails_router, dependencies=PRIVATE_API_DEPENDENCIES) app.include_router(runner_config_router, dependencies=PRIVATE_API_DEPENDENCIES) app.include_router(tenant_config_router, dependencies=PRIVATE_API_DEPENDENCIES) -app.include_router(runtime_config_router) +app.include_router(runtime_config_router, dependencies=PRIVATE_API_DEPENDENCIES) app.include_router(llm_providers_router, dependencies=PRIVATE_API_DEPENDENCIES) app.include_router(prompts_router, dependencies=PRIVATE_API_DEPENDENCIES) app.include_router(tasks_router, dependencies=PRIVATE_API_DEPENDENCIES) @@ -101,15 +86,6 @@ async def lifespan(app: FastAPI): app.include_router(webdav_router, dependencies=PRIVATE_API_DEPENDENCIES) -app.add_middleware( - CORSMiddleware, - allow_origins=["http://localhost:3000"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - - @app.get("/") def read_root() -> dict[str, str]: return {"status": "ok", "message": "AI Email Client API"} diff --git a/backend/requirements.txt b/backend/requirements.txt index b51a97f4a..221c8c7a6 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -25,3 +25,4 @@ opentelemetry-instrumentation-fastapi==0.46b0 opentelemetry-exporter-otlp==1.25.0 setuptools==78.1.1 websockets==14.1 +PyJWT==2.8.0 diff --git a/backend/runner/connector.py b/backend/runner/connector.py index 85cdb8725..dba242d6a 100644 --- a/backend/runner/connector.py +++ b/backend/runner/connector.py @@ -1,4 +1,5 @@ import asyncio +import json import logging from typing import Dict, Any @@ -10,6 +11,14 @@ logger = logging.getLogger(__name__) +def _get_account_name(payload: Dict[str, Any]) -> str | None: + account = payload.get("account") + if not isinstance(account, str): + return None + account = account.strip() + return account or None + + class SelfHostedConnector: def __init__(self, target_ws_url: str, token: str): self.target_ws_url = target_ws_url @@ -60,12 +69,68 @@ async def _listen_loop(self): async def handle_message(self, message: str | bytes): # Dispatch message to internal SMTP/IMAP proxy handlers - logger.debug(f"Received instruction from gateway: {message}") - pass + logger.debug("Received instruction from gateway.") + try: + payload = json.loads(message) + except json.JSONDecodeError: + logger.error("Failed to decode message from gateway.") + await self.send_response({ + "status": "error", + "action": None, + "error": "invalid json", + }) + return + + if not isinstance(payload, dict): + logger.error("Gateway instruction payload must be an object.") + await self.send_response({ + "status": "error", + "action": None, + "error": "invalid payload", + }) + return + + action = payload.get("action") + if action == "fetch_imap": + await self._handle_fetch_imap(payload) + elif action == "send_smtp": + await self._handle_send_smtp(payload) + else: + logger.info("Unknown action received.") + await self.send_response({ + "status": "error", + "action": action if isinstance(action, str) else None, + "error": "unknown action", + }) + + async def _handle_fetch_imap(self, payload: Dict[str, Any]): + if _get_account_name(payload) is None: + logger.error("IMAP fetch instruction is missing account.") + await self.send_response({ + "status": "error", + "action": "fetch_imap", + "error": "missing account", + }) + return + logger.info("Executing local IMAP fetch for configured account.") + # Placeholder for actual internal IMAP logic + await self.send_response({"status": "success", "action": "fetch_imap", "data": "IMAP data placeholder"}) + + async def _handle_send_smtp(self, payload: Dict[str, Any]): + if _get_account_name(payload) is None: + logger.error("SMTP send instruction is missing account.") + await self.send_response({ + "status": "error", + "action": "send_smtp", + "error": "missing account", + }) + return + logger.info("Executing local SMTP send for configured account.") + # Placeholder for actual internal SMTP logic + await self.send_response({"status": "success", "action": "send_smtp", "message_id": "mock_id_123"}) async def send_response(self, response: Dict[str, Any]): if self.is_connected and self.connection: - import json await self.connection.send(json.dumps(response)) if __name__ == "__main__": diff --git a/backend/services/access_policy.py b/backend/services/access_policy.py index 645b11bb2..ef6d50fdd 100644 --- a/backend/services/access_policy.py +++ b/backend/services/access_policy.py @@ -5,8 +5,15 @@ from dataclasses import dataclass, field from typing import Literal +from core.rbac import check_tenant_access + PolicyRoleName = Literal[ - "platform_admin", "organization_admin", "group_admin", "member" + "system_admin", + "tenant_admin", + "platform_admin", + "organization_admin", + "group_admin", + "member", ] DecisionReason = Literal[ "allowed", @@ -45,17 +52,58 @@ class AccessDecision: reason: DecisionReason +ROLE_EQUIVALENTS: dict[str, frozenset[str]] = { + "system_admin": frozenset({"system_admin", "platform_admin"}), + "platform_admin": frozenset({"system_admin", "platform_admin"}), + "tenant_admin": frozenset({"tenant_admin", "organization_admin"}), + "organization_admin": frozenset({"tenant_admin", "organization_admin"}), + "group_admin": frozenset({"group_admin"}), + "member": frozenset({"member"}), +} + +CANONICAL_ROLE: dict[str, str] = { + "system_admin": "system_admin", + "platform_admin": "system_admin", + "tenant_admin": "tenant_admin", + "organization_admin": "tenant_admin", + "group_admin": "group_admin", + "member": "member", +} + + +def _equivalent_roles(role: str) -> frozenset[str]: + return ROLE_EQUIVALENTS.get(role, frozenset({role})) + + +def _canonical_role(role: str) -> str: + return CANONICAL_ROLE.get(role, role) + + +def _role_allowed(role: str, permitted_roles: tuple[PolicyRoleName, ...]) -> bool: + if _is_system_admin_role(role): + return any(_is_system_admin_role(item) for item in permitted_roles) + request_role = _canonical_role(role) + return any( + check_tenant_access(request_role, _canonical_role(item)) + for item in permitted_roles + ) + + +def _is_system_admin_role(role: str) -> bool: + return role in {"system_admin", "platform_admin"} + + def evaluate_access(request: AccessRequest, resource: ResourcePolicy) -> AccessDecision: """Evaluate resource access with ABAC denials before RBAC allows.""" - role_allowed = request.role in resource.permitted_roles + role_allowed = _role_allowed(request.role, resource.permitted_roles) group_allowed = bool(set(request.group_ids) & set(resource.permitted_group_ids)) - platform_admin_allowed = request.role == "platform_admin" and role_allowed + system_admin_allowed = _is_system_admin_role(request.role) and role_allowed - if request.role == "platform_admin" and not role_allowed: + if _is_system_admin_role(request.role) and not role_allowed: return AccessDecision(allowed=False, reason="rbac_denied") if ( - not platform_admin_allowed + not system_admin_allowed and request.organization_id != resource.organization_id ): return AccessDecision(allowed=False, reason="organization_denied") @@ -71,7 +119,7 @@ def evaluate_access(request: AccessRequest, resource: ResourcePolicy) -> AccessD owns_resource = request.user_id == resource.owner_id has_delegation = request.user_id in resource.delegated_user_ids - if not platform_admin_allowed and not owns_resource and not has_delegation: + if not system_admin_allowed and not owns_resource and not has_delegation: return AccessDecision(allowed=False, reason="ownership_denied") if not role_allowed and not group_allowed: diff --git a/backend/services/caldav_service.py b/backend/services/caldav_service.py index ef89b32cc..9fe412d57 100644 --- a/backend/services/caldav_service.py +++ b/backend/services/caldav_service.py @@ -1,18 +1,40 @@ import logging from typing import Dict, Any +from urllib.parse import urlsplit, urlunsplit logger = logging.getLogger(__name__) + +def _log_safe_url(raw_url: str) -> str: + parsed = urlsplit(raw_url) + host = parsed.hostname or "" + if parsed.port is not None: + host = f"{host}:{parsed.port}" + return urlunsplit((parsed.scheme, host, parsed.path, "", "")) + async def sync_caldav_accounts(session, user_id: str): """ Fetch and store events locally for all CalDAV accounts of the user. """ - # Pseudo implementation to parse events and store them - from db.models import TenantConfig # Actually we would use a CaldavAccount model but using something for demo - # The actual implementation would query Caldav accounts for the user_id + from db.models import CaldavAccount + from sqlalchemy import select + logger.info(f"Syncing CalDAV accounts for user {user_id}") - # Simulating N-accounts fetching - logger.info(f"Parsed 0 events for user {user_id}") + stmt = select(CaldavAccount).where(CaldavAccount.user_id == user_id) + result = await session.execute(stmt) + accounts = result.scalars().all() + + total_parsed = 0 + for account in accounts: + # Pseudo implementation to parse events for each account + logger.debug( + "Syncing CalDAV account %s at %s", + account.id, + _log_safe_url(account.server_url), + ) + total_parsed += 0 # Placeholder + + logger.info(f"Parsed {total_parsed} events for user {user_id}") return True class CalDavService: diff --git a/backend/services/webdav_service.py b/backend/services/webdav_service.py index 084459d81..a0bb9d82f 100644 --- a/backend/services/webdav_service.py +++ b/backend/services/webdav_service.py @@ -1,6 +1,11 @@ import logging from typing import Dict, Any, List +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from db.models import ProjectFolder, WebdavAccount + logger = logging.getLogger(__name__) async def sync_webdav_folders(session, user_id: str): @@ -57,6 +62,38 @@ def get_project_folders(self, user_id: str) -> List[Dict[str, Any]]: """ return self._mock_folders.get(user_id, []) + async def get_connected_accounts_from_db( + self, + session: AsyncSession, + user_id: str, + ) -> List[Dict[str, Any]]: + stmt = select(WebdavAccount).where(WebdavAccount.user_id == user_id) + result = await session.execute(stmt) + return [ + { + "account_id": account.id, + "server_url": account.server_url, + "username": account.username, + } + for account in result.scalars().all() + ] + + async def get_project_folders_from_db( + self, + session: AsyncSession, + user_id: str, + ) -> List[Dict[str, Any]]: + stmt = select(ProjectFolder).where(ProjectFolder.user_id == user_id) + result = await session.execute(stmt) + return [ + { + "folder_id": folder.id, + "project_name": folder.project_name, + "webdav_path": folder.webdav_path, + } + for folder in result.scalars().all() + ] + def sync_attachments_to_folder(self, email_id: str, project_name: str) -> bool: """ Organizes an email's attachments into the specified WebDAV project folder. @@ -65,4 +102,55 @@ def sync_attachments_to_folder(self, email_id: str, project_name: str) -> bool: # Mock implementation: in reality, this would download from storage and upload via webdavclient3 return True + def determine_webdav_writeback_intent(self, user_id: str, target_account_id: int | None = None) -> Dict[str, Any]: + """ + Server-authoritative WebDAV writeback source selection. + """ + accounts = self.get_connected_accounts(user_id) + return self.determine_webdav_writeback_intent_from_accounts( + accounts, + target_account_id=target_account_id, + ) + + async def determine_webdav_writeback_intent_from_db( + self, + session: AsyncSession, + user_id: str, + target_account_id: int | None = None, + ) -> Dict[str, Any]: + accounts = await self.get_connected_accounts_from_db(session, user_id) + return self.determine_webdav_writeback_intent_from_accounts( + accounts, + target_account_id=target_account_id, + ) + + def determine_webdav_writeback_intent_from_accounts( + self, + accounts: List[Dict[str, Any]], + target_account_id: int | None = None, + ) -> Dict[str, Any]: + if not accounts: + return {"status": "error", "message": "No connected WebDAV accounts found."} + + selected_account = accounts[0] + if target_account_id is not None: + selected_account = None + for acc in accounts: + if acc["account_id"] == target_account_id: + selected_account = acc + break + if selected_account is None: + return { + "status": "error", + "message": "Requested WebDAV account was not found.", + } + + return { + "intent": "writeback", + "source_id": selected_account["account_id"], + "server_url": selected_account["server_url"], + "requires_if_match": True, + "provenance": "server-authoritative" + } + webdav_service = WebDavService() diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index cb889b7be..6a2f9cd22 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -17,7 +17,7 @@ from api.auth import AuthContext, RoleName, get_auth_context, get_current_user from main import app -TEST_SCOPED_ROLES = {"platform_admin", "organization_admin", "group_admin", "member"} +TEST_SCOPED_ROLES = {"system_admin", "tenant_admin", "group_admin", "member"} def _normalize_header_value(value: str | None) -> str | None: diff --git a/backend/tests/test_access_policy.py b/backend/tests/test_access_policy.py index d57fd9bb8..7016d656e 100644 --- a/backend/tests/test_access_policy.py +++ b/backend/tests/test_access_policy.py @@ -5,7 +5,7 @@ def test_abac_organization_denial_precedes_role_and_group_allow(): decision = evaluate_access( AccessRequest( user_id="alice", - role="organization_admin", + role="tenant_admin", organization_id="org-other", group_ids=("exec",), data_region="eu", @@ -14,7 +14,7 @@ def test_abac_organization_denial_precedes_role_and_group_allow(): ResourcePolicy( owner_id="alice", organization_id="org-acme", - permitted_roles=("organization_admin",), + permitted_roles=("tenant_admin",), permitted_group_ids=("exec",), data_region="eu", required_consent_scopes=("mail.read",), @@ -25,11 +25,11 @@ def test_abac_organization_denial_precedes_role_and_group_allow(): assert decision.reason == "organization_denied" -def test_abac_data_region_denial_overrides_platform_admin_rbac_allow(): +def test_abac_data_region_denial_overrides_system_admin_rbac_allow(): decision = evaluate_access( AccessRequest( user_id="root", - role="platform_admin", + role="system_admin", organization_id="org-acme", group_ids=("exec",), data_region="us", @@ -38,7 +38,7 @@ def test_abac_data_region_denial_overrides_platform_admin_rbac_allow(): ResourcePolicy( owner_id="alice", organization_id="org-acme", - permitted_roles=("platform_admin",), + permitted_roles=("system_admin",), permitted_group_ids=("exec",), data_region="eu", required_consent_scopes=("mail.read",), @@ -97,11 +97,11 @@ def test_missing_request_data_region_denies_regional_resource(): assert decision.reason == "data_region_denied" -def test_platform_admin_bypasses_organization_and_ownership_when_role_permitted(): +def test_system_admin_bypasses_organization_and_ownership_when_role_permitted(): decision = evaluate_access( AccessRequest( user_id="root", - role="platform_admin", + role="system_admin", organization_id=None, group_ids=(), data_region="eu", @@ -110,7 +110,7 @@ def test_platform_admin_bypasses_organization_and_ownership_when_role_permitted( ResourcePolicy( owner_id="alice", organization_id="org-acme", - permitted_roles=("platform_admin",), + permitted_roles=("system_admin",), permitted_group_ids=(), data_region="eu", required_consent_scopes=("mail.read",), @@ -121,11 +121,11 @@ def test_platform_admin_bypasses_organization_and_ownership_when_role_permitted( assert decision.reason == "allowed" -def test_platform_admin_without_rbac_permission_is_denied(): +def test_system_admin_without_rbac_permission_is_denied(): decision = evaluate_access( AccessRequest( user_id="root", - role="platform_admin", + role="system_admin", organization_id=None, group_ids=(), data_region="eu", @@ -134,7 +134,7 @@ def test_platform_admin_without_rbac_permission_is_denied(): ResourcePolicy( owner_id="alice", organization_id="org-acme", - permitted_roles=("organization_admin",), + permitted_roles=("tenant_admin",), permitted_group_ids=(), data_region="eu", required_consent_scopes=("mail.read",), @@ -145,6 +145,78 @@ def test_platform_admin_without_rbac_permission_is_denied(): assert decision.reason == "rbac_denied" +def test_tenant_admin_satisfies_member_role_hierarchy_for_owned_resource(): + decision = evaluate_access( + AccessRequest( + user_id="alice", + role="tenant_admin", + organization_id="org-acme", + group_ids=(), + data_region="eu", + consent_scopes=("mail.read",), + ), + ResourcePolicy( + owner_id="alice", + organization_id="org-acme", + permitted_roles=("member",), + permitted_group_ids=(), + data_region="eu", + required_consent_scopes=("mail.read",), + ), + ) + + assert decision.allowed is True + assert decision.reason == "allowed" + + +def test_organization_admin_alias_satisfies_group_admin_hierarchy(): + decision = evaluate_access( + AccessRequest( + user_id="alice", + role="organization_admin", + organization_id="org-acme", + group_ids=(), + data_region="eu", + consent_scopes=("mail.read",), + ), + ResourcePolicy( + owner_id="alice", + organization_id="org-acme", + permitted_roles=("group_admin",), + permitted_group_ids=(), + data_region="eu", + required_consent_scopes=("mail.read",), + ), + ) + + assert decision.allowed is True + assert decision.reason == "allowed" + + +def test_tenant_admin_satisfies_organization_admin_alias_deterministically(): + decision = evaluate_access( + AccessRequest( + user_id="alice", + role="tenant_admin", + organization_id="org-acme", + group_ids=(), + data_region="eu", + consent_scopes=("mail.read",), + ), + ResourcePolicy( + owner_id="alice", + organization_id="org-acme", + permitted_roles=("organization_admin",), + permitted_group_ids=(), + data_region="eu", + required_consent_scopes=("mail.read",), + ), + ) + + assert decision.allowed is True + assert decision.reason == "allowed" + + def test_abac_owner_denial_overrides_group_admin_rbac_allow_without_delegation(): decision = evaluate_access( AccessRequest( diff --git a/backend/tests/test_apm_observability.py b/backend/tests/test_apm_observability.py index f4613b3b5..568956c75 100644 --- a/backend/tests/test_apm_observability.py +++ b/backend/tests/test_apm_observability.py @@ -28,3 +28,30 @@ def test_metrics_exposure_requires_explicit_environment_gate(): assert "ENABLE_PROMETHEUS_METRICS" in main_source assert "Instrumentator().instrument(app).expose" in main_source + + +def test_open_telemetry_setup_is_centralized_and_opt_in_by_default(): + main_source = (ROOT_DIR / "backend/main.py").read_text() + telemetry_source = (ROOT_DIR / "backend/core/telemetry.py").read_text() + + assert "setup_telemetry(app)" in main_source + assert "FastAPIInstrumentor.instrument_app(app)" not in main_source + assert '_env_flag("ENABLE_OTEL")' in telemetry_source + assert 'os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT")' in telemetry_source + assert '"http://localhost:4317"' not in telemetry_source + assert "OTEL_EXPORTER_OTLP_INSECURE" in telemetry_source + assert "insecure=otlp_insecure" in telemetry_source + assert "except Exception" in telemetry_source + + +def test_telemetry_does_not_instrument_without_explicit_endpoint(monkeypatch): + from fastapi import FastAPI + from core import telemetry + + app = FastAPI() + monkeypatch.delenv("ENABLE_OTEL", raising=False) + monkeypatch.delenv("OTEL_EXPORTER_OTLP_ENDPOINT", raising=False) + + telemetry.setup_telemetry(app) + + assert getattr(app.state, "naruon_telemetry_configured", False) is False diff --git a/backend/tests/test_auth_real.py b/backend/tests/test_auth_real.py index 8dce35ca5..3a1316ac7 100644 --- a/backend/tests/test_auth_real.py +++ b/backend/tests/test_auth_real.py @@ -4,6 +4,7 @@ import inspect import json import os +import sys import time import pytest @@ -42,7 +43,7 @@ "x_group_ids", "x_dev_auth_token", } -PUBLIC_API_ROUTES = {("/api/runtime-config", frozenset({"GET"}))} +PUBLIC_API_ROUTES: set[tuple[str, frozenset[str]]] = set() class _MockResult: @@ -103,7 +104,7 @@ def _valid_session_payload(**overrides: object) -> dict[str, object]: "iss": "naruon-control-plane", "aud": "naruon-api", "sub": "alice", - "role": "organization_admin", + "role": "tenant_admin", "org": "org-acme", "groups": ["group-1", "group-2"], "workspace": "workspace-org-acme", @@ -118,12 +119,14 @@ def restore_auth_flags(): previous_debug = settings.DEBUG previous_runtime_environment = getattr(settings, "RUNTIME_ENVIRONMENT", None) previous_session_hmac_secret = getattr(settings, "AUTH_SESSION_HMAC_SECRET", None) + previous_oidc_signing_keys = sys.modules["api.auth"]._cached_oidc_signing_keys yield settings.DEBUG = previous_debug if previous_runtime_environment is not None: setattr(settings, "RUNTIME_ENVIRONMENT", previous_runtime_environment) if hasattr(settings, "AUTH_SESSION_HMAC_SECRET"): settings.AUTH_SESSION_HMAC_SECRET = previous_session_hmac_secret + sys.modules["api.auth"]._cached_oidc_signing_keys = previous_oidc_signing_keys def _set_runtime_environment(value: str) -> None: @@ -191,7 +194,6 @@ def test_private_api_routes_have_default_signed_session_dependency(): def test_explicit_public_routes_do_not_require_signed_session(): for method, path in ( ("GET", "/"), - ("GET", "/api/runtime-config"), ): response = _request_without_dependency_overrides(method, path) assert response.status_code == 200, f"{method} {path}: {response.text}" @@ -203,8 +205,9 @@ def test_metrics_route_is_not_registered_by_default(): assert response.status_code == 404 -def test_private_api_route_rejects_missing_signed_session_by_default(): - response = _request_without_dependency_overrides("GET", "/api/emails") +@pytest.mark.parametrize("path", ("/api/emails", "/api/runtime-config")) +def test_private_api_route_rejects_missing_signed_session_by_default(path: str): + response = _request_without_dependency_overrides("GET", path) assert response.status_code == 401 assert response.json() == {"detail": "Authentication required"} @@ -235,7 +238,7 @@ async def test_get_auth_context_accepts_signed_bearer_session(): assert context == AuthContext( user_id="alice", - role="organization_admin", + role="tenant_admin", organization_id="org-acme", group_ids=("group-1", "group-2"), workspace_id="workspace-org-acme", @@ -249,7 +252,7 @@ async def test_signed_bearer_session_rejects_tampered_payload(): header_segment, _payload_segment, signature_segment = token.split(".") tampered_payload_segment = _base64url_encode( json.dumps( - _valid_session_payload(role="platform_admin"), + _valid_session_payload(role="system_admin"), separators=(",", ":"), sort_keys=True, ).encode("utf-8") @@ -441,7 +444,7 @@ async def test_signed_bearer_session_rejects_invalid_role_claim(): @pytest.mark.asyncio -async def test_admin_subject_does_not_imply_platform_admin_role(): +async def test_admin_subject_does_not_imply_system_admin_role(): settings.AUTH_SESSION_HMAC_SECRET = SecretStr(TEST_SESSION_HMAC_SECRET) token = _signed_session_token(_valid_session_payload(sub="admin", role="member")) @@ -452,18 +455,18 @@ async def test_admin_subject_does_not_imply_platform_admin_role(): @pytest.mark.asyncio -async def test_platform_admin_requires_explicit_signed_role_claim(): +async def test_system_admin_requires_explicit_signed_role_claim(): settings.AUTH_SESSION_HMAC_SECRET = SecretStr(TEST_SESSION_HMAC_SECRET) token = _signed_session_token( _valid_session_payload( - role="platform_admin", org=None, workspace="workspace-root" + role="system_admin", org=None, workspace="workspace-root" ) ) context = await get_auth_context(authorization=f"Bearer {token}") assert context.user_id == "alice" - assert context.role == "platform_admin" + assert context.role == "system_admin" assert context.organization_id is None assert context.workspace_id == "workspace-root" @@ -486,7 +489,7 @@ async def override_get_db(): headers={ "Authorization": f"Bearer {token}", "X-User-Id": "attacker", - "X-User-Role": "platform_admin", + "X-User-Role": "system_admin", "X-Organization-Id": "org-victim", "X-Dev-Auth-Token": TEST_DEV_AUTH_TOKEN, }, @@ -512,7 +515,7 @@ async def test_debug_mode_does_not_trust_unsigned_identity_headers(): _assert_runner_config_rejects_identity_headers( { "X-User-Id": "attacker", - "X-User-Role": "platform_admin", + "X-User-Role": "system_admin", "X-Organization-Id": "org-victim", } ) @@ -524,7 +527,7 @@ def test_dev_header_trust_requires_configured_token(): _assert_runner_config_rejects_identity_headers( { "X-User-Id": "attacker", - "X-User-Role": "platform_admin", + "X-User-Role": "system_admin", "X-Organization-Id": "org-victim", } ) @@ -536,7 +539,7 @@ def test_dev_header_trust_rejects_wrong_token(): _assert_runner_config_rejects_identity_headers( { "X-User-Id": "attacker", - "X-User-Role": "platform_admin", + "X-User-Role": "system_admin", "X-Organization-Id": "org-victim", "X-Dev-Auth-Token": WRONG_DEV_AUTH_TOKEN, } @@ -549,7 +552,7 @@ def test_dev_auth_token_does_not_work_when_header_trust_is_disabled(): _assert_runner_config_rejects_identity_headers( { "X-User-Id": "attacker", - "X-User-Role": "platform_admin", + "X-User-Role": "system_admin", "X-Organization-Id": "org-victim", "X-Dev-Auth-Token": TEST_DEV_AUTH_TOKEN, } @@ -562,7 +565,7 @@ def test_dev_header_trust_is_rejected_in_production_environment(): _assert_runner_config_rejects_identity_headers( { "X-User-Id": "attacker", - "X-User-Role": "platform_admin", + "X-User-Role": "system_admin", "X-Organization-Id": "org-victim", "X-Dev-Auth-Token": TEST_DEV_AUTH_TOKEN, } @@ -575,7 +578,7 @@ def test_dev_header_trust_requires_strong_token(): _assert_runner_config_rejects_identity_headers( { "X-User-Id": "attacker", - "X-User-Role": "platform_admin", + "X-User-Role": "system_admin", "X-Organization-Id": "org-victim", "X-Dev-Auth-Token": WEAK_DEV_AUTH_TOKEN, } @@ -599,7 +602,7 @@ def test_http_route_rejects_dev_token_and_forged_role_even_when_flags_enabled(): _assert_runner_config_rejects_identity_headers( { "X-User-Id": "attacker", - "X-User-Role": "platform_admin", + "X-User-Role": "system_admin", "X-Organization-Id": "org-victim", "X-Dev-Auth-Token": TEST_DEV_AUTH_TOKEN, } @@ -610,7 +613,7 @@ def test_http_route_rejects_public_identity_headers_without_dev_token(): _assert_runner_config_rejects_identity_headers( { "X-User-Id": "attacker", - "X-User-Role": "platform_admin", + "X-User-Role": "system_admin", "X-Organization-Id": "org-victim", } ) @@ -630,13 +633,13 @@ def test_runtime_auth_rejects_scoped_enterprise_role_headers(): ) -def test_runtime_auth_rejects_platform_admin_role_headers(): +def test_runtime_auth_rejects_system_admin_role_headers(): _enable_local_dev_headers() _assert_runner_config_rejects_identity_headers( { "X-User-Id": "root", - "X-User-Role": "platform_admin", + "X-User-Role": "system_admin", "X-Dev-Auth-Token": TEST_DEV_AUTH_TOKEN, } ) @@ -656,7 +659,7 @@ def test_admin_user_id_is_rejected_without_verified_identity_provider(): def test_ensure_organization_access_rejects_cross_scope_resource(): context = AuthContext( user_id="alice", - role="organization_admin", + role="tenant_admin", organization_id="org-acme", group_ids=("group-1",), workspace_id="workspace-org-acme", @@ -667,3 +670,118 @@ def test_ensure_organization_access_rejects_cross_scope_resource(): assert exc.value.status_code == 403 assert exc.value.detail == "Resource belongs to a different organization" + +@pytest.mark.asyncio +async def test_signed_bearer_session_with_oidc(monkeypatch): + import jwt + + previous_issuer_url = settings.OIDC_ISSUER_URL + previous_client_id = settings.OIDC_CLIENT_ID + previous_secret = settings.AUTH_SESSION_HMAC_SECRET + settings.OIDC_ISSUER_URL = "http://localhost:8081/realms/naruon" + settings.OIDC_CLIENT_ID = "naruon-api" + settings.AUTH_SESSION_HMAC_SECRET = SecretStr(TEST_SESSION_HMAC_SECRET) + + class MockKey: + key_id = "test-key" + key = "public_key" + + monkeypatch.setattr("api.auth.jwks_client", object()) + monkeypatch.setattr("api.auth._cached_oidc_signing_keys", (MockKey(),)) + + def mock_jwt_decode(*args, **kwargs): + return { + "iss": "http://localhost:8081/realms/naruon", + "aud": "naruon-api", + "sub": "alice", + "role": "tenant_admin", + "org": "org-acme", + "groups": ["group-1", "group-2"], + "workspace": "workspace-org-acme", + "exp": int(time.time()) + 300, + } + monkeypatch.setattr(jwt, "decode", mock_jwt_decode) + + try: + token = _signed_session_token( + _valid_session_payload(), + header={"alg": "RS256", "typ": "JWT", "kid": "test-key"}, + ) + context = await get_auth_context(authorization=f"Bearer {token}") + finally: + settings.OIDC_ISSUER_URL = previous_issuer_url + settings.OIDC_CLIENT_ID = previous_client_id + settings.AUTH_SESSION_HMAC_SECRET = previous_secret + + assert context.user_id == "alice" + assert context.role == "tenant_admin" + assert context.organization_id == "org-acme" + + +@pytest.mark.asyncio +async def test_oidc_validation_failure_does_not_fallback_to_signed_session(monkeypatch): + import jwt + + previous_issuer_url = settings.OIDC_ISSUER_URL + previous_client_id = settings.OIDC_CLIENT_ID + previous_secret = settings.AUTH_SESSION_HMAC_SECRET + settings.OIDC_ISSUER_URL = "http://localhost:8081/realms/naruon" + settings.OIDC_CLIENT_ID = "naruon-api" + settings.AUTH_SESSION_HMAC_SECRET = SecretStr(TEST_SESSION_HMAC_SECRET) + + class MockKey: + key_id = "test-key" + key = "public_key" + + monkeypatch.setattr("api.auth.jwks_client", object()) + monkeypatch.setattr("api.auth._cached_oidc_signing_keys", (MockKey(),)) + + def reject_jwt_decode(*args, **kwargs): + raise RuntimeError("oidc rejected") + + monkeypatch.setattr(jwt, "decode", reject_jwt_decode) + token = _signed_session_token( + _valid_session_payload(), + header={"alg": "RS256", "typ": "JWT", "kid": "test-key"}, + ) + + try: + with pytest.raises(HTTPException) as exc: + await get_auth_context(authorization=f"Bearer {token}") + finally: + settings.OIDC_ISSUER_URL = previous_issuer_url + settings.OIDC_CLIENT_ID = previous_client_id + settings.AUTH_SESSION_HMAC_SECRET = previous_secret + + assert exc.value.status_code == 401 + + +@pytest.mark.asyncio +async def test_oidc_request_path_requires_preloaded_jwks(monkeypatch): + previous_issuer_url = settings.OIDC_ISSUER_URL + previous_client_id = settings.OIDC_CLIENT_ID + previous_secret = settings.AUTH_SESSION_HMAC_SECRET + settings.OIDC_ISSUER_URL = "http://localhost:8081/realms/naruon" + settings.OIDC_CLIENT_ID = "naruon-api" + settings.AUTH_SESSION_HMAC_SECRET = SecretStr(TEST_SESSION_HMAC_SECRET) + monkeypatch.setattr("api.auth._cached_oidc_signing_keys", ()) + + class NetworkFetchingJWKSClient: + def get_signing_key_from_jwt(self, token): + raise AssertionError("request path must not fetch JWKS") + + monkeypatch.setattr("api.auth.jwks_client", NetworkFetchingJWKSClient()) + token = _signed_session_token( + _valid_session_payload(), + header={"alg": "RS256", "typ": "JWT", "kid": "test-key"}, + ) + + try: + with pytest.raises(HTTPException) as exc: + await get_auth_context(authorization=f"Bearer {token}") + finally: + settings.OIDC_ISSUER_URL = previous_issuer_url + settings.OIDC_CLIENT_ID = previous_client_id + settings.AUTH_SESSION_HMAC_SECRET = previous_secret + + assert exc.value.status_code == 401 diff --git a/backend/tests/test_calendar_api.py b/backend/tests/test_calendar_api.py index 5946a50b5..f9181ecb7 100644 --- a/backend/tests/test_calendar_api.py +++ b/backend/tests/test_calendar_api.py @@ -392,7 +392,7 @@ def test_calendar_writeback_allows_org_admin_to_target_same_org_source( app, headers={ "X-User-Id": "org-admin", - "X-User-Role": "organization_admin", + "X-User-Role": "tenant_admin", "X-Organization-Id": "org-acme", }, ) @@ -410,7 +410,7 @@ def test_calendar_writeback_allows_org_admin_to_target_same_org_source( assert response.json()["target_source_id"] == "shared-calendar" -def test_calendar_writeback_allows_platform_admin_to_target_any_org_source( +def test_calendar_writeback_allows_system_admin_to_target_any_org_source( writeback_source_override, ): writeback_source_override( @@ -427,12 +427,12 @@ def test_calendar_writeback_allows_platform_admin_to_target_any_org_source( ) ] ) - platform_admin_client = TestClient( + system_admin_client = TestClient( app, - headers={"X-User-Id": "platform-ops", "X-User-Role": "platform_admin"}, + headers={"X-User-Id": "platform-ops", "X-User-Role": "system_admin"}, ) - response = platform_admin_client.post( + response = system_admin_client.post( "/api/calendar/writeback-intent", json={ "action": "create", diff --git a/backend/tests/test_config.py b/backend/tests/test_config.py index 529f521ec..0fb7ee7dc 100644 --- a/backend/tests/test_config.py +++ b/backend/tests/test_config.py @@ -135,3 +135,33 @@ def test_openai_config(): assert hasattr(settings, "OPENAI_EMBEDDING_MODEL") assert hasattr(settings, "OPENAI_MODEL") + + +def test_oidc_settings_must_be_configured_together(monkeypatch): + monkeypatch.setenv( + "DATABASE_URL", "postgresql+asyncpg://test:test@localhost:5432/test_db" + ) + monkeypatch.setenv("AUTH_SESSION_HMAC_SECRET", TEST_AUTH_SESSION_HMAC_SECRET) + monkeypatch.setenv("OIDC_ISSUER_URL", "https://login.example.com/realms/naruon") + monkeypatch.delenv("OIDC_CLIENT_ID", raising=False) + monkeypatch.delenv("OIDC_JWKS_URL", raising=False) + + with pytest.raises( + ValidationError, + match="OIDC_ISSUER_URL, OIDC_CLIENT_ID, and OIDC_JWKS_URL", + ): + _settings_without_env_file() + + +def test_oidc_settings_accept_complete_configuration(monkeypatch): + monkeypatch.setenv( + "DATABASE_URL", "postgresql+asyncpg://test:test@localhost:5432/test_db" + ) + monkeypatch.setenv("AUTH_SESSION_HMAC_SECRET", TEST_AUTH_SESSION_HMAC_SECRET) + monkeypatch.setenv("OIDC_ISSUER_URL", "https://login.example.com/realms/naruon") + monkeypatch.setenv("OIDC_CLIENT_ID", "naruon-api") + monkeypatch.setenv("OIDC_JWKS_URL", "https://login.example.com/realms/naruon/jwks") + + loaded_settings = _settings_without_env_file() + + assert loaded_settings.OIDC_CLIENT_ID == "naruon-api" diff --git a/backend/tests/test_dav_sync.py b/backend/tests/test_dav_sync.py index 4e24852ad..6c5987436 100644 --- a/backend/tests/test_dav_sync.py +++ b/backend/tests/test_dav_sync.py @@ -8,7 +8,8 @@ async def test_caldav_event_parsing_and_sync(): session_mock = AsyncMock() # Mock finding accounts account_mock = MagicMock() - account_mock.server_url = "https://caldav.example.com" + account_mock.id = 1 + account_mock.server_url = "https://alice:secret@caldav.example.com/calendars" account_mock.username = "user" account_mock.credentials_encrypted = "pass" @@ -20,6 +21,9 @@ async def test_caldav_event_parsing_and_sync(): await sync_caldav_accounts(session_mock, "user_1") # Should have logged parsing assert logger_mock.info.called + logged_url = logger_mock.debug.call_args.args[2] + assert logged_url == "https://caldav.example.com/calendars" + assert "secret" not in logged_url @pytest.mark.asyncio async def test_webdav_file_listing_and_sync(): diff --git a/backend/tests/test_llm_providers_api.py b/backend/tests/test_llm_providers_api.py index fa796af35..7fab567c1 100644 --- a/backend/tests/test_llm_providers_api.py +++ b/backend/tests/test_llm_providers_api.py @@ -104,7 +104,7 @@ def admin_client(): app, headers={ "X-User-Id": "admin", - "X-User-Role": "organization_admin", + "X-User-Role": "tenant_admin", "X-Organization-Id": "org-acme", }, ) as client: @@ -267,7 +267,7 @@ def test_llm_provider_model_declares_owner_scope_columns(): ) -def test_llm_provider_organization_admin_cannot_access_other_org_provider( +def test_llm_provider_tenant_admin_cannot_access_other_org_provider( admin_client, ): provider = LLMProvider( diff --git a/backend/tests/test_ontology_api.py b/backend/tests/test_ontology_api.py index 567ac320e..669e9d6b0 100644 --- a/backend/tests/test_ontology_api.py +++ b/backend/tests/test_ontology_api.py @@ -6,10 +6,11 @@ pytestmark = pytest.mark.usefixtures("dev_auth_dependency_overrides") class MockRow: - def __init__(self, sender_email, relationship_type, confidence_score): + def __init__(self, sender_email, relationship_type, confidence_score, parent_sender_email=None): self.sender_email = sender_email self.relationship_type = relationship_type self.confidence_score = confidence_score + self.parent_sender_email = parent_sender_email class MockResult: def __init__(self, items): @@ -26,7 +27,7 @@ def first(self): class MockSession: def __init__(self): - self.items = [MockRow("boss@example.com", "manager", 0.95)] + self.items = [MockRow("boss@example.com", "manager", 0.95, "ceo@example.com")] async def execute(self, stmt): compiled = str(stmt) @@ -46,6 +47,14 @@ async def commit(self): async def refresh(self, obj): pass +class ExistingRelationshipSession(MockSession): + def __init__(self): + super().__init__() + self.items = [MockRow("vendor@example.com", "vendor", 0.5, "buyer@example.com")] + + async def execute(self, stmt): + return MockResult(self.items) + async def override_get_db(): yield MockSession() @@ -63,6 +72,7 @@ def test_get_relationships(client: TestClient): items = resp.json() assert len(items) == 1 assert items[0]["sender_email"] == "boss@example.com" + assert items[0]["parent_sender_email"] == "ceo@example.com" assert items[0]["relationship_type"] == "manager" assert items[0]["next_action"] == "classify_sender" @@ -71,6 +81,7 @@ def test_create_relationship(client: TestClient): "/api/ontology/relationships", json={ "sender_email": "vendor@example.com", + "parent_sender_email": "buyer@example.com", "relationship_type": "vendor", "confidence_score": 0.8 } @@ -78,5 +89,31 @@ def test_create_relationship(client: TestClient): assert resp.status_code == 200 data = resp.json() assert data["sender_email"] == "vendor@example.com" + assert data["parent_sender_email"] == "buyer@example.com" assert data["relationship_type"] == "vendor" assert data["next_action"] == "prepare_response_draft" + + +def test_update_relationship_preserves_existing_parent_when_omitted(): + async def override_existing_get_db(): + yield ExistingRelationshipSession() + + app.dependency_overrides[get_db] = override_existing_get_db + try: + with TestClient(app, headers={"X-User-Id": "testuser"}) as test_client: + resp = test_client.post( + "/api/ontology/relationships", + json={ + "sender_email": "vendor@example.com", + "relationship_type": "customer", + "confidence_score": 0.7, + }, + ) + finally: + app.dependency_overrides.clear() + + assert resp.status_code == 200 + data = resp.json() + assert data["sender_email"] == "vendor@example.com" + assert data["parent_sender_email"] == "buyer@example.com" + assert data["relationship_type"] == "customer" diff --git a/backend/tests/test_prompts_api.py b/backend/tests/test_prompts_api.py index d586ff48e..dafe291cd 100644 --- a/backend/tests/test_prompts_api.py +++ b/backend/tests/test_prompts_api.py @@ -82,7 +82,7 @@ def auth_client(): app, headers={ "X-User-Id": "testuser", - "X-User-Role": "organization_admin", + "X-User-Role": "tenant_admin", "X-Organization-Id": "org-acme", }, ) as c: @@ -90,12 +90,12 @@ def auth_client(): @pytest.fixture -def orgless_platform_admin_client(): +def orgless_system_admin_client(): with TestClient( app, headers={ "X-User-Id": "platform-admin", - "X-User-Role": "platform_admin", + "X-User-Role": "system_admin", }, ) as c: yield c @@ -296,7 +296,7 @@ async def mock_execute(prompt_text, api_key, base_url, **kwargs): def test_prompt_test_does_not_use_org_provider_without_org_scope( - orgless_platform_admin_client, monkeypatch + orgless_system_admin_client, monkeypatch ): import api.prompts as prompts_module @@ -319,7 +319,7 @@ async def mock_execute(prompt_text, api_key, base_url, **kwargs): ) ) - response = orgless_platform_admin_client.post( + response = orgless_system_admin_client.post( "/api/prompts/test", json={"content": "Summarize this: {{email}}", "variables": {"email": "hi"}}, ) diff --git a/backend/tests/test_runner_config_api.py b/backend/tests/test_runner_config_api.py index e229cf6c6..4d3d351df 100644 --- a/backend/tests/test_runner_config_api.py +++ b/backend/tests/test_runner_config_api.py @@ -67,7 +67,7 @@ async def override_get_db(): app, headers={ "X-User-Id": "admin", - "X-User-Role": "organization_admin", + "X-User-Role": "tenant_admin", "X-Organization-Id": "org-acme", }, ) as c: @@ -87,7 +87,7 @@ async def override_get_db(): app, headers={ "X-User-Id": "org-admin-2", - "X-User-Role": "organization_admin", + "X-User-Role": "tenant_admin", "X-Organization-Id": "org-acme", }, ) as c: @@ -97,7 +97,7 @@ async def override_get_db(): @pytest.fixture -def platform_admin_client(mock_db): +def system_admin_client(mock_db): async def override_get_db(): yield mock_db @@ -107,7 +107,7 @@ async def override_get_db(): app, headers={ "X-User-Id": "platform-root", - "X-User-Role": "platform_admin", + "X-User-Role": "system_admin", "X-Organization-Id": "org-acme", }, ) as c: @@ -183,8 +183,8 @@ def test_runner_rotation_includes_connector_bootstrap_contract(admin_client): assert "registration_token" not in data["connector_manifest"] -def test_platform_admin_can_manage_runner_config(platform_admin_client): - rotate_response = platform_admin_client.post("/api/runner-config/rotate") +def test_system_admin_can_manage_runner_config(system_admin_client): + rotate_response = system_admin_client.post("/api/runner-config/rotate") assert rotate_response.status_code == 200 assert rotate_response.json()["workspace_id"] == "workspace-org-acme" @@ -199,7 +199,7 @@ async def override_get_db(): app, headers={ "X-User-Id": "admin", - "X-User-Role": "organization_admin", + "X-User-Role": "tenant_admin", }, ) as client: response = client.get("/api/runner-config") diff --git a/backend/tests/test_runner_connector.py b/backend/tests/test_runner_connector.py new file mode 100644 index 000000000..7d4142925 --- /dev/null +++ b/backend/tests/test_runner_connector.py @@ -0,0 +1,128 @@ +import json +from unittest.mock import AsyncMock + +import pytest + +from runner.connector import SelfHostedConnector + + +@pytest.mark.asyncio +async def test_handle_message_dispatches_fetch_imap(): + connector = SelfHostedConnector("ws://gateway.example/api/runner/ws", "token") + original_handler = connector._handle_fetch_imap + connector._handle_fetch_imap = AsyncMock(wraps=original_handler) + connector.send_response = AsyncMock() + + await connector.handle_message(json.dumps({"action": "fetch_imap", "account": "mailbox-1"})) + + connector._handle_fetch_imap.assert_awaited_once() + connector.send_response.assert_awaited_once_with( + {"status": "success", "action": "fetch_imap", "data": "IMAP data placeholder"} + ) + + +@pytest.mark.asyncio +async def test_handle_message_dispatches_send_smtp(): + connector = SelfHostedConnector("ws://gateway.example/api/runner/ws", "token") + original_handler = connector._handle_send_smtp + connector._handle_send_smtp = AsyncMock(wraps=original_handler) + connector.send_response = AsyncMock() + + await connector.handle_message(json.dumps({"action": "send_smtp", "account": "mailbox-1"})) + + connector._handle_send_smtp.assert_awaited_once() + connector.send_response.assert_awaited_once_with( + {"status": "success", "action": "send_smtp", "message_id": "mock_id_123"} + ) + + +@pytest.mark.asyncio +async def test_handle_message_reports_invalid_json(): + connector = SelfHostedConnector("ws://gateway.example/api/runner/ws", "token") + connector.send_response = AsyncMock() + + await connector.handle_message("{not-json") + + connector.send_response.assert_awaited_once_with( + {"status": "error", "action": None, "error": "invalid json"} + ) + + +@pytest.mark.asyncio +async def test_handle_message_reports_non_object_payload(): + connector = SelfHostedConnector("ws://gateway.example/api/runner/ws", "token") + connector._handle_fetch_imap = AsyncMock() + connector._handle_send_smtp = AsyncMock() + connector.send_response = AsyncMock() + + await connector.handle_message(json.dumps(["fetch_imap"])) + + connector._handle_fetch_imap.assert_not_awaited() + connector._handle_send_smtp.assert_not_awaited() + connector.send_response.assert_awaited_once_with( + {"status": "error", "action": None, "error": "invalid payload"} + ) + + +@pytest.mark.asyncio +async def test_handle_message_reports_unknown_action(): + connector = SelfHostedConnector("ws://gateway.example/api/runner/ws", "token") + connector._handle_fetch_imap = AsyncMock() + connector._handle_send_smtp = AsyncMock() + connector.send_response = AsyncMock() + + await connector.handle_message(json.dumps({"action": "sync_unknown", "account": "mailbox-1"})) + + connector._handle_fetch_imap.assert_not_awaited() + connector._handle_send_smtp.assert_not_awaited() + connector.send_response.assert_awaited_once_with( + {"status": "error", "action": "sync_unknown", "error": "unknown action"} + ) + + +@pytest.mark.asyncio +async def test_handle_fetch_imap_requires_account(): + connector = SelfHostedConnector("ws://gateway.example/api/runner/ws", "token") + connector.send_response = AsyncMock() + + await connector.handle_message(json.dumps({"action": "fetch_imap"})) + + connector.send_response.assert_awaited_once_with( + {"status": "error", "action": "fetch_imap", "error": "missing account"} + ) + + +@pytest.mark.asyncio +async def test_handle_fetch_imap_rejects_non_string_account(): + connector = SelfHostedConnector("ws://gateway.example/api/runner/ws", "token") + connector.send_response = AsyncMock() + + await connector.handle_message(json.dumps({"action": "fetch_imap", "account": 7})) + + connector.send_response.assert_awaited_once_with( + {"status": "error", "action": "fetch_imap", "error": "missing account"} + ) + + +@pytest.mark.asyncio +async def test_handle_send_smtp_requires_account(): + connector = SelfHostedConnector("ws://gateway.example/api/runner/ws", "token") + connector.send_response = AsyncMock() + + await connector.handle_message(json.dumps({"action": "send_smtp"})) + + connector.send_response.assert_awaited_once_with( + {"status": "error", "action": "send_smtp", "error": "missing account"} + ) + + +@pytest.mark.asyncio +async def test_handle_send_smtp_rejects_blank_account(): + connector = SelfHostedConnector("ws://gateway.example/api/runner/ws", "token") + connector.send_response = AsyncMock() + + await connector.handle_message(json.dumps({"action": "send_smtp", "account": " "})) + + connector.send_response.assert_awaited_once_with( + {"status": "error", "action": "send_smtp", "error": "missing account"} + ) diff --git a/backend/tests/test_runtime_config_api.py b/backend/tests/test_runtime_config_api.py index 04fca223f..231881c28 100644 --- a/backend/tests/test_runtime_config_api.py +++ b/backend/tests/test_runtime_config_api.py @@ -1,3 +1,10 @@ +import base64 +import hashlib +import hmac +import json +import os +import time + import pytest from fastapi.testclient import TestClient from main import app @@ -9,8 +16,54 @@ def client(): yield c -def test_runtime_config_returns_non_secret_data(client): +def _base64url_encode(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + +def _signed_session_token() -> str: + header_bytes = json.dumps( + {"alg": "HS256", "typ": "JWT"}, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + payload_bytes = json.dumps( + { + "ver": 1, + "iss": "naruon-control-plane", + "aud": "naruon-api", + "sub": "alice", + "role": "tenant_admin", + "org": "org-acme", + "groups": ["group-1"], + "workspace": "workspace-org-acme", + "exp": int(time.time()) + 300, + }, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + header_segment = _base64url_encode(header_bytes) + payload_segment = _base64url_encode(payload_bytes) + signing_input = f"{header_segment}.{payload_segment}" + signature = hmac.new( + os.environ["AUTH_SESSION_HMAC_SECRET"].encode("utf-8"), + signing_input.encode("ascii"), + hashlib.sha256, + ).digest() + return f"{signing_input}.{_base64url_encode(signature)}" + + +def test_runtime_config_rejects_missing_signed_session(client): response = client.get("/api/runtime-config") + + assert response.status_code == 401 + assert response.json() == {"detail": "Authentication required"} + + +def test_runtime_config_returns_non_secret_data(client): + response = client.get( + "/api/runtime-config", + headers={"Authorization": f"Bearer {_signed_session_token()}"}, + ) assert response.status_code == 200 data = response.json() assert "product_name" in data diff --git a/backend/tests/test_tasks_api.py b/backend/tests/test_tasks_api.py index 2077cc745..964a1d67d 100644 --- a/backend/tests/test_tasks_api.py +++ b/backend/tests/test_tasks_api.py @@ -49,7 +49,7 @@ def _valid_session_payload(**overrides: object) -> dict[str, object]: "iss": "naruon-control-plane", "aud": "naruon-api", "sub": "alice", - "role": "organization_admin", + "role": "tenant_admin", "org": "org-acme", "groups": [], "workspace": "workspace-org-acme", diff --git a/backend/tests/test_tenant_config_api.py b/backend/tests/test_tenant_config_api.py index 9bbd7f896..2ff7b7e3e 100644 --- a/backend/tests/test_tenant_config_api.py +++ b/backend/tests/test_tenant_config_api.py @@ -145,7 +145,7 @@ def test_tenant_config_stays_user_owned_even_for_admin_headers(client): json={"user_id": "member-user", "smtp_server": "smtp.example.com"}, headers={ "X-User-Id": "admin", - "X-User-Role": "organization_admin", + "X-User-Role": "tenant_admin", "X-Organization-Id": "org-acme", }, ) @@ -334,7 +334,7 @@ def test_tenant_config_get_rejects_cross_user_access(client): params={"user_id": "member-user"}, headers={ "X-User-Id": "admin", - "X-User-Role": "platform_admin", + "X-User-Role": "system_admin", "X-Organization-Id": "org-acme", }, ) @@ -362,7 +362,7 @@ def test_global_config_allows_admin(client): "/api/config/global", headers={ "X-User-Id": "admin-user", - "X-User-Role": "organization_admin", + "X-User-Role": "tenant_admin", "X-Organization-Id": "org-acme", "X-Workspace-Id": "ws-1", }, diff --git a/backend/tests/test_webdav_api.py b/backend/tests/test_webdav_api.py index 264a1e9c8..906e53382 100644 --- a/backend/tests/test_webdav_api.py +++ b/backend/tests/test_webdav_api.py @@ -1,25 +1,49 @@ +import uuid + +import asyncpg +import httpx import pytest from fastapi.testclient import TestClient +from sqlalchemy import text +from sqlalchemy.exc import OperationalError +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from core.config import settings from main import app from db.session import get_db -from services.webdav_service import webdav_service +from services.webdav_service import WebDavService, webdav_service pytestmark = pytest.mark.usefixtures("dev_auth_dependency_overrides") @pytest.fixture(autouse=True) def stub_webdav_service(monkeypatch): - monkeypatch.setattr( - webdav_service, - "get_connected_accounts", - lambda user_id: [{"account_id": 1, "server_url": "https://webdav.naruon.net", "username": "demo_user"}] if user_id == "alice" else [], - ) - monkeypatch.setattr( - webdav_service, - "get_project_folders", - lambda user_id: [ + async def fake_accounts(db, user_id): + return [ + { + "account_id": 1, + "server_url": "https://webdav.naruon.net", + "username": "demo_user", + } + ] if user_id == "alice" else [] + + async def fake_folders(db, user_id): + return [ {"folder_id": 1, "project_name": "Naruon Roadmap 2026", "webdav_path": "/Projects/Naruon_Roadmap_2026"}, {"folder_id": 2, "project_name": "Marketing Assets", "webdav_path": "/Projects/Marketing_Assets"} - ] if user_id == "alice" else [], + ] if user_id == "alice" else [] + + async def fake_intent(db, user_id, target_account_id=None): + return webdav_service.determine_webdav_writeback_intent_from_accounts( + await fake_accounts(db, user_id), + target_account_id=target_account_id, + ) + + monkeypatch.setattr(webdav_service, "get_connected_accounts_from_db", fake_accounts) + monkeypatch.setattr(webdav_service, "get_project_folders_from_db", fake_folders) + monkeypatch.setattr( + webdav_service, + "determine_webdav_writeback_intent_from_db", + fake_intent, ) @pytest.fixture @@ -45,3 +69,170 @@ def test_get_project_folders(auth_client): assert len(body) == 2 assert body[0]["project_name"] == "Naruon Roadmap 2026" assert body[1]["project_name"] == "Marketing Assets" + +def test_get_webdav_writeback_intent(auth_client): + response = auth_client.post("/api/webdav/writeback-intent", json={}) + assert response.status_code == 200, response.text + body = response.json() + assert body["intent"] == "writeback" + assert body["requires_if_match"] is True + assert body["source_id"] == 1 + assert body["server_url"] == "https://webdav.naruon.net" + assert body["provenance"] == "server-authoritative" + + +def test_get_webdav_writeback_intent_with_target_account(auth_client): + response = auth_client.post( + "/api/webdav/writeback-intent", + json={"target_account_id": 1}, + ) + assert response.status_code == 200, response.text + body = response.json() + assert body["intent"] == "writeback" + assert body["source_id"] == 1 + assert body["server_url"] == "https://webdav.naruon.net" + assert body["requires_if_match"] is True + assert body["provenance"] == "server-authoritative" + + +def test_get_webdav_writeback_intent_with_invalid_target_account(auth_client): + response = auth_client.post( + "/api/webdav/writeback-intent", + json={"target_account_id": 999}, + ) + assert response.status_code == 422 + assert response.json()["detail"] == "Requested WebDAV account was not found." + + +def test_get_webdav_writeback_intent_no_accounts(): + with TestClient( + app, + headers={"X-User-Id": "bob", "X-Organization-Id": "org-acme"}, + ) as client: + response = client.post("/api/webdav/writeback-intent", json={}) + assert response.status_code == 422 + assert response.json()["detail"] == "No connected WebDAV accounts found." + + +@pytest.mark.asyncio +async def test_webdav_writeback_intent_real_postgres_smoke(monkeypatch): + account_id = 10_000 + (uuid.uuid4().int % 1_000_000) + user_id = f"webdav-smoke-{account_id}" + monkeypatch.setattr( + webdav_service, + "get_connected_accounts_from_db", + WebDavService.get_connected_accounts_from_db.__get__( + webdav_service, + WebDavService, + ), + ) + monkeypatch.setattr( + webdav_service, + "determine_webdav_writeback_intent_from_db", + WebDavService.determine_webdav_writeback_intent_from_db.__get__( + webdav_service, + WebDavService, + ), + ) + + engine = create_async_engine(settings.DATABASE_URL) + try: + async with engine.begin() as conn: + await conn.execute(text("SELECT 1")) + await conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS webdav_accounts ( + account_id INTEGER PRIMARY KEY, + user_id VARCHAR NOT NULL, + server_url VARCHAR NOT NULL, + username VARCHAR NOT NULL, + credentials_encrypted VARCHAR NOT NULL, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + ) + await conn.execute( + text( + """ + DELETE FROM webdav_accounts + WHERE account_id = :account_id + """ + ), + {"account_id": account_id}, + ) + await conn.execute( + text( + """ + INSERT INTO webdav_accounts ( + account_id, + user_id, + server_url, + username, + credentials_encrypted + ) + VALUES ( + :account_id, + :user_id, + :server_url, + :username, + :credentials_encrypted + ) + """ + ), + { + "account_id": account_id, + "user_id": user_id, + "server_url": "https://real-webdav.naruon.net", + "username": user_id, + "credentials_encrypted": "test-only-placeholder", + }, + ) + except ( + ConnectionRefusedError, + OSError, + OperationalError, + asyncpg.CannotConnectNowError, + asyncpg.InvalidAuthorizationSpecificationError, + asyncpg.InvalidCatalogNameError, + asyncpg.InvalidPasswordError, + ): + await engine.dispose() + pytest.skip("PostgreSQL smoke path unavailable") + except Exception: + await engine.dispose() + raise + + session_factory = async_sessionmaker(engine, expire_on_commit=False) + + async def override_real_db(): + async with session_factory() as session: + yield session + + app.dependency_overrides[get_db] = override_real_db + try: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient( + transport=transport, + base_url="http://testserver", + headers={"X-User-Id": user_id, "X-Organization-Id": "org-acme"}, + ) as client: + response = await client.post( + "/api/webdav/writeback-intent", + json={"target_account_id": account_id}, + ) + finally: + app.dependency_overrides.pop(get_db, None) + async with engine.begin() as conn: + await conn.execute( + text("DELETE FROM webdav_accounts WHERE account_id = :account_id"), + {"account_id": account_id}, + ) + await engine.dispose() + + assert response.status_code == 200, response.text + body = response.json() + assert body["source_id"] == account_id + assert body["server_url"] == "https://real-webdav.naruon.net" + assert body["provenance"] == "server-authoritative" diff --git a/docs/operations/auth-key-management.md b/docs/operations/auth-key-management.md index cffa14df0..97c312671 100644 --- a/docs/operations/auth-key-management.md +++ b/docs/operations/auth-key-management.md @@ -86,8 +86,9 @@ - Private FastAPI `/api/*` routers are included with a default `get_auth_context` dependency so authentication is deny-by-default at router registration time. Public endpoints must be explicit exceptions, currently - `/api/runtime-config` and `/`. Prometheus `/metrics` is opt-in and must stay - behind a trusted scrape path or reverse proxy access policy when enabled. + `/`. Runtime feature/configuration endpoints stay signed-session protected. + Prometheus `/metrics` is opt-in and must stay behind a trusted scrape path or + reverse proxy access policy when enabled. - Authentication is not sufficient for privileged control-plane resources: LLM provider registry reads and writes require `platform_admin` or `organization_admin` signed role claims. diff --git a/docs/plans/2026-05-11-runtime-config-api.md b/docs/plans/2026-05-11-runtime-config-api.md index 9dd113a12..f5596b093 100644 --- a/docs/plans/2026-05-11-runtime-config-api.md +++ b/docs/plans/2026-05-11-runtime-config-api.md @@ -9,12 +9,12 @@ Remove build-time-only frontend API configuration assumptions and introduce a ba ## Stepwise Tasks ### Task 1: Backend Endpoint Implementation -1. In `backend/api/tenant_config.py` (or a new router `runtime_config.py`), implement `GET /api/runtime-config`. -2. The endpoint should return non-secret info: `product_name`, `version`, `features` (e.g. `{"llm_enabled": True}`), etc. -3. Add a basic Pytest to assert it returns 200 OK and valid JSON without secrets. +1. In `backend/api/tenant_config.py` (or a new router `runtime_config.py`), implement `GET /api/runtime-config` as a signed-session protected private API route. +2. The endpoint should return non-secret info: `product_name`, `version`, `features` (e.g. `{"llm_enabled": True}`), etc., only after authentication because feature flags and provider state can aid reconnaissance. +3. Add Pytest coverage that unauthenticated requests return 401 and signed-session requests return valid JSON without secrets. ### Task 2: Frontend Runtime Config Client -1. Create `frontend/src/lib/runtime-config.ts` which fetches from `/api/runtime-config`. +1. Create `frontend/src/lib/runtime-config.ts` which fetches from `/api/runtime-config` through the signed-session API client. 2. Create `frontend/src/lib/api-client.ts` to manage API URLs dynamically using the fetched config or a safe fallback. 3. Provide React context or a store for config state. diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index aaa4a91c5..81a61bd05 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -1,14 +1,25 @@ import { defineConfig, devices } from '@playwright/test'; +const devServerPort = Number.parseInt(process.env.PLAYWRIGHT_PORT ?? '18080', 10); +const devServerUrl = `http://127.0.0.1:${devServerPort}`; + export default defineConfig({ testDir: './tests/e2e', - timeout: 30_000, + timeout: 60_000, expect: { - timeout: 10_000, + timeout: 15_000, }, use: { - baseURL: process.env.LIVE_BASE_URL ?? 'http://127.0.0.1:18080', + baseURL: process.env.LIVE_BASE_URL ?? devServerUrl, trace: 'retain-on-failure', + actionTimeout: 15_000, + navigationTimeout: 60_000, + }, + webServer: { + command: `npm run dev -- -p ${devServerPort}`, + port: devServerPort, + reuseExistingServer: !process.env.CI, + timeout: 120_000, }, projects: [ { diff --git a/frontend/src/components/DashboardLayout.test.tsx b/frontend/src/components/DashboardLayout.test.tsx index 6724a1627..8e15417ca 100644 --- a/frontend/src/components/DashboardLayout.test.tsx +++ b/frontend/src/components/DashboardLayout.test.tsx @@ -1,7 +1,12 @@ /* @vitest-environment jsdom */ import React, { act } from "react"; import { createRoot, type Root } from "react-dom/client"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("next/navigation", () => ({ + usePathname: () => window.location.pathname, + useSearchParams: () => new URLSearchParams(window.location.search), +})); import { DashboardLayout } from "./DashboardLayout"; @@ -132,6 +137,7 @@ describe("DashboardLayout", () => { expect(mobileMenu?.textContent ?? "").toContain("도움"); expect(mobileMenu?.querySelector('a[href="/"]')?.textContent).toContain("홈"); expect(mobileMenu?.querySelector('a[href="/mail"]')?.textContent).toContain("메일"); + expect(mobileMenu?.querySelector('a[href="/mail?folder=inbox"]')?.textContent).toContain("받은 메일"); expect(mobileMenu?.querySelector('a[href="/mail?folder=sent"]')?.textContent).toContain("보낸 메일"); expect(mobileMenu?.querySelector('a[href="/calendar"]')?.textContent).toContain("일정"); expect(mobileMenu?.querySelector('a[href="/tasks"]')?.textContent).toContain("작업"); @@ -216,6 +222,44 @@ describe("DashboardLayout", () => { expect(mobileHrefs).toEqual(["/", "/mail", "/calendar", "/tasks", "/projects", "/search", "/ai-hub", "/data", "/security", "/settings"]); }); + it("keeps query-based mail shortcuts mutually exclusive in the mobile drawer", () => { + window.history.replaceState(null, "", "/mail?folder=sent"); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + act(() => { + root?.render( + +
Mail workspace content
+
, + ); + }); + + act(() => { + container + ?.querySelector('button[aria-label="워크스페이스 메뉴 열기"]') + ?.click(); + }); + + const mobileMenu = container.querySelector('#mobile-workspace-menu'); + expect( + mobileMenu + ?.querySelector('a[href="/mail?folder=sent"]') + ?.getAttribute("aria-current"), + ).toBe("page"); + expect( + mobileMenu + ?.querySelector('a[href="/mail?folder=inbox"]') + ?.getAttribute("aria-current"), + ).toBeNull(); + expect( + mobileMenu + ?.querySelector('a[href="/mail?folder=starred"]') + ?.getAttribute("aria-current"), + ).toBeNull(); + }); + it("clears stale mobile hashes and resets the mobile workspace store when switching startup back to dashboard", () => { container = document.createElement("div"); document.body.appendChild(container); diff --git a/frontend/src/components/DashboardLayout.tsx b/frontend/src/components/DashboardLayout.tsx index f9573a6c4..0a9e922e0 100644 --- a/frontend/src/components/DashboardLayout.tsx +++ b/frontend/src/components/DashboardLayout.tsx @@ -3,7 +3,7 @@ import Image from 'next/image'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState, useSyncExternalStore } from 'react'; import { Bell, Briefcase, @@ -30,7 +30,7 @@ import { setMobileWorkspaceView, useMobileWorkspaceView } from '@/lib/mobile-wor import { setWorkspaceStartupView, useWorkspaceStartupView, type WorkspaceStartupView } from '@/lib/workspace-preferences'; const mailNavItems = [ - { label: '받은 메일', description: '우선순위 인박스', icon: Inbox, href: '/mail', available: true }, + { label: '받은 메일', description: '우선순위 인박스', icon: Inbox, href: '/mail?folder=inbox', available: true }, { label: '중요 메일', description: '중요 표시된 메일', icon: Star, href: '/mail?folder=starred', available: true }, { label: '보낸 메일', description: '답변 추적 대상', icon: Send, href: '/mail?folder=sent', available: true }, { label: '임시 보관함', description: '작성 중인 메일', icon: FileText, href: '/mail?folder=drafts', available: true }, @@ -79,20 +79,85 @@ const headerActions = [ +const locationChangeEvent = 'naruon:location-change'; +let historyListenerInstalled = false; + +function emitLocationChange() { + window.dispatchEvent(new Event(locationChangeEvent)); +} + +function installHistoryListener() { + if (typeof window === 'undefined' || historyListenerInstalled) return; + const pushState = window.history.pushState; + const replaceState = window.history.replaceState; + + window.history.pushState = function patchedPushState(this: History, ...args: Parameters) { + pushState.apply(this, args); + emitLocationChange(); + } as History['pushState']; + + window.history.replaceState = function patchedReplaceState(this: History, ...args: Parameters) { + replaceState.apply(this, args); + emitLocationChange(); + } as History['replaceState']; + + historyListenerInstalled = true; +} + +function subscribeToLocationChanges(callback: () => void) { + if (typeof window === 'undefined') return () => {}; + installHistoryListener(); + window.addEventListener(locationChangeEvent, callback); + window.addEventListener('popstate', callback); + return () => { + window.removeEventListener(locationChangeEvent, callback); + window.removeEventListener('popstate', callback); + }; +} + +function getCurrentSearch() { + return typeof window === 'undefined' ? '' : window.location.search; +} + +function getServerSearch() { + return ''; +} + +function useCurrentSearchParams() { + const search = useSyncExternalStore(subscribeToLocationChanges, getCurrentSearch, getServerSearch); + return useMemo(() => new URLSearchParams(search), [search]); +} + function splitHref(href: string) { - const [path, hash = ''] = href.split('#'); - return { path: path.split('?')[0] || '/', hash: hash ? `#${hash}` : '' }; + const [pathWithQuery, hash = ''] = href.split('#'); + const [path = '/', query = ''] = pathWithQuery.split('?'); + return { path: path || '/', query, hash: hash ? `#${hash}` : '' }; } -function isActivePath(pathname: string | null, href: string, currentHash = '') { +type SearchParamsLike = Pick; + +function isActivePath( + pathname: string | null, + href: string, + currentHash = '', + currentSearchParams?: SearchParamsLike | null, +) { if (!pathname) return false; - const { path, hash } = splitHref(href); + const { path, query, hash } = splitHref(href); if (hash) { return pathname === path && currentHash === hash; } - return path === '/' + const pathMatch = path === '/' ? pathname === '/' : pathname === path || pathname.startsWith(`${path}/`); + if (!pathMatch) return false; + if (!query) return true; + if (!currentSearchParams) return false; + const targetSearchParams = new URLSearchParams(query); + for (const [key, value] of targetSearchParams.entries()) { + if (currentSearchParams.get(key) !== value) return false; + } + return true; } export function NavLink({ @@ -109,6 +174,7 @@ export function NavLink({ icon: React.ComponentType<{ className?: string; 'aria-hidden'?: boolean }>; }) { const pathname = usePathname(); + const searchParams = useCurrentSearchParams(); const [currentHash, setCurrentHash] = useState(''); useEffect(() => { @@ -124,7 +190,7 @@ export function NavLink({ }; }, [pathname]); - const active = isActivePath(pathname, href, currentHash); + const active = isActivePath(pathname, href, currentHash, searchParams); if (!available) { return ( @@ -177,7 +243,8 @@ function PrimaryNavLink({ icon: React.ComponentType<{ className?: string; 'aria-hidden'?: boolean }>; }) { const pathname = usePathname(); - const active = isActivePath(pathname, href); + const searchParams = useCurrentSearchParams(); + const active = isActivePath(pathname, href, '', searchParams); return ( -