From 348f96c1b1eefda2b3d9e3b932a6457acd9209e7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 08:48:53 +0900 Subject: [PATCH 1/9] test(security): define Keyverse credential resolution port contract --- backend/tests/test_credential_resolution.py | 76 +++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 backend/tests/test_credential_resolution.py diff --git a/backend/tests/test_credential_resolution.py b/backend/tests/test_credential_resolution.py new file mode 100644 index 000000000..6d1805e50 --- /dev/null +++ b/backend/tests/test_credential_resolution.py @@ -0,0 +1,76 @@ +"""Contract tests for the Keyverse credential-resolution application port.""" +from __future__ import annotations + +import pytest + +from core.credential_resolution import ( + CredentialReference, + CredentialResolutionUnavailable, + UnavailableCredentialResolver, +) + + +def test_credential_reference_rejects_blank_identity_fields() -> None: + """Credential references must bind every authorization identity dimension.""" + for field_name in ( + "authority", + "tenant_id", + "environment_name", + "secret_namespace", + "secret_key", + "secret_version", + "purpose_name", + ): + values = { + "authority": "keyverse", + "tenant_id": "workspace-123", + "environment_name": "production", + "secret_namespace": "naruon/runtime", + "secret_key": "auth_session_hmac_secret", + "secret_version": "v1", + "purpose_name": "session-signing", + } + values[field_name] = " " + with pytest.raises(ValueError, match=field_name): + CredentialReference(**values) + + +def test_credential_reference_repr_contains_no_secret_value() -> None: + """Value-free references are safe to log and review.""" + reference = CredentialReference( + authority="keyverse", + tenant_id="workspace-123", + environment_name="production", + secret_namespace="naruon/runtime", + secret_key="auth_session_hmac_secret", + secret_version="v1", + purpose_name="session-signing", + ) + + rendered = repr(reference) + + assert "workspace-123" in rendered + assert "auth_session_hmac_secret" in rendered + assert "secret_value" not in rendered + + +def test_unavailable_resolver_fails_closed_without_fallback() -> None: + """An unreleased Keyverse data plane cannot fall back to env or dotenv.""" + resolver = UnavailableCredentialResolver( + reason="Keyverse workload credential API is not released" + ) + reference = CredentialReference( + authority="keyverse", + tenant_id="workspace-123", + environment_name="production", + secret_namespace="naruon/runtime", + secret_key="encryption_key", + secret_version="v1", + purpose_name="data-encryption", + ) + + with pytest.raises( + CredentialResolutionUnavailable, + match="Keyverse workload credential API is not released", + ): + resolver.resolve_credential(reference) From df2b094791a8393528eaa2e1901195369317a809 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 08:49:41 +0900 Subject: [PATCH 2/9] feat(security): add fail-closed credential resolution port --- backend/core/credential_resolution.py | 68 +++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 backend/core/credential_resolution.py diff --git a/backend/core/credential_resolution.py b/backend/core/credential_resolution.py new file mode 100644 index 000000000..f587ddb96 --- /dev/null +++ b/backend/core/credential_resolution.py @@ -0,0 +1,68 @@ +"""Application port for resolving credentials from an external authority. + +The concrete Keyverse transport adapter intentionally does not live here yet. +Consumers can depend on this value-free contract while the owner publishes an +immutable workload credential-resolution API and release artifact. +""" +from __future__ import annotations + +from dataclasses import dataclass, fields +from typing import Protocol + + +@dataclass(frozen=True) +class CredentialReference: + """Identify one credential without carrying its secret value.""" + + authority: str + tenant_id: str + environment_name: str + secret_namespace: str + secret_key: str + secret_version: str + purpose_name: str + + def __post_init__(self) -> None: + """Reject references that cannot be authorized unambiguously.""" + for reference_field in fields(self): + field_value = getattr(self, reference_field.name) + if not field_value.strip(): + raise ValueError( + f"{reference_field.name} must be a non-blank credential reference field" + ) + + +@dataclass(frozen=True, repr=False) +class ResolvedCredential: + """Hold one resolved secret while keeping its value out of repr output.""" + + secret_value: str + reference: CredentialReference + + def __repr__(self) -> str: + """Render only value-free identity metadata.""" + return f"ResolvedCredential(reference={self.reference!r}, secret_value=)" + + +class CredentialResolutionUnavailable(RuntimeError): + """Raised when the configured credential authority cannot resolve safely.""" + + +class CredentialResolver(Protocol): + """Resolve one credential reference through an external authority.""" + + def resolve_credential(self, reference: CredentialReference) -> ResolvedCredential: + """Resolve one credential or fail closed without local fallback.""" + ... + + +@dataclass(frozen=True) +class UnavailableCredentialResolver: + """Fail closed until an immutable Keyverse workload adapter is available.""" + + reason: str + + def resolve_credential(self, reference: CredentialReference) -> ResolvedCredential: + """Reject resolution instead of consulting environment or dotenv state.""" + del reference + raise CredentialResolutionUnavailable(self.reason) From 2b7c6a2dc87843aff266e571aa88842d8947c153 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 08:50:09 +0900 Subject: [PATCH 3/9] test(security): cover resolved credential redaction --- backend/tests/test_credential_resolution.py | 51 ++++++++++++--------- 1 file changed, 30 insertions(+), 21 deletions(-) diff --git a/backend/tests/test_credential_resolution.py b/backend/tests/test_credential_resolution.py index 6d1805e50..829951143 100644 --- a/backend/tests/test_credential_resolution.py +++ b/backend/tests/test_credential_resolution.py @@ -6,10 +6,24 @@ from core.credential_resolution import ( CredentialReference, CredentialResolutionUnavailable, + ResolvedCredential, UnavailableCredentialResolver, ) +def _credential_reference() -> CredentialReference: + """Return one complete value-free Keyverse credential reference.""" + return CredentialReference( + authority="keyverse", + tenant_id="workspace-123", + environment_name="production", + secret_namespace="naruon/runtime", + secret_key="auth_session_hmac_secret", + secret_version="v1", + purpose_name="session-signing", + ) + + def test_credential_reference_rejects_blank_identity_fields() -> None: """Credential references must bind every authorization identity dimension.""" for field_name in ( @@ -37,40 +51,35 @@ def test_credential_reference_rejects_blank_identity_fields() -> None: def test_credential_reference_repr_contains_no_secret_value() -> None: """Value-free references are safe to log and review.""" - reference = CredentialReference( - authority="keyverse", - tenant_id="workspace-123", - environment_name="production", - secret_namespace="naruon/runtime", - secret_key="auth_session_hmac_secret", - secret_version="v1", - purpose_name="session-signing", - ) - - rendered = repr(reference) + rendered = repr(_credential_reference()) assert "workspace-123" in rendered assert "auth_session_hmac_secret" in rendered assert "secret_value" not in rendered +def test_resolved_credential_repr_redacts_secret_value() -> None: + """Resolved credential values never appear in ordinary object rendering.""" + credential = ResolvedCredential( + secret_value="do-not-log-this-secret", + reference=_credential_reference(), + ) + + rendered = repr(credential) + + assert "do-not-log-this-secret" not in rendered + assert "" in rendered + assert "workspace-123" in rendered + + def test_unavailable_resolver_fails_closed_without_fallback() -> None: """An unreleased Keyverse data plane cannot fall back to env or dotenv.""" resolver = UnavailableCredentialResolver( reason="Keyverse workload credential API is not released" ) - reference = CredentialReference( - authority="keyverse", - tenant_id="workspace-123", - environment_name="production", - secret_namespace="naruon/runtime", - secret_key="encryption_key", - secret_version="v1", - purpose_name="data-encryption", - ) with pytest.raises( CredentialResolutionUnavailable, match="Keyverse workload credential API is not released", ): - resolver.resolve_credential(reference) + resolver.resolve_credential(_credential_reference()) From a49641e8354d22cddc3997df1ee4620765b752df Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 08:50:50 +0900 Subject: [PATCH 4/9] docs(adr): define Keyverse credential resolution consumer boundary --- ...018-keyverse-credential-resolution-port.md | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 docs/adr/0018-keyverse-credential-resolution-port.md diff --git a/docs/adr/0018-keyverse-credential-resolution-port.md b/docs/adr/0018-keyverse-credential-resolution-port.md new file mode 100644 index 000000000..a4848d27b --- /dev/null +++ b/docs/adr/0018-keyverse-credential-resolution-port.md @@ -0,0 +1,62 @@ +# ADR-0018: Consume Keyverse credentials through a value-free application port + +- Status: Proposed +- Date: 2026-09-10 +- Scope: Naruon runtime credential acquisition +- Related: ContextualWisdomLab/.github#2063, ContextualWisdomLab/keyverse#129, #151, #153 + +## Context + +Naruon currently has bootstrap/runtime settings that can be sourced through Pydantic environment and dotenv discovery. Several tenant-scoped provider and mailbox credentials already live in Naruon-owned encrypted persistence, but root runtime material such as session-signing and encryption keys still requires a migration boundary. + +Keyverse is the intended CWL credential authority. Its workload credential data plane, however, is not yet an immutable released dependency. Reading an open Keyverse PR, querying Keyverse tables, copying its encryption implementation, or silently falling back to `.env` after a failed future cutover would violate the repository and organization boundaries. + +Naruon therefore needs a consumer-owned port before it needs a transport adapter. The port must express exactly which credential is requested without carrying its secret value in logs, configuration objects, PR fixtures, or interoperability metadata. + +## Decision + +Naruon introduces `core.credential_resolution` as an application port with these value-free identity dimensions: + +- credential authority; +- tenant/workspace identity; +- deployment environment; +- secret namespace; +- secret key; +- immutable/versioned secret identifier; +- runtime purpose. + +The port returns a resolved credential only through an implementation of `CredentialResolver`. Until Keyverse publishes and Naruon pins an immutable workload credential-resolution contract, `UnavailableCredentialResolver` is the only integration state introduced by this ADR and fails closed. + +This PR does **not** replace current bootstrap settings and does not claim `.env` removal. It creates the seam required for a later shadow-verification and cutover stack. The future Keyverse adapter must authenticate the workload with released identity semantics, bind the request to the reference above, verify TLS/authority, honor version/revocation/lease rules, reject stale or expired material, and provide no environment/dotenv/plaintext-database fallback. + +## Alternatives considered + +### Import Keyverse source or query its database + +Rejected. That couples Naruon to owner internals and an unreleased schema rather than a released bounded-context contract. + +### Keep using environment variables as the long-term credential interface + +Rejected. Environment and dotenv are process bootstrap transports, not an auditable credential authority with namespace, version, revocation, and workload authorization semantics. + +### Build a Naruon-local second vault + +Rejected. Naruon owns its product/runtime policy but not the CWL-wide credential authority. A second vault would duplicate custody, rotation, revocation, and audit responsibilities. + +## Consequences + +Naruon can prepare callers, test doubles, outage behavior, and configuration migration without pretending the Keyverse data plane is already released. The cost is a staged migration: existing root runtime configuration remains until the owner contract is released and an adapter passes shadow, rotation, revocation, outage, rollback, and clean-start acceptance. + +The `Proposed` status is intentional. It must not be promoted to `Accepted` solely because this PR merges; acceptance requires an immutable Keyverse owner release plus Naruon integration evidence on the protected branch. + +## Verification requirements + +Before production cutover: + +1. pin an immutable Keyverse workload credential API/client/schema release; +2. verify the workload identity and tenant/environment/purpose binding end to end; +3. exercise current and rotated secret versions and revoked/expired denial; +4. verify Keyverse outage fails closed without `.env`, environment, local-file, or local-vault secret fallback; +5. verify resolved values are absent from logs, traces, metrics, artifacts, browser bundles, exception rendering, and LLM inputs; +6. run Naruon's protected-branch backend, security, CodeQL, dependency, image, coverage, and independent-review gates on one exact head; +7. only then remove the corresponding legacy dotenv/environment discovery paths in a successor change. From 81954c0d226ef0b5f4eec72f9bd72ea82a0bf1e0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 08:51:06 +0900 Subject: [PATCH 5/9] docs(adr): index Keyverse credential resolution boundary --- docs/adr/README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/adr/README.md b/docs/adr/README.md index 4d461fff6..640a392f1 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -13,6 +13,9 @@ 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-0018](0018-keyverse-credential-resolution-port.md) | Stage Keyverse credential consumption behind a value-free, fail-closed application port | Proposed | Consumer seam only; production cutover remains `BLOCKED-UPSTREAM` until immutable Keyverse workload release | + +ADR-0018 intentionally leaves room for ADR numbers already proposed on parallel open branches. It must be reconciled against protected `develop` after those stacks land; its number does not imply that absent ADR-0005 through ADR-0017 are accepted on this branch. The complete topic-intelligence requirements, architecture, contract, UML, conceptual ERD, security, test, and operability graph is indexed at From 1d22146784aa4008908ccc189d0559a7455a0e73 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 08:56:12 +0900 Subject: [PATCH 6/9] docs(security): ground Keyverse credential port in workload identity standards --- .../keyverse-credential-resolution-port.md | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 docs/doctoring/keyverse-credential-resolution-port.md diff --git a/docs/doctoring/keyverse-credential-resolution-port.md b/docs/doctoring/keyverse-credential-resolution-port.md new file mode 100644 index 000000000..79ef12fa3 --- /dev/null +++ b/docs/doctoring/keyverse-credential-resolution-port.md @@ -0,0 +1,35 @@ +# Keyverse credential-resolution consumer boundary — standards doctoring + +Observed Naruon base: `develop@042b0c70531b229af3acbd0421a2f23098d848b3` +Consumer PR: `#1630` +Decision record: `docs/adr/0018-keyverse-credential-resolution-port.md` + +## Evidence and interpretation + +NIST SP 800-207 rejects implicit trust based only on network location and treats authentication and authorization as explicit decisions before access to an enterprise resource. SP 800-207A carries that principle into cloud-native applications and specifically describes application/service identity as an access-control input; it cites workload identity infrastructure such as SPIFFE as part of the enforcement architecture. + +SPIFFE's stable Workload API specification standardizes how a running workload obtains and validates cryptographic workload identity. Its caller-identification requirement is relevant to the future Keyverse adapter: merely reaching a local or network endpoint is not sufficient authority to resolve a secret. The credential request must be bound to an authenticated workload identity and the requested resource dimensions. + +OWASP's Secrets Management Cheat Sheet recommends centralized secret storage/provisioning and explicitly treats auditing, rotation, revocation, and expiration as lifecycle requirements. It calls for audit evidence covering who or what requested a secret, approval/rejection, use, expiration, authentication/authorization errors, and administrative updates. + +These sources support the following Naruon-side constraints, without defining Keyverse's owner implementation: + +1. `CredentialReference` remains value-free and names tenant, environment, namespace, key, version, purpose, and authority so authorization can be resource-specific rather than inferred from network reachability. +2. The future adapter must present a verifiable workload identity and fail closed when identity, authorization, version, revocation, expiration, or authority validation fails. +3. Rotation and revocation are owner lifecycle semantics. Naruon must consume them through the released Keyverse contract rather than reconstructing them from local timestamps or a duplicate vault. +4. Secret values must not appear in reference metadata or routine object rendering. Audit/event records should identify the request and outcome without carrying the secret itself. +5. An unavailable or unreleased Keyverse data plane does not authorize fallback to `.env`, plaintext configuration, or a Naruon-local second credential authority. Existing bootstrap mechanisms remain an explicitly tracked migration state until a verified cutover; they are not silently reclassified as the target architecture. + +## What these sources do not establish + +The cited standards do not prove that the current Keyverse open PRs provide a production workload secret API, nor do they select a particular transport, KMS, HSM, lease duration, or secret-storage schema for Keyverse. Those are owner decisions and require immutable release evidence before Naruon adopts them. + +## APA 7 references + +Chandramouli, R., & Butcher, Z. (2023). *A zero trust architecture model for access control in cloud-native applications in multi-cloud environments* (NIST Special Publication 800-207A). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-207A + +OWASP Foundation. (n.d.). *Secrets management cheat sheet*. OWASP Cheat Sheet Series. https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html + +Rose, S., Borchert, O., Mitchell, S., & Connelly, S. (2020). *Zero trust architecture* (NIST Special Publication 800-207). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-207 + +SPIFFE. (n.d.). *SPIFFE Workload API*. https://spiffe.io/docs/latest/spiffe-specs/spiffe_workload_api/ From ba820d17eddf9f1d2e8bb61c7e76483049e7bbd7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 09:56:11 +0900 Subject: [PATCH 7/9] test(credentials): reject non-string reference fields Reproduce the current review finding for None, integer, bytes, and list identity values before changing the application port. Signed-off-by: Seongho Bae --- backend/tests/test_credential_resolution.py | 27 +++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/backend/tests/test_credential_resolution.py b/backend/tests/test_credential_resolution.py index 829951143..5a0b01fc3 100644 --- a/backend/tests/test_credential_resolution.py +++ b/backend/tests/test_credential_resolution.py @@ -49,6 +49,33 @@ def test_credential_reference_rejects_blank_identity_fields() -> None: CredentialReference(**values) +@pytest.mark.parametrize( + "invalid_value", + [ + pytest.param(None, id="none"), + pytest.param(1, id="integer"), + pytest.param(b"bytes", id="bytes"), + pytest.param([], id="list"), + ], +) +def test_credential_reference_rejects_non_string_identity_fields( + invalid_value: object, +) -> None: + """Runtime construction must reject non-string identity dimensions uniformly.""" + values: dict[str, object] = { + "authority": invalid_value, + "tenant_id": "workspace-123", + "environment_name": "production", + "secret_namespace": "naruon/runtime", + "secret_key": "auth_session_hmac_secret", + "secret_version": "v1", + "purpose_name": "session-signing", + } + + with pytest.raises(ValueError, match="authority"): + CredentialReference(**values) + + def test_credential_reference_repr_contains_no_secret_value() -> None: """Value-free references are safe to log and review.""" rendered = repr(_credential_reference()) From 821dc57b6db0b92decc10f32bfcaf723e5a17f7f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 09:56:29 +0900 Subject: [PATCH 8/9] fix(credentials): validate reference field types Reject non-string identity values with the same fail-closed ValueError contract and make the Protocol stub explicit for static analysis. Signed-off-by: Seongho Bae --- backend/core/credential_resolution.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/core/credential_resolution.py b/backend/core/credential_resolution.py index f587ddb96..e7556efb0 100644 --- a/backend/core/credential_resolution.py +++ b/backend/core/credential_resolution.py @@ -26,7 +26,7 @@ def __post_init__(self) -> None: """Reject references that cannot be authorized unambiguously.""" for reference_field in fields(self): field_value = getattr(self, reference_field.name) - if not field_value.strip(): + if not isinstance(field_value, str) or not field_value.strip(): raise ValueError( f"{reference_field.name} must be a non-blank credential reference field" ) @@ -53,7 +53,7 @@ class CredentialResolver(Protocol): def resolve_credential(self, reference: CredentialReference) -> ResolvedCredential: """Resolve one credential or fail closed without local fallback.""" - ... + pass @dataclass(frozen=True) From 0d10215b318c07c0b81fc9f00dcc3d54950d5fe9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 09:56:46 +0900 Subject: [PATCH 9/9] docs(security): harden credential redirect boundary Qualify Keyverse owner references and require redirect re-authorization before any future workload credential is attached. Keep the ADR date at 2026-09-10, the repository's current KST authoring date. Signed-off-by: Seongho Bae --- docs/adr/0018-keyverse-credential-resolution-port.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/adr/0018-keyverse-credential-resolution-port.md b/docs/adr/0018-keyverse-credential-resolution-port.md index a4848d27b..bc2ef627d 100644 --- a/docs/adr/0018-keyverse-credential-resolution-port.md +++ b/docs/adr/0018-keyverse-credential-resolution-port.md @@ -3,7 +3,7 @@ - Status: Proposed - Date: 2026-09-10 - Scope: Naruon runtime credential acquisition -- Related: ContextualWisdomLab/.github#2063, ContextualWisdomLab/keyverse#129, #151, #153 +- Related: ContextualWisdomLab/.github#2063, ContextualWisdomLab/keyverse#129, ContextualWisdomLab/keyverse#151, ContextualWisdomLab/keyverse#153 ## Context @@ -27,7 +27,7 @@ Naruon introduces `core.credential_resolution` as an application port with these The port returns a resolved credential only through an implementation of `CredentialResolver`. Until Keyverse publishes and Naruon pins an immutable workload credential-resolution contract, `UnavailableCredentialResolver` is the only integration state introduced by this ADR and fails closed. -This PR does **not** replace current bootstrap settings and does not claim `.env` removal. It creates the seam required for a later shadow-verification and cutover stack. The future Keyverse adapter must authenticate the workload with released identity semantics, bind the request to the reference above, verify TLS/authority, honor version/revocation/lease rules, reject stale or expired material, and provide no environment/dotenv/plaintext-database fallback. +This PR does **not** replace current bootstrap settings and does not claim `.env` removal. It creates the seam required for a later shadow-verification and cutover stack. The future Keyverse adapter must authenticate the workload with released identity semantics, bind the request to the reference above, verify authenticated HTTPS and authority before attaching workload credentials, disable redirects by default, honor version/revocation/lease rules, reject stale or expired material, and provide no environment/dotenv/plaintext-database fallback. If an owner-released contract later requires redirects, Naruon must re-authorize every destination after redirect resolution and must not forward workload credentials until the redirected HTTPS endpoint and authority have passed the same verification policy. ## Alternatives considered @@ -58,5 +58,6 @@ Before production cutover: 3. exercise current and rotated secret versions and revoked/expired denial; 4. verify Keyverse outage fails closed without `.env`, environment, local-file, or local-vault secret fallback; 5. verify resolved values are absent from logs, traces, metrics, artifacts, browser bundles, exception rendering, and LLM inputs; -6. run Naruon's protected-branch backend, security, CodeQL, dependency, image, coverage, and independent-review gates on one exact head; -7. only then remove the corresponding legacy dotenv/environment discovery paths in a successor change. +6. verify redirect handling is disabled unless a released contract requires it, and then re-authorize each redirected HTTPS destination before any workload credential is attached; +7. run Naruon's protected-branch backend, security, CodeQL, dependency, image, coverage, and independent-review gates on one exact head; +8. only then remove the corresponding legacy dotenv/environment discovery paths in a successor change.