From 9a9e065921e6b1ed250957615ee10f4f6ab85a20 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 16:27:24 +0900 Subject: [PATCH 1/2] feat(noema): add signed gateway settings --- AGENTS.md | 6 + CHANGELOG.md | 4 + backend/api/noema_config.py | 170 ++++++++++ backend/main.py | 2 + backend/tests/test_noema_config_api.py | 305 ++++++++++++++++++ .../adr/0005-signed-noema-gateway-settings.md | 47 +++ docs/adr/README.md | 1 + docs/architecture/noema-decision-agent.md | 6 + 8 files changed, 541 insertions(+) create mode 100644 backend/api/noema_config.py create mode 100644 backend/tests/test_noema_config_api.py create mode 100644 docs/adr/0005-signed-noema-gateway-settings.md diff --git a/AGENTS.md b/AGENTS.md index bacb7cc4e..eeb2c71ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2839d0bc5..55768d0c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,8 @@ ## [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. - **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 diff --git a/backend/api/noema_config.py b/backend/api/noema_config.py new file mode 100644 index 000000000..66322049c --- /dev/null +++ b/backend/api/noema_config.py @@ -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) diff --git a/backend/main.py b/backend/main.py index 51b054dbf..6dfe5fe38 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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 @@ -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("/") diff --git a/backend/tests/test_noema_config_api.py b/backend/tests/test_noema_config_api.py new file mode 100644 index 000000000..a34d6b6a0 --- /dev/null +++ b/backend/tests/test_noema_config_api.py @@ -0,0 +1,305 @@ +import pytest +from httpx import ASGITransport, AsyncClient + +from api.auth import AuthContext, get_auth_context +from db.models import AuditLog, SecurityAuditEvent, TenantConfig +from db.session import get_db +from main import app + + +class _Result: + def __init__(self, value): + self.value = value + + def scalar_one_or_none(self): + return self.value + + +class _Session: + def __init__(self, config=None, commit_error=None): + self.config = config + self.commit_error = commit_error + self.added = [] + self.committed = False + + async def execute(self, _query): + return _Result(self.config) + + def add(self, value): + self.added.append(value) + if isinstance(value, TenantConfig): + self.config = value + + async def commit(self): + self.committed = True + if self.commit_error is not None: + raise self.commit_error + + +@pytest.fixture +def auth_context(): + return AuthContext( + user_id="user-1", + role="member", + organization_id="org-1", + group_ids=(), + workspace_id="workspace-org-1", + ) + + +@pytest.fixture +def override_dependencies(auth_context): + session = _Session() + + async def get_test_db(): + yield session + + async def get_test_auth(): + return auth_context + + app.dependency_overrides[get_db] = get_test_db + app.dependency_overrides[get_auth_context] = get_test_auth + yield session + app.dependency_overrides.pop(get_db, None) + app.dependency_overrides.pop(get_auth_context, None) + + +async def _client(): + return AsyncClient(transport=ASGITransport(app=app), base_url="http://test") + + +async def _allow_gateway_url(value): + return value + + +@pytest.mark.asyncio +async def test_noema_gateway_update_masks_token_and_writes_audit( + override_dependencies, monkeypatch +): + monkeypatch.setattr( + "api.noema_config.validate_llm_provider_base_url_async", + _allow_gateway_url, + ) + + async with await _client() as client: + response = await client.put( + "/api/noema-gateway", + json={ + "base_url": "https://orchestrator.example/v1", + "token": "gateway-secret", + }, + ) + + assert response.status_code == 200 + assert response.json() == { + "base_url": "https://orchestrator.example/v1", + "configured": True, + "has_token": True, + } + config = override_dependencies.config + assert config.noema_orchestrator_token == "gateway-secret" + assert "gateway-secret" not in response.text + assert any(isinstance(item, AuditLog) for item in override_dependencies.added) + event = next( + item + for item in override_dependencies.added + if isinstance(item, SecurityAuditEvent) + ) + assert event.resource_type == "noema_gateway" + assert event.detail_text == "Updated Noema gateway settings" + assert "gateway-secret" not in event.detail_text + + +@pytest.mark.asyncio +async def test_noema_gateway_get_returns_readiness_without_secret( + override_dependencies, monkeypatch +): + monkeypatch.setattr( + "api.noema_config.validate_llm_provider_base_url_async", + _allow_gateway_url, + ) + override_dependencies.config = TenantConfig( + user_id="user-1", + organization_id="org-1", + noema_orchestrator_base_url="https://orchestrator.example/v1", + noema_orchestrator_token="gateway-secret", + ) + + async with await _client() as client: + response = await client.get("/api/noema-gateway") + + assert response.status_code == 200 + assert response.json() == { + "base_url": "https://orchestrator.example/v1", + "configured": True, + "has_token": True, + } + assert "gateway-secret" not in response.text + + +@pytest.mark.asyncio +async def test_noema_gateway_get_without_config_is_not_ready(override_dependencies): + async with await _client() as client: + response = await client.get("/api/noema-gateway") + + assert response.status_code == 200 + assert response.json() == { + "base_url": None, + "configured": False, + "has_token": False, + } + + +@pytest.mark.asyncio +async def test_noema_gateway_rejects_unallowlisted_normalized_url( + override_dependencies, monkeypatch +): + async def reject_url(_value): + return None + + monkeypatch.setattr( + "api.noema_config.validate_llm_provider_base_url_async", reject_url + ) + async with await _client() as client: + response = await client.put( + "/api/noema-gateway", + json={ + "base_url": "https://orchestrator.example/v1", + "token": "gateway-secret", + }, + ) + + assert response.status_code == 422 + assert response.json()["detail"] == "Noema gateway base URL is not allowed" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("token", [None, "********"]) +async def test_noema_gateway_preserves_existing_token_for_masked_or_null_input( + override_dependencies, monkeypatch, token +): + override_dependencies.config = TenantConfig( + user_id="user-1", + organization_id="org-1", + noema_orchestrator_base_url="https://orchestrator.example/v1", + noema_orchestrator_token="gateway-secret", + ) + monkeypatch.setattr( + "api.noema_config.validate_llm_provider_base_url_async", + _allow_gateway_url, + ) + async with await _client() as client: + response = await client.put( + "/api/noema-gateway", + json={"base_url": "https://orchestrator.example/v1", "token": token}, + ) + + assert response.status_code == 200 + assert override_dependencies.config.noema_orchestrator_token == "gateway-secret" + + +@pytest.mark.asyncio +async def test_noema_gateway_rejects_control_character_in_token( + override_dependencies +): + async with await _client() as client: + response = await client.put( + "/api/noema-gateway", + json={"token": "gateway\nsecret"}, + ) + + assert response.status_code == 422 + assert response.json()["detail"] == "Noema gateway token is invalid" + + +@pytest.mark.asyncio +async def test_noema_gateway_reports_missing_token_for_valid_url( + override_dependencies, monkeypatch +): + monkeypatch.setattr( + "api.noema_config.validate_llm_provider_base_url_async", + _allow_gateway_url, + ) + async with await _client() as client: + response = await client.put( + "/api/noema-gateway", + json={"base_url": "https://orchestrator.example/v1"}, + ) + + assert response.status_code == 422 + assert response.json()["detail"] == "Noema gateway base URL and token are required" + + +@pytest.mark.asyncio +async def test_noema_gateway_handles_missing_encryption_key( + override_dependencies, monkeypatch +): + monkeypatch.setattr( + "api.noema_config.validate_llm_provider_base_url_async", + _allow_gateway_url, + ) + override_dependencies.commit_error = RuntimeError("ENCRYPTION_KEY is required") + async with await _client() as client: + response = await client.put( + "/api/noema-gateway", + json={ + "base_url": "https://orchestrator.example/v1", + "token": "gateway-secret", + }, + ) + + assert response.status_code == 503 + assert "Server encryption key is not configured" in response.json()["detail"] + + +@pytest.mark.asyncio +async def test_noema_gateway_propagates_unexpected_commit_error( + override_dependencies, monkeypatch +): + monkeypatch.setattr( + "api.noema_config.validate_llm_provider_base_url_async", + _allow_gateway_url, + ) + override_dependencies.commit_error = RuntimeError("database unavailable") + with pytest.raises(RuntimeError, match="database unavailable"): + async with await _client() as client: + await client.put( + "/api/noema-gateway", + json={ + "base_url": "https://orchestrator.example/v1", + "token": "gateway-secret", + }, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "payload,detail", + [ + ({"base_url": "https://orchestrator.example/v2"}, "Noema gateway base URL is not allowed"), + ({"token": ""}, "Noema gateway token is required"), + ({"unknown": "value"}, "Extra inputs are not permitted"), + ], +) +async def test_noema_gateway_rejects_invalid_updates( + override_dependencies, monkeypatch, payload, detail +): + monkeypatch.setattr( + "api.noema_config.validate_llm_provider_base_url_async", + _allow_gateway_url, + ) + async with await _client() as client: + response = await client.put("/api/noema-gateway", json=payload) + + assert response.status_code == 422 + assert detail in response.text + assert not override_dependencies.committed + + +@pytest.mark.asyncio +async def test_noema_gateway_requires_a_setting_update(override_dependencies): + async with await _client() as client: + response = await client.put("/api/noema-gateway", json={}) + + assert response.status_code == 422 + assert response.json()["detail"] == "No gateway settings supplied" diff --git a/docs/adr/0005-signed-noema-gateway-settings.md b/docs/adr/0005-signed-noema-gateway-settings.md new file mode 100644 index 000000000..391b6ac28 --- /dev/null +++ b/docs/adr/0005-signed-noema-gateway-settings.md @@ -0,0 +1,47 @@ +# ADR-0005: Signed-session Noema gateway settings + +- **Status:** Proposed +- **Date:** 2026-08-20 +- **Scope:** Naruon's per-user Noema gateway settings API +- **Figma:** Not applicable; this slice is a backend control-plane contract and + adds no visual surface. + +## Context + +ADR-0004's Noema runtime path reads a dedicated gateway URL and Fernet-protected +token from the scoped `tenant_configs` record, but operators had no product API +to configure those values. Adding the fields to the mailbox self-service schema +would blur credential ownership and make a future frontend send unrelated mail +settings together with an inference credential. + +## Decision + +Naruon exposes `GET` and `PUT /api/noema-gateway` for the authenticated signed +session's `(user_id, organization_id)` scope. The route: + +1. validates an HTTPS `/v1` URL through the existing allowlist and global-address + transport policy; +2. stores the gateway token through the existing `EncryptedString` Fernet KV; +3. returns only `base_url`, `configured`, and `has_token`; +4. writes generic `AuditLog` and `SecurityAuditEvent` records without token + values; and +5. preserves the existing single-alias contextual-orchestrator runtime contract. + +The route does not accept a target user, does not manage mailbox credentials, +does not read environment provider keys, and does not add an organization-wide +fallback that would change Noema's existing per-user scope. A frontend must +omit blank secret fields when preserving a stored token. + +## Consequences + +Users can complete the gateway setup from a signed-session settings surface, +and operators can distinguish unconfigured gateway state without seeing a +credential. Organization-wide administration and frontend presentation remain +separate follow-up decisions because they require an explicit membership and +delegation contract. + +## Verification + +`backend/tests/test_noema_config_api.py` covers readiness responses, token +non-disclosure, audit records, URL rejection, empty updates, and extra-field +rejection. The focused Noema suite must pass with warnings treated as errors. diff --git a/docs/adr/README.md b/docs/adr/README.md index 4d461fff6..f263bcdf8 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -13,6 +13,7 @@ govern implementation. | [ADR-0002](0002-fitted-topic-artifact-consumption.md) | Conditionally consume only a versioned fitted topic artifact through a fail-closed adapter | Proposed | Target `PLANNED`; runtime `BLOCKED-UPSTREAM` | | [ADR-0003](0003-separate-topic-measurement-from-agenda-generation.md) | Keep statistical measurement separate from agenda generation | Proposed | Target and future capability `PLANNED`; no implementation authorization | | [ADR-0004](0004-status-weighted-calendar-conflicts.md) | Evaluate CalDAV VEVENT overlaps by occupying status; cancelled does not occupy | Accepted | `ACCEPTED-NARUON-POLICY`; advisory evaluate API only | +| [ADR-0005](0005-signed-noema-gateway-settings.md) | Configure the scoped Noema gateway through a signed, audited, token-masking API | Proposed | Product setup path for the contextual-orchestrator runtime slice | The complete topic-intelligence requirements, architecture, contract, UML, conceptual ERD, security, test, and operability graph is indexed at diff --git a/docs/architecture/noema-decision-agent.md b/docs/architecture/noema-decision-agent.md index b203b36c9..34df61afd 100644 --- a/docs/architecture/noema-decision-agent.md +++ b/docs/architecture/noema-decision-agent.md @@ -43,6 +43,12 @@ This slice does **not** copy draft email-writing clients that still require a tenant `model_profile_id`. Keep the existing tools, owner-scope, and opt-in writeback surface. +The setup contract is [`ADR-0005`](../adr/0005-signed-noema-gateway-settings.md): +`GET` and `PUT /api/noema-gateway` use the signed session's current owner scope, +validate the HTTPS allowlist, persist the token through Fernet, and return only +readiness metadata. Mailbox self-service fields and cross-user administration +are intentionally outside this route. + Upstream org secrets (`NVIDIA_NIM_API_KEY`, `NVIDIA_NIM_API_KEY_SUB`, `BYTEZ_API_KEY`, `OPENROUTER_API_KEY`, `OPENAI_API_KEY`) belong in the orchestrator KV. naruon must not read them at request time. GitHub Models and From f7e5f5b637f37f7101bb6e358c162c511f072903 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 16:36:49 +0900 Subject: [PATCH 2/2] docs(noema): record gateway security evidence --- CHANGELOG.md | 1 + .../adr/0005-signed-noema-gateway-settings.md | 3 ++ docs/doctoring/noema-gateway-settings.md | 43 +++++++++++++++++++ 3 files changed, 47 insertions(+) create mode 100644 docs/doctoring/noema-gateway-settings.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 55768d0c7..d6b539037 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ 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 diff --git a/docs/adr/0005-signed-noema-gateway-settings.md b/docs/adr/0005-signed-noema-gateway-settings.md index 391b6ac28..88d342f93 100644 --- a/docs/adr/0005-signed-noema-gateway-settings.md +++ b/docs/adr/0005-signed-noema-gateway-settings.md @@ -45,3 +45,6 @@ delegation contract. `backend/tests/test_noema_config_api.py` covers readiness responses, token non-disclosure, audit records, URL rejection, empty updates, and extra-field rejection. The focused Noema suite must pass with warnings treated as errors. + +The control mapping and APA 7th references are maintained in +[`docs/doctoring/noema-gateway-settings.md`](../doctoring/noema-gateway-settings.md). diff --git a/docs/doctoring/noema-gateway-settings.md b/docs/doctoring/noema-gateway-settings.md new file mode 100644 index 000000000..2e5e132af --- /dev/null +++ b/docs/doctoring/noema-gateway-settings.md @@ -0,0 +1,43 @@ +# Doctoring: Noema gateway settings + +## Change under review + +The signed-session `GET`/`PUT /api/noema-gateway` route configures the existing +per-user contextual-orchestrator gateway without exposing the gateway token. +It is a control-plane settings surface, not a mailbox credential surface and +not an organization-wide delegation API. + +## Evidence-backed controls + +| Control | Implementation evidence | Customer/operational action | +|---|---|---| +| Authenticated subject boundary | The route depends on the verified `AuthContext` and resolves `TenantConfig` with both `user_id` and `organization_id`. | Sign in to the intended organization before saving the gateway; do not reuse a token across organizations. | +| Credential confidentiality | `noema_orchestrator_token` uses the existing `EncryptedString` Fernet type; responses expose only `has_token`, and audit text is generic. | Confirm readiness from `has_token`; never paste the gateway token into support tickets or logs. | +| Endpoint validation | The URL must be HTTPS, end in `/v1`, pass the existing host allowlist, and resolve to global addresses. | Add the gateway host to the approved allowlist before saving it; a rejected URL is an actionable setup error. | +| Accountability | Successful updates create both `AuditLog` and `SecurityAuditEvent` records with a stable opaque resource UID and no token value. | Use the security audit surface to verify who changed the gateway and when. | +| Fail-closed behavior | Missing URL/token, malformed URL, control characters, invalid fields, and encryption-root failures return controlled errors; runtime resolution already fails closed. | Resolve the returned setup error before retrying Noema; do not bypass validation with environment keys. | + +These controls are aligned with the OWASP Application Security Verification +Standard's use as a verification baseline for web application security controls +(OWASP Foundation, 2025) and with NIST's current authentication and authenticator +management guidance (National Institute of Standards and Technology, 2025). This is an implementation mapping, +not a claim that Naruon is certified or conforms to every requirement in either +publication. + +## Test evidence + +`backend/tests/test_noema_config_api.py` covers ready/unready state, malformed +and unallowlisted URLs, omitted/masked/null token preservation, control +characters, extra fields, no-setting updates, token non-disclosure, generic +audit content, encryption-root failure, and unexpected database errors. The +module reaches 100% line coverage under the focused coverage command. The full +backend suite passes with warnings treated as errors. + +## References (APA 7th) + +National Institute of Standards and Technology. (2025, July). *Digital identity +guidelines: Authentication and authenticator management* (NIST Special +Publication 800-63B-4). https://doi.org/10.6028/NIST.SP.800-63B-4 + +OWASP Foundation. (2025). *OWASP Application Security Verification Standard +5.0.0*. https://owasp.org/www-project-application-security-verification-standard/