diff --git a/decisions.md b/decisions.md index 42c21ec8..53f2553f 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 @@ -4571,6 +4571,12 @@ 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. +**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 provenance fact, changes no read path, and is the one thing only the @@ -4581,12 +4587,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 1030ee21..7fd0575a 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, 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; 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, 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; 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, 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 a1d4730f..029d8d4e 100644 --- a/plan/designs/ingest_principal_design.md +++ b/plan/designs/ingest_principal_design.md @@ -55,25 +55,31 @@ 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. - -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. +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, 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 +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. -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 7996bac3..6b8187be 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 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.""" 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 ab3f3cf9..9f744c9b 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,15 @@ 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 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 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). @@ -514,6 +518,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) @@ -1133,8 +1138,10 @@ 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 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 @@ -1171,12 +1178,14 @@ def _mount_ingest( ingest: IngestPort, deployment_id: UUID, max_body_bytes: int | None, + attribution_requires_write: bool, trusted_principal_source: bool = False, ) -> None: """Add the D62 lineage-aware push surface over the E0 ingest gate.""" @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 +1206,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; malformed values" - " are 422 only on a trusted 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." ), ), ] = None, @@ -1221,11 +1232,13 @@ 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, 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 @@ -1237,10 +1250,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 = 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 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 a709f3d8..b0324293 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 @@ -27,11 +28,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 @@ -121,6 +126,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 +138,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(), @@ -159,6 +165,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() @@ -201,19 +227,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, - ) - 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" ) @@ -226,6 +240,7 @@ def test_an_ingest_signed_token_reaches_only_the_ingest_route() -> None: ) assert accepted.status_code == 200, accepted.text assert ingest.calls == 1 + assert ingest.last_principal is None refused = ( client.post( @@ -245,6 +260,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" ) @@ -260,6 +289,76 @@ def test_an_ingest_signed_token_reaches_only_the_ingest_route() -> None: 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" + ) + 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", + "X-Ingest-Principal-Kind": "user", + "X-Ingest-Principal-Ref": "user:trusted-control-plane", + }, + ) + assert write_response.status_code == 200, write_response.text + 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 ea2803a2..3ef6ceb0 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 | @@ -63,7 +64,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, 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 | @@ -71,6 +72,13 @@ 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 `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 A browser refuses a cross-origin request *before it is sent* unless the server diff --git a/website/src/app/docs/project-status/page.mdx b/website/src/app/docs/project-status/page.mdx index 3c623ab2..5cd887a1 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