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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,12 @@ in this repo.
reimplement the orchestrator catalog in this repo. Keep the existing
owner-scoped tools and opt-in writeback surface.

- Noema gateway setup uses the signed `GET`/`PUT /api/noema-gateway` route.
It is scoped to the authenticated `(user_id, organization_id)` pair, stores
the token through `EncryptedString`, returns only `has_token` readiness, and
records generic audit events. Do not add target-user or mailbox credential
fields to this route without a separate membership/delegation ADR.

### This repo's role in the ecosystem

- **This repo (naruon) is the ECOSYSTEM HUB:** email/PIM that DOM-decomposes
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
## [Unreleased]
- **Noema gateway setup:** added signed-session `GET`/`PUT /api/noema-gateway`
settings with HTTPS `/v1` allowlist validation, Fernet-backed token storage,
masked readiness responses, and generic audit records. The route keeps the
existing per-user organization scope and does not expose gateway tokens.
Doctoring records the OWASP ASVS 5.0.0 and NIST SP 800-63B-4 evidence mapping.
- **Noema LLM routing through contextual-orchestrator.**
`run_noema_agent` no longer calls `resolve_runtime_llm_provider` or a
tenant `gpt-4o` chat model. Completions go to the orchestrator gateway
Expand Down
170 changes: 170 additions & 0 deletions backend/api/noema_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
"""Signed-session settings for the per-user Noema gateway credential."""

from __future__ import annotations

import hashlib

from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict
from sqlalchemy.ext.asyncio import AsyncSession

from api.auth import AuthContext, get_auth_context
from db.models import AuditLog, SecurityAuditEvent, TenantConfig
from db.session import get_db
from services.llm_provider_urls import validate_llm_provider_base_url_async
from services.orchestrator_gateway import validate_orchestrator_gateway_url
from services.tenant_config_scope import (
get_scoped_tenant_config,
new_scoped_tenant_config,
)

router = APIRouter(prefix="/api/noema-gateway", tags=["noema-gateway"])


class NoemaGatewayUpdate(BaseModel):
"""Optional values for the signed-session user's Noema gateway."""

model_config = ConfigDict(extra="forbid")

base_url: str | None = None
token: str | None = None


class NoemaGatewayResponse(BaseModel):
"""Safe gateway state that never returns the Fernet-protected token."""

base_url: str | None = None
configured: bool = False
has_token: bool = False


def _resource_uid(auth_context: AuthContext) -> str:
"""Return a stable, non-secret audit identifier for the scoped setting."""
scope = f"{auth_context.organization_id or ''}:{auth_context.user_id}"
digest = hashlib.sha256(scope.encode("utf-8")).hexdigest()[:16]
return f"noema_gateway:{digest}"


async def _validated_base_url(value: str) -> str:
"""Validate the HTTPS /v1 shape and the configured global-host policy."""
try:
shaped_url = validate_orchestrator_gateway_url(value)
normalized_url = await validate_llm_provider_base_url_async(shaped_url)
if not normalized_url:
raise ValueError("gateway host is not allowlisted")
return validate_orchestrator_gateway_url(normalized_url)
except ValueError as exc:
raise HTTPException(
status_code=422,
detail="Noema gateway base URL is not allowed",
) from exc


def _clean_token(value: str | None) -> str | None:
"""Normalize a submitted token without recording or returning its value."""
if value is None:
return None
token = value.strip()
if not token or token == "*" * 8:
return None
if any(ord(character) < 32 or ord(character) == 127 for character in token):
raise HTTPException(status_code=422, detail="Noema gateway token is invalid")
return token


def _response(config: TenantConfig | None) -> NoemaGatewayResponse:
"""Build a response containing only non-secret gateway state."""
if config is None:
return NoemaGatewayResponse()
has_token = bool(config.noema_orchestrator_token)
return NoemaGatewayResponse(
base_url=config.noema_orchestrator_base_url,
configured=bool(config.noema_orchestrator_base_url and has_token),
has_token=has_token,
)


@router.get("", response_model=NoemaGatewayResponse)
async def get_noema_gateway(
db: AsyncSession = Depends(get_db),
auth_context: AuthContext = Depends(get_auth_context),
) -> NoemaGatewayResponse:
"""Return the signed-session user's scoped gateway readiness state."""
config = await get_scoped_tenant_config(
db, auth_context.user_id, auth_context.organization_id
)
return _response(config)


@router.put("", response_model=NoemaGatewayResponse)
async def update_noema_gateway(
update: NoemaGatewayUpdate,
db: AsyncSession = Depends(get_db),
auth_context: AuthContext = Depends(get_auth_context),
) -> NoemaGatewayResponse:
"""Persist the current user's gateway settings with an auditable change."""
updates = update.model_dump(exclude_unset=True)
if not updates:
raise HTTPException(status_code=422, detail="No gateway settings supplied")

config = await get_scoped_tenant_config(
db, auth_context.user_id, auth_context.organization_id
)
if config is None:
config = new_scoped_tenant_config(
user_id=auth_context.user_id,
organization_id=auth_context.organization_id,
)
db.add(config)

if "token" in updates:
token = _clean_token(updates["token"])
if token is not None:
config.noema_orchestrator_token = token
elif not config.noema_orchestrator_token:
raise HTTPException(status_code=422, detail="Noema gateway token is required")

if "base_url" in updates:
config.noema_orchestrator_base_url = await _validated_base_url(
updates["base_url"] or ""
)

if not config.noema_orchestrator_base_url or not config.noema_orchestrator_token:
raise HTTPException(
status_code=422,
detail="Noema gateway base URL and token are required",
)

resource_uid = _resource_uid(auth_context)
db.add(
AuditLog(
user_id=auth_context.user_id,
action="update",
resource_type="noema_gateway",
resource_id=resource_uid,
details="Updated Noema gateway settings",
)
)
db.add(
SecurityAuditEvent(
actor_user_id=auth_context.user_id,
actor_role=auth_context.role,
organization_id=auth_context.organization_id,
workspace_id=auth_context.workspace_id,
event_action="update",
resource_type="noema_gateway",
resource_uid=resource_uid,
evidence_source="api.noema_config",
detail_text="Updated Noema gateway settings",
)
)
try:
await db.commit()
except Exception as exc:
if "ENCRYPTION_KEY is required" not in str(exc):
raise
raise HTTPException(
status_code=503,
detail="Server encryption key is not configured. Contact your workspace administrator.",
) from exc
return _response(config)
2 changes: 2 additions & 0 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from api.ai_hub import router as ai_hub_router
from api.projects import router as projects_router
from api.session import router as auth_session_router
from api.noema_config import router as noema_config_router
from core.config import canonical_origin, settings
from core.telemetry import setup_telemetry
from core.version import get_release_version
Expand Down Expand Up @@ -239,6 +240,7 @@ async def add_security_headers(request: Request, call_next):
app.include_router(ai_hub_router, dependencies=PRIVATE_API_DEPENDENCIES)
app.include_router(projects_router, dependencies=PRIVATE_API_DEPENDENCIES)
app.include_router(auth_session_router, dependencies=PRIVATE_API_DEPENDENCIES)
app.include_router(noema_config_router, dependencies=PRIVATE_API_DEPENDENCIES)


@app.get("/")
Expand Down
Loading