From 0d1a2d92a3686fd832dda1bb2d4ef29ac2c1d6b3 Mon Sep 17 00:00:00 2001 From: Jiri Puc Date: Thu, 3 Sep 2026 17:49:33 +0200 Subject: [PATCH 1/6] fix(auth): reserve ingest attribution for write scope An ingest-scoped browser credential may upload a document but must not be able to assert another caller as its immutable creator. Gate trusted-principal headers on full write authority, document the boundary, and exercise both narrow refusal and write compatibility over signed HTTP requests. --- openapi.json | 6 +-- plan/designs/ingest_principal_design.md | 32 ++++++++------ src/rememberstack/profiles/selfhost.py | 13 +++--- src/rememberstack/surfaces/http_api.py | 45 ++++++++++++-------- src/tests/adapters/test_signed_token_auth.py | 32 +++++++++++++- website/src/app/docs/configuration/page.mdx | 7 ++- 6 files changed, 91 insertions(+), 44 deletions(-) diff --git a/openapi.json b/openapi.json index 1030ee219..f332deb65 100644 --- a/openapi.json +++ b/openapi.json @@ -3669,7 +3669,7 @@ }, "/ingest": { "post": { - "description": "Push one file through E0, optionally as a stable lineage version.\n\nAttribution travels in **headers, never the query string**: the\nreference is erasable PII and a URL is copied verbatim into access\nlogs, proxies and traces, where a later principal deletion cannot\nreach it.\n\nThe pair is honoured only when the composing profile declares its\nperimeter trusted (``trusted_principal_source``). The deployment-wide\nbearer identifies a deployment, not a caller, so elsewhere any client\ncould assert it was a person. Untrusted attribution is **ignored, not\nrejected**: nothing forged is recorded either way, and refusing would\nlet a metadata concern fail an otherwise valid ingest.", + "description": "Push one file through E0, optionally as a stable lineage version.\n\nAttribution travels in **headers, never the query string**: the\nreference is erasable PII and a URL is copied verbatim into access\nlogs, proxies and traces, where a later principal deletion cannot\nreach it.\n\nThe pair is honoured only when the composing profile declares its\nnetwork perimeter trusted (``trusted_principal_source``) and any\nconfigured API credential has full WRITE scope. Narrow credentials may\nadd or read data, but cannot nominate an immutable principal. Untrusted\nattribution is **ignored, not rejected**: nothing forged is recorded\neither way, and refusing would let a metadata concern fail an otherwise\nvalid ingest.", "operationId": "ingest_document_ingest_post", "parameters": [ { @@ -3790,7 +3790,7 @@ } }, { - "description": "One of: user | api_credential | service. Sent with X-Ingest-Principal-Ref. Ignored unless the deployment declares a trusted principal source; malformed values are 422 only on a trusted deployment.", + "description": "One of: user | api_credential | service. Sent with X-Ingest-Principal-Ref. Ignored unless the deployment declares a trusted principal source and any configured API credential has write scope; malformed values are 422 only for a trusted assertion.", "in": "header", "name": "X-Ingest-Principal-Kind", "required": false, @@ -3803,7 +3803,7 @@ "type": "null" } ], - "description": "One of: user | api_credential | service. Sent with X-Ingest-Principal-Ref. Ignored unless the deployment declares a trusted principal source; malformed values are 422 only on a trusted deployment.", + "description": "One of: user | api_credential | service. Sent with X-Ingest-Principal-Ref. Ignored unless the deployment declares a trusted principal source and any configured API credential has write scope; malformed values are 422 only for a trusted assertion.", "title": "X-Ingest-Principal-Kind" } }, diff --git a/plan/designs/ingest_principal_design.md b/plan/designs/ingest_principal_design.md index a1d4730f2..3dd51d691 100644 --- a/plan/designs/ingest_principal_design.md +++ b/plan/designs/ingest_principal_design.md @@ -55,25 +55,29 @@ co-uploader list and no re-attribution path. The composing profile surfaces this as `trusted_principal_source` (env **`REMEMBERSTACK_SELFHOST_TRUSTED_PRINCIPAL_SOURCE`** — the self-host -settings class carries the `REMEMBERSTACK_SELFHOST_` prefix), default off. Enable it **only** -where the deployment is reachable solely by a control plane that has -already authenticated the actor it names — a managed data plane behind a -private transit gateway. On a publicly reachable deployment it makes -attribution meaningless rather than merely permissive. +settings class carries the `REMEMBERSTACK_SELFHOST_` prefix), default off. +Enable it **only** where the deployment's network perimeter has already +authenticated the actor it names. When the API auth perimeter is configured, +the request must additionally carry full `write` scope before these headers +are believed; a narrow browser `ingest` credential can add a document but +cannot name its immutable principal. Without API auth, enabling the setting +trusts the network perimeter by itself. On an ordinarily reachable deployment +it makes attribution meaningless rather than merely permissive. Attribution is honoured only when the composing profile declares -`trusted_principal_source=True`. The deployment-wide bearer identifies a -*deployment*, not a caller, so elsewhere any client could assert it was a -person. Untrusted attribution is **ignored, not rejected**: nothing forged -is recorded either way, so refusing buys no safety while adding a failure -mode where a merely misconfigured deployment rejects real documents. +`trusted_principal_source=True` and, when present, the API credential carries +`write` scope. A narrow credential identifies its own caller but has no +authority to assert an arbitrary principal. Untrusted attribution is **ignored, +not rejected**: nothing forged is recorded either way, so refusing buys no +safety while adding a failure mode where a merely misconfigured deployment +rejects real documents. **Metadata must never break ingest.** Default is off, so the self-host posture is unchanged. -This bounds the trust rather than eliminating it: within a trusted -perimeter the caller is still asserting the principal. Deriving identity -from a per-caller credential would require a per-caller perimeter, which -the engine does not have and this slice does not add. +This bounds the trust rather than eliminating it: a full-write caller within a +trusted perimeter is still asserting the principal. Narrow credentials never +turn their own subject into attribution implicitly; doing so would conflate +the credential holder with the immutable creator claim. ### 2.4 Transport: headers, never the query string diff --git a/src/rememberstack/profiles/selfhost.py b/src/rememberstack/profiles/selfhost.py index 7996bac31..d19a16a72 100644 --- a/src/rememberstack/profiles/selfhost.py +++ b/src/rememberstack/profiles/selfhost.py @@ -133,13 +133,12 @@ class SelfHostSettings(BaseSettings): Env: `REMEMBERSTACK_SELFHOST_TRUSTED_PRINCIPAL_SOURCE` (this settings class carries the `REMEMBERSTACK_SELFHOST_` prefix). - Off by default. The perimeter credential authenticates a *deployment*, - not a caller, so on an ordinary or public perimeter any client could - assert it was a person; attribution there is ignored. Set this **only** - when the deployment is reachable solely by a control plane that has - already authenticated the actor it names — e.g. a managed data plane - behind a private transit gateway. Enabling it on a publicly reachable - deployment makes attribution meaningless, not merely permissive.""" + Off by default. The network perimeter must authenticate the asserted actor. + When API auth is configured, only a full ``write`` credential may supply + attribution; narrow browser credentials are ignored even when this flag is + on. Without API auth, this setting trusts the network perimeter by itself. + Enabling it on an ordinarily reachable deployment makes attribution + meaningless, not merely permissive.""" conversion_routes: Annotated[dict[str, str], NoDecode] = Field( default_factory=lambda: dict(STOCK_CONVERSION_ROUTE_NAMES) ) diff --git a/src/rememberstack/surfaces/http_api.py b/src/rememberstack/surfaces/http_api.py index ab3f3cf9a..dc3cadae2 100644 --- a/src/rememberstack/surfaces/http_api.py +++ b/src/rememberstack/surfaces/http_api.py @@ -65,6 +65,7 @@ from rememberstack.model import SpendLeaseRefused from rememberstack.model import SpendLeaseUnavailable from rememberstack.model import ToolDescriptor +from rememberstack.model.auth import PerimeterScope from rememberstack.ports.auth import AuthPerimeterPort from rememberstack.surfaces.graph_queries import GraphBusyError from rememberstack.surfaces.graph_queries import GraphHydrationError @@ -354,12 +355,13 @@ def build_api( control plane for ingest/search/operations POST (D46). Each capability is explicitly composed; absent services do not pretend to exist. - `trusted_principal_source` declares that this deployment's perimeter is - reached only by a caller entitled to state who ingested a document (a - managed control plane). It is **off by default**: the deployment-wide - bearer identifies a deployment, not a caller, so elsewhere an asserted - `X-Ingest-Principal-*` pair is **ignored, never rejected** — metadata - must not be able to fail an otherwise valid ingest. + `trusted_principal_source` declares that the deployment's network perimeter + is entitled to state who ingested a document. It is **off by default**, + and a configured auth perimeter additionally requires WRITE scope before + believing the headers: a narrow browser ingest credential identifies its + holder but cannot nominate some other immutable principal. Elsewhere an + asserted `X-Ingest-Principal-*` pair is **ignored, never rejected** — + metadata must not be able to fail an otherwise valid ingest. `ingest_body_max_bytes` bounds `POST /ingest` request bodies before they are buffered (413 over the cap; 411 when no Content-Length is declared). @@ -1177,6 +1179,7 @@ def _mount_ingest( @app.post("/ingest", response_model=IngestedVersion) def ingest_document( + request: Request, content: Annotated[bytes, Body(media_type="application/octet-stream")], filename: Annotated[str, Query(min_length=1)], mime: Annotated[str, Query(min_length=1)], @@ -1197,8 +1200,9 @@ def ingest_document( description=( "One of: user | api_credential | service. Sent with" " X-Ingest-Principal-Ref. Ignored unless the deployment" - " declares a trusted principal source; malformed values" - " are 422 only on a trusted deployment." + " declares a trusted principal source and any configured" + " API credential has write scope; malformed values are" + " 422 only for a trusted assertion." ), ), ] = None, @@ -1221,11 +1225,12 @@ def ingest_document( reach it. The pair is honoured only when the composing profile declares its - perimeter trusted (``trusted_principal_source``). The deployment-wide - bearer identifies a deployment, not a caller, so elsewhere any client - could assert it was a person. Untrusted attribution is **ignored, not - rejected**: nothing forged is recorded either way, and refusing would - let a metadata concern fail an otherwise valid ingest. + network perimeter trusted (``trusted_principal_source``) and any + configured API credential has full WRITE scope. Narrow credentials may + add or read data, but cannot nominate an immutable principal. Untrusted + attribution is **ignored, not rejected**: nothing forged is recorded + either way, and refusing would let a metadata concern fail an otherwise + valid ingest. """ if max_body_bytes is not None and len(content) > max_body_bytes: # the ASGI guard already refused honest requests; this backstop @@ -1237,10 +1242,16 @@ def ingest_document( detail="source_kind and source_ref must be supplied together", ) # The headers are taken RAW and are not validated at request binding. - # An untrusted deployment must discard them without inspection: if - # malformed attribution could 422, a metadata concern would still fail - # an otherwise valid upload, which is exactly what ignoring is for. - if not trusted_principal_source: + # An untrusted source must discard them without inspection: if malformed + # attribution could 422, a metadata concern would still fail an + # otherwise valid upload, which is exactly what ignoring is for. A + # configured perimeter publishes its context before this handler; only + # full WRITE authority may make the immutable attribution assertion. + context = getattr(request.state, "perimeter_context", None) + may_assert_principal = context is None or context.scope.covers( + required=PerimeterScope.WRITE + ) + if not trusted_principal_source or not may_assert_principal: principal_kind, principal_ref = None, None ingested_by = _parse_ingest_principal(kind=principal_kind, ref=principal_ref) # An old structural IngestPort has no `ingested_by` keyword; passing it diff --git a/src/tests/adapters/test_signed_token_auth.py b/src/tests/adapters/test_signed_token_auth.py index a709f3d8f..833f0a82e 100644 --- a/src/tests/adapters/test_signed_token_auth.py +++ b/src/tests/adapters/test_signed_token_auth.py @@ -30,6 +30,7 @@ from rememberstack.model import DocumentUpload from rememberstack.model import IngestedVersion from rememberstack.model import IngestPrincipal +from rememberstack.model import IngestPrincipalKind from rememberstack.model import PerimeterCredential from rememberstack.model.auth import PerimeterScope from rememberstack.surfaces.http_api import build_api @@ -121,6 +122,7 @@ def __init__(self, *, deployment_id: UUID) -> None: """Bind the receipt to the served deployment.""" self.deployment_id = deployment_id self.calls = 0 + self.last_principal: IngestPrincipal | None = None def ingest( self, @@ -132,8 +134,8 @@ def ingest( """Record one accepted upload.""" assert deployment_id == self.deployment_id assert upload.content == b"memory" - assert ingested_by is None self.calls += 1 + self.last_principal = ingested_by return IngestedVersion( deployment_id=deployment_id, doc_id=uuid4(), @@ -212,6 +214,7 @@ def test_an_ingest_signed_token_reaches_only_the_ingest_route() -> None: ingest=ingest, connectors=object(), # type: ignore[arg-type] auth=auth, + trusted_principal_source=True, ) client = TestClient(app) ingest_token = _token( @@ -222,10 +225,16 @@ def test_an_ingest_signed_token_reaches_only_the_ingest_route() -> None: accepted = client.post( "/ingest?filename=memory.md&mime=text/markdown", content=b"memory", - headers={**ingest_headers, "Content-Type": "application/octet-stream"}, + headers={ + **ingest_headers, + "Content-Type": "application/octet-stream", + "X-Ingest-Principal-Kind": "user", + "X-Ingest-Principal-Ref": "user:forged", + }, ) assert accepted.status_code == 200, accepted.text assert ingest.calls == 1 + assert ingest.last_principal is None refused = ( client.post( @@ -245,6 +254,20 @@ def test_an_ingest_signed_token_reaches_only_the_ingest_route() -> None: assert [response.status_code for response in refused] == [403] * len(refused) assert ingest.calls == 1 + read_token = _token( + private=private, kid="k1", audience=str(deployment_id), scope="read" + ) + read_response = client.post( + "/ingest?filename=memory.md&mime=text/markdown", + content=b"memory", + headers={ + "Authorization": f"Bearer {read_token}", + "Content-Type": "application/octet-stream", + }, + ) + assert read_response.status_code == 403 + assert ingest.calls == 1 + write_token = _token( private=private, kid="k1", audience=str(deployment_id), scope="write" ) @@ -254,10 +277,15 @@ def test_an_ingest_signed_token_reaches_only_the_ingest_route() -> None: headers={ "Authorization": f"Bearer {write_token}", "Content-Type": "application/octet-stream", + "X-Ingest-Principal-Kind": "user", + "X-Ingest-Principal-Ref": "user:trusted-control-plane", }, ) assert write_response.status_code == 200, write_response.text assert ingest.calls == 2 + assert ingest.last_principal == IngestPrincipal( + kind=IngestPrincipalKind.USER, external_ref="user:trusted-control-plane" + ) def test_a_credential_for_another_deployment_is_refused() -> None: diff --git a/website/src/app/docs/configuration/page.mdx b/website/src/app/docs/configuration/page.mdx index ea2803a21..795c5d6ed 100644 --- a/website/src/app/docs/configuration/page.mdx +++ b/website/src/app/docs/configuration/page.mdx @@ -63,7 +63,7 @@ exactly one of three scopes: | Scope | What it reaches | | --- | --- | -| `read` | Retrieval, search, inventory, readiness, and other non-mutating routes. It cannot ingest or change configuration | +| `read` | The enumerated retrieval, search, inventory, and readiness routes. It cannot ingest or change configuration | | `ingest` | Only `POST /ingest`. It cannot search or inspect memory, run an assured operation, or create a connector that keeps pulling after the caller leaves | | `write` | The complete deployment API, including `POST /ingest`; it also satisfies every `read` requirement | @@ -71,6 +71,11 @@ Unlisted routes require `write`. A new route therefore stays unavailable to narrow credentials until it is classified deliberately, and an unknown scope on a signed credential is refused rather than downgraded. +An `ingest` credential also cannot assert `X-Ingest-Principal-Kind` or +`X-Ingest-Principal-Ref`. Those immutable attribution headers are ignored +unless the deployment trusts their network source and the API credential has +full `write` scope. + #### Calling a deployment from a browser A browser refuses a cross-origin request *before it is sent* unless the server From 2ed3d37a2a6b371d237aeea5d994c62250e68b92 Mon Sep 17 00:00:00 2001 From: Jiri Puc Date: Thu, 3 Sep 2026 18:58:30 +0200 Subject: [PATCH 2/6] fix(auth): fail closed on missing attribution context Address every LOW finding from OPUS round 1. Make perimeter presence explicit at ingest composition, pin shared-secret compatibility with a real HTTP proof, split attribution from route-scope behavior, and align canonical and operator documentation with the full-write authority rule. --- decisions.md | 5 + openapi.json | 6 +- plan/designs/ingest_principal_design.md | 8 +- src/rememberstack/profiles/selfhost.py | 7 +- src/rememberstack/surfaces/http_api.py | 34 +++--- src/tests/adapters/test_signed_token_auth.py | 112 +++++++++++++++---- website/src/app/docs/configuration/page.mdx | 5 +- website/src/app/docs/project-status/page.mdx | 3 +- 8 files changed, 132 insertions(+), 48 deletions(-) diff --git a/decisions.md b/decisions.md index 42c21ec85..d2767f83f 100644 --- a/decisions.md +++ b/decisions.md @@ -4571,6 +4571,11 @@ Alembic's autocommit boundary and impossible to pre-build by hand before the column existed. The later "documents by principal" operation adds the index in its own revision. +**Refinement (2026-09-03).** Where API authentication is configured, trusted +attribution additionally requires full `write` authority. The unscoped shared +secret remains unrestricted; narrow signed scopes cannot assert an immutable +principal and their attribution headers are ignored. + **Why.** D50 makes content-level authorization and per-user scoping library non-goals; that is about **authorization**. Attribution is a provenance fact, changes no read path, and is the one thing only the diff --git a/openapi.json b/openapi.json index f332deb65..58d2718c2 100644 --- a/openapi.json +++ b/openapi.json @@ -3669,7 +3669,7 @@ }, "/ingest": { "post": { - "description": "Push one file through E0, optionally as a stable lineage version.\n\nAttribution travels in **headers, never the query string**: the\nreference is erasable PII and a URL is copied verbatim into access\nlogs, proxies and traces, where a later principal deletion cannot\nreach it.\n\nThe pair is honoured only when the composing profile declares its\nnetwork perimeter trusted (``trusted_principal_source``) and any\nconfigured API credential has full WRITE scope. Narrow credentials may\nadd or read data, but cannot nominate an immutable principal. Untrusted\nattribution is **ignored, not rejected**: nothing forged is recorded\neither way, and refusing would let a metadata concern fail an otherwise\nvalid ingest.", + "description": "Push one file through E0, optionally as a stable lineage version.\n\nAttribution travels in **headers, never the query string**: the\nreference is erasable PII and a URL is copied verbatim into access\nlogs, proxies and traces, where a later principal deletion cannot\nreach it.\n\nThe pair is honoured only when the composing profile declares its\nnetwork perimeter trusted (``trusted_principal_source``) and any\nconfigured API credential has full WRITE authority. The unscoped shared\nsecret is unrestricted; narrow signed credentials may add or read data,\nbut cannot nominate an immutable principal. Untrusted attribution is\n**ignored, not rejected**: nothing forged is recorded either way, and\nrefusing would let a metadata concern fail an otherwise valid ingest.", "operationId": "ingest_document_ingest_post", "parameters": [ { @@ -3790,7 +3790,7 @@ } }, { - "description": "One of: user | api_credential | service. Sent with X-Ingest-Principal-Ref. Ignored unless the deployment declares a trusted principal source and any configured API credential has write scope; malformed values are 422 only for a trusted assertion.", + "description": "One of: user | api_credential | service. Sent with X-Ingest-Principal-Ref. Ignored unless the deployment declares a trusted principal source and any configured API credential has full write authority; malformed values are 422 only for a trusted assertion.", "in": "header", "name": "X-Ingest-Principal-Kind", "required": false, @@ -3803,7 +3803,7 @@ "type": "null" } ], - "description": "One of: user | api_credential | service. Sent with X-Ingest-Principal-Ref. Ignored unless the deployment declares a trusted principal source and any configured API credential has write scope; malformed values are 422 only for a trusted assertion.", + "description": "One of: user | api_credential | service. Sent with X-Ingest-Principal-Ref. Ignored unless the deployment declares a trusted principal source and any configured API credential has full write authority; malformed values are 422 only for a trusted assertion.", "title": "X-Ingest-Principal-Kind" } }, diff --git a/plan/designs/ingest_principal_design.md b/plan/designs/ingest_principal_design.md index 3dd51d691..2c298349b 100644 --- a/plan/designs/ingest_principal_design.md +++ b/plan/designs/ingest_principal_design.md @@ -58,15 +58,17 @@ The composing profile surfaces this as `trusted_principal_source` settings class carries the `REMEMBERSTACK_SELFHOST_` prefix), default off. Enable it **only** where the deployment's network perimeter has already authenticated the actor it names. When the API auth perimeter is configured, -the request must additionally carry full `write` scope before these headers -are believed; a narrow browser `ingest` credential can add a document but +the request must additionally carry full `write` authority before these +headers are believed. The unscoped shared secret is unrestricted and therefore +qualifies; a narrow signed browser `ingest` credential can add a document but cannot name its immutable principal. Without API auth, enabling the setting trusts the network perimeter by itself. On an ordinarily reachable deployment it makes attribution meaningless rather than merely permissive. Attribution is honoured only when the composing profile declares `trusted_principal_source=True` and, when present, the API credential carries -`write` scope. A narrow credential identifies its own caller but has no +full `write` authority. The legacy shared secret has that unrestricted +authority; a narrow signed credential identifies its own caller but has no authority to assert an arbitrary principal. Untrusted attribution is **ignored, not rejected**: nothing forged is recorded either way, so refusing buys no safety while adding a failure mode where a merely misconfigured deployment diff --git a/src/rememberstack/profiles/selfhost.py b/src/rememberstack/profiles/selfhost.py index d19a16a72..b5e57cf66 100644 --- a/src/rememberstack/profiles/selfhost.py +++ b/src/rememberstack/profiles/selfhost.py @@ -134,9 +134,10 @@ class SelfHostSettings(BaseSettings): class carries the `REMEMBERSTACK_SELFHOST_` prefix). Off by default. The network perimeter must authenticate the asserted actor. - When API auth is configured, only a full ``write`` credential may supply - attribution; narrow browser credentials are ignored even when this flag is - on. Without API auth, this setting trusts the network perimeter by itself. + When API auth is configured, only full ``write`` authority may supply + attribution: the unscoped shared secret is unrestricted and qualifies, + while narrow signed credentials are ignored even when this flag is on. + Without API auth, this setting trusts the network perimeter by itself. Enabling it on an ordinarily reachable deployment makes attribution meaningless, not merely permissive.""" conversion_routes: Annotated[dict[str, str], NoDecode] = Field( diff --git a/src/rememberstack/surfaces/http_api.py b/src/rememberstack/surfaces/http_api.py index dc3cadae2..a1cb67fed 100644 --- a/src/rememberstack/surfaces/http_api.py +++ b/src/rememberstack/surfaces/http_api.py @@ -357,11 +357,12 @@ def build_api( `trusted_principal_source` declares that the deployment's network perimeter is entitled to state who ingested a document. It is **off by default**, - and a configured auth perimeter additionally requires WRITE scope before - believing the headers: a narrow browser ingest credential identifies its - holder but cannot nominate some other immutable principal. Elsewhere an - asserted `X-Ingest-Principal-*` pair is **ignored, never rejected** — - metadata must not be able to fail an otherwise valid ingest. + and a configured auth perimeter additionally requires full WRITE authority + before believing the headers. The unscoped shared secret is unrestricted; + a narrow signed browser ingest credential identifies its holder but cannot + nominate some other immutable principal. Elsewhere an asserted + `X-Ingest-Principal-*` pair is **ignored, never rejected** — metadata must + not be able to fail an otherwise valid ingest. `ingest_body_max_bytes` bounds `POST /ingest` request bodies before they are buffered (413 over the cap; 411 when no Content-Length is declared). @@ -516,6 +517,7 @@ def hydrate_relation(relation_id: UUID) -> Envelope: deployment_id=deployment_id, max_body_bytes=ingest_body_max_bytes, trusted_principal_source=trusted_principal_source, + attribution_requires_write=auth is not None, ) if ingest_body_max_bytes is not None: app.add_middleware(_IngestBodyLimit, max_bytes=ingest_body_max_bytes) @@ -1135,8 +1137,9 @@ def _parse_ingest_principal( ) -> IngestPrincipal | None: """Validate a trusted attribution pair, or raise a 422 explaining why. - Only reached on a trusted perimeter, so a malformed pair here is a real - client error worth reporting rather than metadata that should be dropped. + Only reached on a trusted perimeter presenting full WRITE authority, so a + malformed pair here is a real client error worth reporting rather than + metadata that should be dropped. """ if kind is None and ref is None: return None @@ -1174,6 +1177,7 @@ def _mount_ingest( deployment_id: UUID, max_body_bytes: int | None, trusted_principal_source: bool = False, + attribution_requires_write: bool = False, ) -> None: """Add the D62 lineage-aware push surface over the E0 ingest gate.""" @@ -1201,7 +1205,7 @@ def ingest_document( "One of: user | api_credential | service. Sent with" " X-Ingest-Principal-Ref. Ignored unless the deployment" " declares a trusted principal source and any configured" - " API credential has write scope; malformed values are" + " API credential has full write authority; malformed values are" " 422 only for a trusted assertion." ), ), @@ -1226,11 +1230,11 @@ def ingest_document( The pair is honoured only when the composing profile declares its network perimeter trusted (``trusted_principal_source``) and any - configured API credential has full WRITE scope. Narrow credentials may - add or read data, but cannot nominate an immutable principal. Untrusted - attribution is **ignored, not rejected**: nothing forged is recorded - either way, and refusing would let a metadata concern fail an otherwise - valid ingest. + configured API credential has full WRITE authority. The unscoped shared + secret is unrestricted; narrow signed credentials may add or read data, + but cannot nominate an immutable principal. Untrusted attribution is + **ignored, not rejected**: nothing forged is recorded either way, and + refusing would let a metadata concern fail an otherwise valid ingest. """ if max_body_bytes is not None and len(content) > max_body_bytes: # the ASGI guard already refused honest requests; this backstop @@ -1248,8 +1252,8 @@ def ingest_document( # configured perimeter publishes its context before this handler; only # full WRITE authority may make the immutable attribution assertion. context = getattr(request.state, "perimeter_context", None) - may_assert_principal = context is None or context.scope.covers( - required=PerimeterScope.WRITE + may_assert_principal = not attribution_requires_write or ( + context is not None and context.scope.covers(required=PerimeterScope.WRITE) ) if not trusted_principal_source or not may_assert_principal: principal_kind, principal_ref = None, None diff --git a/src/tests/adapters/test_signed_token_auth.py b/src/tests/adapters/test_signed_token_auth.py index 833f0a82e..8381b1ffb 100644 --- a/src/tests/adapters/test_signed_token_auth.py +++ b/src/tests/adapters/test_signed_token_auth.py @@ -27,12 +27,15 @@ from rememberstack.adapters.managed.signed_token_auth import load_verification_keys from rememberstack.adapters.managed.signed_token_auth import SignedTokenAuth from rememberstack.adapters.managed.signed_token_auth import SignedTokenUnusable +from rememberstack.adapters.selfhost.hashed_bearer_auth import digest_bearer_secret +from rememberstack.adapters.selfhost.hashed_bearer_auth import HashedBearerAuth from rememberstack.model import DocumentUpload from rememberstack.model import IngestedVersion from rememberstack.model import IngestPrincipal from rememberstack.model import IngestPrincipalKind from rememberstack.model import PerimeterCredential from rememberstack.model.auth import PerimeterScope +from rememberstack.ports.auth import AuthPerimeterPort from rememberstack.surfaces.http_api import build_api @@ -161,6 +164,26 @@ def ingest_observed( raise AssertionError("unexpected observed-source ingest") +def _ingest_client( + *, deployment_id: UUID, auth: AuthPerimeterPort +) -> tuple[TestClient, _RecordingIngest]: + """Compose the real HTTP perimeter around one recording ingest port.""" + ingest = _RecordingIngest(deployment_id=deployment_id) + boundary = _OpenBoundary() + app = build_api( + engine=_UnusedEngine(), # type: ignore[arg-type] + deployment_id=deployment_id, + admission=boundary, # type: ignore[arg-type] + readiness=boundary, # type: ignore[arg-type] + surface=_EmptyOperations(deployment_id=deployment_id), # type: ignore[arg-type] + ingest=ingest, + connectors=object(), # type: ignore[arg-type] + auth=auth, + trusted_principal_source=True, + ) + return TestClient(app), ingest + + def test_a_valid_credential_names_its_subject_and_scope() -> None: """The whole point: a person's browser reaches the deployment as itself.""" deployment_id = uuid4() @@ -203,20 +226,7 @@ def test_an_ingest_signed_token_reaches_only_the_ingest_route() -> None: auth = SignedTokenAuth( deployment_id=deployment_id, keys=load_verification_keys(jwks=jwks) ) - ingest = _RecordingIngest(deployment_id=deployment_id) - boundary = _OpenBoundary() - app = build_api( - engine=_UnusedEngine(), # type: ignore[arg-type] - deployment_id=deployment_id, - admission=boundary, # type: ignore[arg-type] - readiness=boundary, # type: ignore[arg-type] - surface=_EmptyOperations(deployment_id=deployment_id), # type: ignore[arg-type] - ingest=ingest, - connectors=object(), # type: ignore[arg-type] - auth=auth, - trusted_principal_source=True, - ) - client = TestClient(app) + client, ingest = _ingest_client(deployment_id=deployment_id, auth=auth) ingest_token = _token( private=private, kid="k1", audience=str(deployment_id), scope="ingest" ) @@ -225,12 +235,7 @@ def test_an_ingest_signed_token_reaches_only_the_ingest_route() -> None: accepted = client.post( "/ingest?filename=memory.md&mime=text/markdown", content=b"memory", - headers={ - **ingest_headers, - "Content-Type": "application/octet-stream", - "X-Ingest-Principal-Kind": "user", - "X-Ingest-Principal-Ref": "user:forged", - }, + headers={**ingest_headers, "Content-Type": "application/octet-stream"}, ) assert accepted.status_code == 200, accepted.text assert ingest.calls == 1 @@ -268,6 +273,46 @@ def test_an_ingest_signed_token_reaches_only_the_ingest_route() -> None: assert read_response.status_code == 403 assert ingest.calls == 1 + write_token = _token( + private=private, kid="k1", audience=str(deployment_id), scope="write" + ) + write_response = client.post( + "/ingest?filename=memory.md&mime=text/markdown", + content=b"memory", + headers={ + "Authorization": f"Bearer {write_token}", + "Content-Type": "application/octet-stream", + }, + ) + assert write_response.status_code == 200, write_response.text + assert ingest.calls == 2 + + +def test_a_narrow_ingest_credential_cannot_assert_attribution() -> None: + """Direct browser upload does not grant authority to name its creator.""" + deployment_id = uuid4() + private, jwks = _keypair(kid="k1") + auth = SignedTokenAuth( + deployment_id=deployment_id, keys=load_verification_keys(jwks=jwks) + ) + client, ingest = _ingest_client(deployment_id=deployment_id, auth=auth) + + ingest_token = _token( + private=private, kid="k1", audience=str(deployment_id), scope="ingest" + ) + narrow_response = client.post( + "/ingest?filename=memory.md&mime=text/markdown", + content=b"memory", + headers={ + "Authorization": f"Bearer {ingest_token}", + "Content-Type": "application/octet-stream", + "X-Ingest-Principal-Kind": "user", + "X-Ingest-Principal-Ref": "user:forged", + }, + ) + assert narrow_response.status_code == 200, narrow_response.text + assert ingest.last_principal is None + write_token = _token( private=private, kid="k1", audience=str(deployment_id), scope="write" ) @@ -282,12 +327,37 @@ def test_an_ingest_signed_token_reaches_only_the_ingest_route() -> None: }, ) assert write_response.status_code == 200, write_response.text - assert ingest.calls == 2 assert ingest.last_principal == IngestPrincipal( kind=IngestPrincipalKind.USER, external_ref="user:trusted-control-plane" ) +def test_the_unscoped_shared_secret_retains_attribution_authority() -> None: + """The legacy shared secret remains unrestricted for compatibility.""" + deployment_id = uuid4() + secret = "legacy-shared-secret" + auth = HashedBearerAuth( + issued_deployment_id=deployment_id, digest=digest_bearer_secret(secret=secret) + ) + client, ingest = _ingest_client(deployment_id=deployment_id, auth=auth) + + response = client.post( + "/ingest?filename=memory.md&mime=text/markdown", + content=b"memory", + headers={ + "Authorization": f"Bearer {secret}", + "Content-Type": "application/octet-stream", + "X-Ingest-Principal-Kind": "service", + "X-Ingest-Principal-Ref": "service:legacy-control-plane", + }, + ) + + assert response.status_code == 200, response.text + assert ingest.last_principal == IngestPrincipal( + kind=IngestPrincipalKind.SERVICE, external_ref="service:legacy-control-plane" + ) + + def test_a_credential_for_another_deployment_is_refused() -> None: """D45: a wildcard certificate completes TLS to the wrong process.""" private, jwks = _keypair(kid="k1") diff --git a/website/src/app/docs/configuration/page.mdx b/website/src/app/docs/configuration/page.mdx index 795c5d6ed..ff460f2f4 100644 --- a/website/src/app/docs/configuration/page.mdx +++ b/website/src/app/docs/configuration/page.mdx @@ -73,8 +73,9 @@ on a signed credential is refused rather than downgraded. An `ingest` credential also cannot assert `X-Ingest-Principal-Kind` or `X-Ingest-Principal-Ref`. Those immutable attribution headers are ignored -unless the deployment trusts their network source and the API credential has -full `write` scope. +unless the deployment trusts their network source and the caller has full +`write` authority. The unscoped `API_BEARER_BIND` shared secret is unrestricted +and qualifies; only narrow signed credentials are refused. #### Calling a deployment from a browser diff --git a/website/src/app/docs/project-status/page.mdx b/website/src/app/docs/project-status/page.mdx index 3c623ab2b..5cd887a1c 100644 --- a/website/src/app/docs/project-status/page.mdx +++ b/website/src/app/docs/project-status/page.mdx @@ -218,7 +218,8 @@ examined, and the failure looks like the deployment being down. into access logs, proxies and traces. - It is honoured only where the deployment declares `REMEMBERSTACK_SELFHOST_TRUSTED_PRINCIPAL_SOURCE` (default off, for a - control-plane-only perimeter). Elsewhere attribution is **ignored, never + control-plane-only perimeter) and, when an API perimeter is configured, the + caller has full `write` authority. Elsewhere attribution is **ignored, never rejected** — metadata must not be able to fail an otherwise valid ingest. - Deleting a principal nulls attribution and never destroys the document version. This is row deletion, **not** a D74-grade hard forget; a From 4c1907545cbe55d4eff9cb6854a80935e6755f6f Mon Sep 17 00:00:00 2001 From: Jiri Puc Date: Thu, 3 Sep 2026 19:08:33 +0200 Subject: [PATCH 3/6] docs(auth): make attribution authority unambiguous Address every LOW finding from OPUS round 2. State the presenting-credential rule at the canonical decision and OpenAPI source, remove stale per-caller wording, and make the composition flag required so a future call site cannot omit the fail-closed choice. --- decisions.md | 25 +++++++++++++------------ openapi.json | 6 +++--- src/rememberstack/surfaces/http_api.py | 21 +++++++++++---------- 3 files changed, 27 insertions(+), 25 deletions(-) diff --git a/decisions.md b/decisions.md index d2767f83f..6b33870d8 100644 --- a/decisions.md +++ b/decisions.md @@ -4548,12 +4548,12 @@ principal, so D55's identical-bytes no-op can never let a later submitter rewrite it. The pair travels in `X-Ingest-Principal-*` **headers, never the query string**, because `external_ref` is erasable PII and a URL is copied into access logs, proxies and traces. It is honoured **only** when the -composing profile sets `trusted_principal_source`; the deployment-wide -bearer identifies a deployment, not a caller. Untrusted attribution is -**ignored, not rejected** — nothing forged is recorded either way, so a -refusal would buy no safety while letting a metadata concern fail a real -upload. `external_ref` is constrained to printable ASCII because the -header transport cannot carry more, making that an explicit 422 rather +composing profile sets `trusted_principal_source` and, where API authentication +is configured, the presenting credential carries full `write` authority. +Untrusted attribution is **ignored, not rejected** — nothing forged is recorded +either way, so a refusal would buy no safety while letting a metadata concern +fail a real upload. `external_ref` is constrained to printable ASCII because +the header transport cannot carry more, making that an explicit 422 rather than an encoding crash. Deleting a principal nulls attribution (`ON DELETE SET NULL`) and never destroys the version. `IngestedVersion` is unchanged and the `ingested_by` keyword is omitted @@ -4586,12 +4586,13 @@ this decision exists to prevent. **Consequences.** Erasure here is **row deletion, not a D74-grade forget**: there is no portable manifest, barrier, residual verification or restore replay for principals, so this must not be offered as an erasure -guarantee until the follow-up person-grain forget target lands. Within a -trusted perimeter the principal remains caller-asserted; deriving it would -need a per-caller perimeter the engine does not have. Operators must apply -the migration and start the new runtime **before** any client forwards -attribution — an older engine accepts the upload, creates an unattributed -version, and D55 then prevents a retry from repairing it. +guarantee until the follow-up person-grain forget target lands. A full-write +caller within a trusted perimeter still asserts the principal. Narrow +credentials never turn their own subject into attribution implicitly, because +that would conflate the credential holder with the immutable creator claim. +Operators must apply the migration and start the new runtime **before** any +client forwards attribution — an older engine accepts the upload, creates an +unattributed version, and D55 then prevents a retry from repairing it. **Rejected.** No principal; an opaque string without a kind; inferring a person from a credential; the principal in the query string; accepting diff --git a/openapi.json b/openapi.json index 58d2718c2..e19b78b8b 100644 --- a/openapi.json +++ b/openapi.json @@ -3669,7 +3669,7 @@ }, "/ingest": { "post": { - "description": "Push one file through E0, optionally as a stable lineage version.\n\nAttribution travels in **headers, never the query string**: the\nreference is erasable PII and a URL is copied verbatim into access\nlogs, proxies and traces, where a later principal deletion cannot\nreach it.\n\nThe pair is honoured only when the composing profile declares its\nnetwork perimeter trusted (``trusted_principal_source``) and any\nconfigured API credential has full WRITE authority. The unscoped shared\nsecret is unrestricted; narrow signed credentials may add or read data,\nbut cannot nominate an immutable principal. Untrusted attribution is\n**ignored, not rejected**: nothing forged is recorded either way, and\nrefusing would let a metadata concern fail an otherwise valid ingest.", + "description": "Push one file through E0, optionally as a stable lineage version.\n\nAttribution travels in **headers, never the query string**: the\nreference is erasable PII and a URL is copied verbatim into access\nlogs, proxies and traces, where a later principal deletion cannot\nreach it.\n\nThe pair is honoured only when the composing profile declares its\nnetwork perimeter trusted (``trusted_principal_source``) and the\npresenting credential carries full WRITE authority. The unscoped\nshared secret is unrestricted; narrow signed credentials may add or\nread data, but cannot nominate an immutable principal. Untrusted\nattribution is **ignored, not rejected**: nothing forged is recorded\neither way, and refusing would let a metadata concern fail an otherwise\nvalid ingest.", "operationId": "ingest_document_ingest_post", "parameters": [ { @@ -3790,7 +3790,7 @@ } }, { - "description": "One of: user | api_credential | service. Sent with X-Ingest-Principal-Ref. Ignored unless the deployment declares a trusted principal source and any configured API credential has full write authority; malformed values are 422 only for a trusted assertion.", + "description": "One of: user | api_credential | service. Sent with X-Ingest-Principal-Ref. Ignored unless the deployment declares a trusted principal source and the presenting credential carries full write authority; malformed values are 422 only for a trusted assertion.", "in": "header", "name": "X-Ingest-Principal-Kind", "required": false, @@ -3803,7 +3803,7 @@ "type": "null" } ], - "description": "One of: user | api_credential | service. Sent with X-Ingest-Principal-Ref. Ignored unless the deployment declares a trusted principal source and any configured API credential has full write authority; malformed values are 422 only for a trusted assertion.", + "description": "One of: user | api_credential | service. Sent with X-Ingest-Principal-Ref. Ignored unless the deployment declares a trusted principal source and the presenting credential carries full write authority; malformed values are 422 only for a trusted assertion.", "title": "X-Ingest-Principal-Kind" } }, diff --git a/src/rememberstack/surfaces/http_api.py b/src/rememberstack/surfaces/http_api.py index a1cb67fed..5039517c6 100644 --- a/src/rememberstack/surfaces/http_api.py +++ b/src/rememberstack/surfaces/http_api.py @@ -1176,8 +1176,8 @@ def _mount_ingest( ingest: IngestPort, deployment_id: UUID, max_body_bytes: int | None, + attribution_requires_write: bool, trusted_principal_source: bool = False, - attribution_requires_write: bool = False, ) -> None: """Add the D62 lineage-aware push surface over the E0 ingest gate.""" @@ -1204,9 +1204,9 @@ def ingest_document( description=( "One of: user | api_credential | service. Sent with" " X-Ingest-Principal-Ref. Ignored unless the deployment" - " declares a trusted principal source and any configured" - " API credential has full write authority; malformed values are" - " 422 only for a trusted assertion." + " declares a trusted principal source and the presenting" + " credential carries full write authority; malformed values" + " are 422 only for a trusted assertion." ), ), ] = None, @@ -1229,12 +1229,13 @@ def ingest_document( reach it. The pair is honoured only when the composing profile declares its - network perimeter trusted (``trusted_principal_source``) and any - configured API credential has full WRITE authority. The unscoped shared - secret is unrestricted; narrow signed credentials may add or read data, - but cannot nominate an immutable principal. Untrusted attribution is - **ignored, not rejected**: nothing forged is recorded either way, and - refusing would let a metadata concern fail an otherwise valid ingest. + network perimeter trusted (``trusted_principal_source``) and the + presenting credential carries full WRITE authority. The unscoped + shared secret is unrestricted; narrow signed credentials may add or + read data, but cannot nominate an immutable principal. Untrusted + attribution is **ignored, not rejected**: nothing forged is recorded + either way, and refusing would let a metadata concern fail an otherwise + valid ingest. """ if max_body_bytes is not None and len(content) > max_body_bytes: # the ASGI guard already refused honest requests; this backstop From f219bc88fe95cffd9c2c9b7f6a406c233d5a9992 Mon Sep 17 00:00:00 2001 From: Jiri Puc Date: Thu, 3 Sep 2026 19:23:06 +0200 Subject: [PATCH 4/6] docs(auth): clarify trusted attribution paths Address every LOW finding from OPUS round 3. Qualify the no-auth trusted-network path, state ingest/read disjointness precisely, restore the design rationale, use the canonical amendment form, and align the test module's stated scope. --- decisions.md | 9 ++++--- openapi.json | 6 ++--- plan/designs/ingest_principal_design.md | 15 +++++------ src/rememberstack/surfaces/http_api.py | 28 +++++++++++--------- src/tests/adapters/test_signed_token_auth.py | 5 ++-- 5 files changed, 33 insertions(+), 30 deletions(-) diff --git a/decisions.md b/decisions.md index 6b33870d8..53f2553f6 100644 --- a/decisions.md +++ b/decisions.md @@ -4571,10 +4571,11 @@ Alembic's autocommit boundary and impossible to pre-build by hand before the column existed. The later "documents by principal" operation adds the index in its own revision. -**Refinement (2026-09-03).** Where API authentication is configured, trusted -attribution additionally requires full `write` authority. The unscoped shared -secret remains unrestricted; narrow signed scopes cannot assert an immutable -principal and their attribution headers are ignored. +**Amendment (2026-09-03, ingest attribution authority).** Where API +authentication is configured, trusted attribution additionally requires full +`write` authority. The unscoped shared secret remains unrestricted; narrow +signed scopes cannot assert an immutable principal and their attribution +headers are ignored. **Why.** D50 makes content-level authorization and per-user scoping library non-goals; that is about **authorization**. Attribution is a diff --git a/openapi.json b/openapi.json index e19b78b8b..7fd0575a5 100644 --- a/openapi.json +++ b/openapi.json @@ -3669,7 +3669,7 @@ }, "/ingest": { "post": { - "description": "Push one file through E0, optionally as a stable lineage version.\n\nAttribution travels in **headers, never the query string**: the\nreference is erasable PII and a URL is copied verbatim into access\nlogs, proxies and traces, where a later principal deletion cannot\nreach it.\n\nThe pair is honoured only when the composing profile declares its\nnetwork perimeter trusted (``trusted_principal_source``) and the\npresenting credential carries full WRITE authority. The unscoped\nshared secret is unrestricted; narrow signed credentials may add or\nread data, but cannot nominate an immutable principal. Untrusted\nattribution is **ignored, not rejected**: nothing forged is recorded\neither way, and refusing would let a metadata concern fail an otherwise\nvalid ingest.", + "description": "Push one file through E0, optionally as a stable lineage version.\n\nAttribution travels in **headers, never the query string**: the\nreference is erasable PII and a URL is copied verbatim into access\nlogs, proxies and traces, where a later principal deletion cannot\nreach it.\n\nThe pair is honoured only when the composing profile declares its\nnetwork perimeter trusted (``trusted_principal_source``) and, where API\nauthentication is configured, the presenting credential carries full\nWRITE authority. The unscoped shared secret is unrestricted; a narrow\nsigned ``ingest`` credential may add a document but cannot nominate its\nprincipal. Untrusted attribution is **ignored, not rejected**: nothing\nforged is recorded either way, and refusing would let a metadata\nconcern fail an otherwise valid ingest.", "operationId": "ingest_document_ingest_post", "parameters": [ { @@ -3790,7 +3790,7 @@ } }, { - "description": "One of: user | api_credential | service. Sent with X-Ingest-Principal-Ref. Ignored unless the deployment declares a trusted principal source and the presenting credential carries full write authority; malformed values are 422 only for a trusted assertion.", + "description": "One of: user | api_credential | service. Sent with X-Ingest-Principal-Ref. Ignored unless the deployment declares a trusted principal source and, where API authentication is configured, the presenting credential carries full write authority; malformed values are 422 only for a trusted assertion.", "in": "header", "name": "X-Ingest-Principal-Kind", "required": false, @@ -3803,7 +3803,7 @@ "type": "null" } ], - "description": "One of: user | api_credential | service. Sent with X-Ingest-Principal-Ref. Ignored unless the deployment declares a trusted principal source and the presenting credential carries full write authority; malformed values are 422 only for a trusted assertion.", + "description": "One of: user | api_credential | service. Sent with X-Ingest-Principal-Ref. Ignored unless the deployment declares a trusted principal source and, where API authentication is configured, the presenting credential carries full write authority; malformed values are 422 only for a trusted assertion.", "title": "X-Ingest-Principal-Kind" } }, diff --git a/plan/designs/ingest_principal_design.md b/plan/designs/ingest_principal_design.md index 2c298349b..8b4416f6e 100644 --- a/plan/designs/ingest_principal_design.md +++ b/plan/designs/ingest_principal_design.md @@ -65,14 +65,13 @@ cannot name its immutable principal. Without API auth, enabling the setting trusts the network perimeter by itself. On an ordinarily reachable deployment it makes attribution meaningless rather than merely permissive. -Attribution is honoured only when the composing profile declares -`trusted_principal_source=True` and, when present, the API credential carries -full `write` authority. The legacy shared secret has that unrestricted -authority; a narrow signed credential identifies its own caller but has no -authority to assert an arbitrary principal. Untrusted attribution is **ignored, -not rejected**: nothing forged is recorded either way, so refusing buys no -safety while adding a failure mode where a merely misconfigured deployment -rejects real documents. +Full `write` is the line because such a credential can already write anything +to this deployment, so naming a principal grants it no authority it lacks. A +narrow credential's issuer deliberately withheld everything else, and +attribution must not be the one fact it can still forge. Untrusted attribution +is **ignored, not rejected**: nothing forged is recorded either way, so refusing +buys no safety while adding a failure mode where a merely misconfigured +deployment rejects real documents. **Metadata must never break ingest.** Default is off, so the self-host posture is unchanged. diff --git a/src/rememberstack/surfaces/http_api.py b/src/rememberstack/surfaces/http_api.py index 5039517c6..5d20e64a0 100644 --- a/src/rememberstack/surfaces/http_api.py +++ b/src/rememberstack/surfaces/http_api.py @@ -1137,9 +1137,10 @@ def _parse_ingest_principal( ) -> IngestPrincipal | None: """Validate a trusted attribution pair, or raise a 422 explaining why. - Only reached on a trusted perimeter presenting full WRITE authority, so a - malformed pair here is a real client error worth reporting rather than - metadata that should be dropped. + Only reached on a trusted network perimeter and, where API authentication + is configured, one presenting full WRITE authority. A malformed pair here + is therefore a real client error worth reporting rather than metadata that + should be dropped. """ if kind is None and ref is None: return None @@ -1204,9 +1205,10 @@ def ingest_document( description=( "One of: user | api_credential | service. Sent with" " X-Ingest-Principal-Ref. Ignored unless the deployment" - " declares a trusted principal source and the presenting" - " credential carries full write authority; malformed values" - " are 422 only for a trusted assertion." + " declares a trusted principal source and, where API" + " authentication is configured, the presenting credential" + " carries full write authority; malformed values are 422" + " only for a trusted assertion." ), ), ] = None, @@ -1229,13 +1231,13 @@ def ingest_document( reach it. The pair is honoured only when the composing profile declares its - network perimeter trusted (``trusted_principal_source``) and the - presenting credential carries full WRITE authority. The unscoped - shared secret is unrestricted; narrow signed credentials may add or - read data, but cannot nominate an immutable principal. Untrusted - attribution is **ignored, not rejected**: nothing forged is recorded - either way, and refusing would let a metadata concern fail an otherwise - valid ingest. + network perimeter trusted (``trusted_principal_source``) and, where API + authentication is configured, the presenting credential carries full + WRITE authority. The unscoped shared secret is unrestricted; a narrow + signed ``ingest`` credential may add a document but cannot nominate its + principal. Untrusted attribution is **ignored, not rejected**: nothing + forged is recorded either way, and refusing would let a metadata + concern fail an otherwise valid ingest. """ if max_body_bytes is not None and len(content) > max_body_bytes: # the ASGI guard already refused honest requests; this backstop diff --git a/src/tests/adapters/test_signed_token_auth.py b/src/tests/adapters/test_signed_token_auth.py index 8381b1ffb..b03242935 100644 --- a/src/tests/adapters/test_signed_token_auth.py +++ b/src/tests/adapters/test_signed_token_auth.py @@ -1,10 +1,11 @@ -"""The signed perimeter credential, and what it refuses. +"""Signed perimeter credentials and shared-secret compatibility at their boundary. These tests are mostly about refusal. A verifier that accepts good credentials is easy; the interesting question is whether it can be talked into accepting something it should not — a token signed with the wrong key, one aimed at another deployment, one asking to be verified with no algorithm at all, or one -whose expiry has passed. +whose expiry has passed. The HTTP proofs also pin compatibility with the legacy +unscoped shared secret wherever narrow signed authority changes behaviour. """ from __future__ import annotations From aa847ddc3a79b5f881095abde3e9c105f75644a1 Mon Sep 17 00:00:00 2001 From: Jiri Puc Date: Thu, 3 Sep 2026 19:37:06 +0200 Subject: [PATCH 5/6] docs(auth): make trust configuration actionable Put the trusted-principal explanation beside its setting and name the exact operator control in public docs. Clarify that narrow credentials lose only their attribution headers while their upload still succeeds. --- plan/designs/ingest_principal_design.md | 15 ++++++------ src/rememberstack/profiles/selfhost.py | 27 ++++++++++++--------- src/rememberstack/surfaces/http_api.py | 16 ++++++------ website/src/app/docs/configuration/page.mdx | 8 +++--- 4 files changed, 36 insertions(+), 30 deletions(-) diff --git a/plan/designs/ingest_principal_design.md b/plan/designs/ingest_principal_design.md index 8b4416f6e..029d8d4e2 100644 --- a/plan/designs/ingest_principal_design.md +++ b/plan/designs/ingest_principal_design.md @@ -57,13 +57,14 @@ The composing profile surfaces this as `trusted_principal_source` (env **`REMEMBERSTACK_SELFHOST_TRUSTED_PRINCIPAL_SOURCE`** — the self-host settings class carries the `REMEMBERSTACK_SELFHOST_` prefix), default off. Enable it **only** where the deployment's network perimeter has already -authenticated the actor it names. When the API auth perimeter is configured, -the request must additionally carry full `write` authority before these -headers are believed. The unscoped shared secret is unrestricted and therefore -qualifies; a narrow signed browser `ingest` credential can add a document but -cannot name its immutable principal. Without API auth, enabling the setting -trusts the network perimeter by itself. On an ordinarily reachable deployment -it makes attribution meaningless rather than merely permissive. +authenticated the actor it names, such as a managed data plane behind a private +transit gateway for privileged callers. When the API auth perimeter is +configured, the request must additionally carry full `write` authority before +these headers are believed. The unscoped shared secret is unrestricted and +therefore qualifies; a narrow signed browser `ingest` credential can add a +document but cannot name its immutable principal. Without API auth, enabling +the setting trusts the network perimeter by itself. On an ordinarily reachable +deployment it makes attribution meaningless rather than merely permissive. Full `write` is the line because such a credential can already write anything to this deployment, so naming a principal grants it no authority it lacks. A diff --git a/src/rememberstack/profiles/selfhost.py b/src/rememberstack/profiles/selfhost.py index b5e57cf66..6b8187bef 100644 --- a/src/rememberstack/profiles/selfhost.py +++ b/src/rememberstack/profiles/selfhost.py @@ -118,6 +118,21 @@ class SelfHostSettings(BaseSettings): managed host sets its published bound here so it is enforced where the body is actually received.""" trusted_principal_source: bool = False + """Whether `X-Ingest-Principal-*` on `POST /ingest` is believed (D101). + + Env: `REMEMBERSTACK_SELFHOST_TRUSTED_PRINCIPAL_SOURCE` (this settings + class carries the `REMEMBERSTACK_SELFHOST_` prefix). + + Off by default. Enable only where the deployment is reachable through a + network perimeter that authenticates the asserted actor, such as a managed + data plane behind a private transit gateway for privileged callers. When + API auth is configured, only full ``write`` authority may supply + attribution: the unscoped shared secret is unrestricted and qualifies, + while a narrow signed credential's attribution headers are ignored even + when this flag is on; the upload itself still succeeds. Without API auth, + this setting trusts the network perimeter by itself. Enabling it on an + ordinarily reachable deployment makes attribution meaningless, not merely + permissive.""" #: Browser origins allowed to call this deployment (D59). #: #: Empty by default, which is the self-host answer: nothing is advertised @@ -128,18 +143,6 @@ class SelfHostSettings(BaseSettings): #: #: Comma-separated in the environment; each must be an exact https origin. browser_origins: str = "" - """Whether `X-Ingest-Principal-*` on `POST /ingest` is believed (D101). - - Env: `REMEMBERSTACK_SELFHOST_TRUSTED_PRINCIPAL_SOURCE` (this settings - class carries the `REMEMBERSTACK_SELFHOST_` prefix). - - Off by default. The network perimeter must authenticate the asserted actor. - When API auth is configured, only full ``write`` authority may supply - attribution: the unscoped shared secret is unrestricted and qualifies, - while narrow signed credentials are ignored even when this flag is on. - Without API auth, this setting trusts the network perimeter by itself. - Enabling it on an ordinarily reachable deployment makes attribution - meaningless, not merely permissive.""" conversion_routes: Annotated[dict[str, str], NoDecode] = Field( default_factory=lambda: dict(STOCK_CONVERSION_ROUTE_NAMES) ) diff --git a/src/rememberstack/surfaces/http_api.py b/src/rememberstack/surfaces/http_api.py index 5d20e64a0..017ba93e0 100644 --- a/src/rememberstack/surfaces/http_api.py +++ b/src/rememberstack/surfaces/http_api.py @@ -355,14 +355,14 @@ def build_api( control plane for ingest/search/operations POST (D46). Each capability is explicitly composed; absent services do not pretend to exist. - `trusted_principal_source` declares that the deployment's network perimeter - is entitled to state who ingested a document. It is **off by default**, - and a configured auth perimeter additionally requires full WRITE authority - before believing the headers. The unscoped shared secret is unrestricted; - a narrow signed browser ingest credential identifies its holder but cannot - nominate some other immutable principal. Elsewhere an asserted - `X-Ingest-Principal-*` pair is **ignored, never rejected** — metadata must - not be able to fail an otherwise valid ingest. + `trusted_principal_source` declares that this deployment's network perimeter + is reached only by callers entitled to state who ingested a document. It is + **off by default**, and a configured auth perimeter additionally requires + full WRITE authority before believing the headers. The unscoped shared + secret is unrestricted; a narrow signed browser ingest credential + identifies its holder but cannot nominate some other immutable principal. + Elsewhere an asserted `X-Ingest-Principal-*` pair is **ignored, never + rejected** — metadata must not be able to fail an otherwise valid ingest. `ingest_body_max_bytes` bounds `POST /ingest` request bodies before they are buffered (413 over the cap; 411 when no Content-Length is declared). diff --git a/website/src/app/docs/configuration/page.mdx b/website/src/app/docs/configuration/page.mdx index ff460f2f4..ac58f8792 100644 --- a/website/src/app/docs/configuration/page.mdx +++ b/website/src/app/docs/configuration/page.mdx @@ -49,6 +49,7 @@ Authoritative template: repository [`.env.example`](https://github.com/writeitai | `REMEMBERSTACK_SELFHOST_API_SIGNING_KEYS` | Optional JWKS (Ed25519 public keys only) this deployment verifies signed credentials against. Set beside or instead of `API_BEARER_BIND`: a caller may present a shared secret, a signed credential, or either. Refused at startup if it carries private key material, a non-Ed25519 key, or a key that half loads | | `REMEMBERSTACK_SELFHOST_API_REVOKED_CREDENTIAL_IDS` | Optional comma-separated credential ids to refuse. A signature is verified by arithmetic, so its issuer cannot withdraw one — this is how a revoked but unexpired credential is refused. Meaningless without `API_SIGNING_KEYS`, and rejected if set alone | | `REMEMBERSTACK_SELFHOST_REQUIRE_API_AUTH` | When `true`, the API process refuses to start unless it has a perimeter — `API_BEARER_BIND`, `API_SIGNING_KEYS`, or both. Default `false` (open quickstart) | +| `REMEMBERSTACK_SELFHOST_TRUSTED_PRINCIPAL_SOURCE` | Whether `X-Ingest-Principal-*` attribution is believed. Default `false`; enable only behind a network perimeter that authenticates the asserted actor. With API auth configured, the presenting credential also needs full `write` authority | | `REMEMBERSTACK_SELFHOST_BROWSER_ORIGINS` | Optional comma-separated `https://` origins allowed to call this deployment from a browser. Empty by default, which advertises no CORS at all. Each entry must be an exact scheme-and-host origin — no wildcard, no path, no `http://` | | `REMEMBERSTACK_API_URL` | Client target; defaults to `http://127.0.0.1:8000` — **must match** if you change the port | | `REMEMBERSTACK_TOKEN_HOST` | Device-grant host for `remember login` (required unless `--token-host` is passed). Never derived from the query API URL | @@ -73,9 +74,10 @@ on a signed credential is refused rather than downgraded. An `ingest` credential also cannot assert `X-Ingest-Principal-Kind` or `X-Ingest-Principal-Ref`. Those immutable attribution headers are ignored -unless the deployment trusts their network source and the caller has full -`write` authority. The unscoped `API_BEARER_BIND` shared secret is unrestricted -and qualifies; only narrow signed credentials are refused. +unless `REMEMBERSTACK_SELFHOST_TRUSTED_PRINCIPAL_SOURCE` is enabled and the +caller has full `write` authority. The unscoped `API_BEARER_BIND` shared secret +is unrestricted and qualifies; a narrow credential's attribution headers are +ignored while its otherwise valid upload still succeeds. #### Calling a deployment from a browser From c7213af1bdb171a01560a1b9287805111c4d418a Mon Sep 17 00:00:00 2001 From: Jiri Puc Date: Thu, 3 Sep 2026 19:47:54 +0200 Subject: [PATCH 6/6] docs(auth): describe narrow scope behavior exactly State that ingest credentials cannot create any attribution, including self-attribution, and enumerate the read-scoped operation and connector listing routes. --- src/rememberstack/surfaces/http_api.py | 9 +++++---- website/src/app/docs/configuration/page.mdx | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/rememberstack/surfaces/http_api.py b/src/rememberstack/surfaces/http_api.py index 017ba93e0..9f744c9be 100644 --- a/src/rememberstack/surfaces/http_api.py +++ b/src/rememberstack/surfaces/http_api.py @@ -359,10 +359,11 @@ def build_api( is reached only by callers entitled to state who ingested a document. It is **off by default**, and a configured auth perimeter additionally requires full WRITE authority before believing the headers. The unscoped shared - secret is unrestricted; a narrow signed browser ingest credential - identifies its holder but cannot nominate some other immutable principal. - Elsewhere an asserted `X-Ingest-Principal-*` pair is **ignored, never - rejected** — metadata must not be able to fail an otherwise valid ingest. + secret is unrestricted; a narrow signed browser ingest credential may add + a document, but its attribution headers are ignored and its own subject + never becomes attribution. Elsewhere an asserted `X-Ingest-Principal-*` + pair is **ignored, never rejected** — metadata must not be able to fail an + otherwise valid ingest. `ingest_body_max_bytes` bounds `POST /ingest` request bodies before they are buffered (413 over the cap; 411 when no Content-Length is declared). diff --git a/website/src/app/docs/configuration/page.mdx b/website/src/app/docs/configuration/page.mdx index ac58f8792..3ef6ceb0d 100644 --- a/website/src/app/docs/configuration/page.mdx +++ b/website/src/app/docs/configuration/page.mdx @@ -64,7 +64,7 @@ exactly one of three scopes: | Scope | What it reaches | | --- | --- | -| `read` | The enumerated retrieval, search, inventory, and readiness routes. It cannot ingest or change configuration | +| `read` | The enumerated retrieval, search, inventory, readiness, and operation/connector listing routes. It cannot ingest, run an assured operation that has not declared itself non-mutating, or change configuration | | `ingest` | Only `POST /ingest`. It cannot search or inspect memory, run an assured operation, or create a connector that keeps pulling after the caller leaves | | `write` | The complete deployment API, including `POST /ingest`; it also satisfies every `read` requirement |