From b7bf6e6594efae6bdc8bca9fa3996f9eb7a4a695 Mon Sep 17 00:00:00 2001 From: Jiri Puc Date: Thu, 3 Sep 2026 15:25:38 +0200 Subject: [PATCH] feat(auth): add a narrow ingest perimeter scope Add the D62 ingest scope so browser credentials can upload one document without gaining read access or standing connector authority. Keep WRITE as the only scope that crosses route classes, exercise real signed credentials at the HTTP perimeter, and document the closed three-scope vocabulary. --- src/rememberstack/model/auth.py | 21 ++- src/rememberstack/surfaces/route_scope.py | 20 ++- src/tests/adapters/test_signed_token_auth.py | 169 +++++++++++++++++++ src/tests/surfaces/test_route_scope.py | 58 ++++++- website/src/app/docs/configuration/page.mdx | 15 ++ 5 files changed, 264 insertions(+), 19 deletions(-) diff --git a/src/rememberstack/model/auth.py b/src/rememberstack/model/auth.py index 28412e10..01a39188 100644 --- a/src/rememberstack/model/auth.py +++ b/src/rememberstack/model/auth.py @@ -13,23 +13,28 @@ class PerimeterScope(StrEnum): """What an authenticated caller may do, as a closed vocabulary. - Two values, deliberately. A scope vocabulary that grows by string - concatenation fails open the first time somebody adds a route, and the - perimeter is not the place to build a permission language: it exists to - answer one narrow question cheaply, in front of every request. - - ``WRITE`` includes everything ``READ`` allows. A credential that may change - the memory may obviously also look at it. + Three values, deliberately. ``INGEST`` exists for D62: a browser that may + add one document must not also be able to create a connector — standing + configuration that keeps pulling from a third-party system after the tab + closes. A scope vocabulary that grows by string concatenation fails open + the first time somebody adds a route, and the perimeter is not the place + to build a permission language: it exists to answer one narrow question + cheaply, in front of every request. + + ``WRITE`` includes everything. ``READ`` and ``INGEST`` are disjoint: an + ingest credential may reach only the one ingest route, not retrieval or + any other mutation. """ READ = "read" + INGEST = "ingest" WRITE = "write" def covers(self, *, required: "PerimeterScope") -> bool: """True when this scope satisfies ``required``.""" if self is PerimeterScope.WRITE: return True - return required is PerimeterScope.READ + return self is required class CredentialKind(StrEnum): diff --git a/src/rememberstack/surfaces/route_scope.py b/src/rememberstack/surfaces/route_scope.py index 57a98f77..b8cf861e 100644 --- a/src/rememberstack/surfaces/route_scope.py +++ b/src/rememberstack/surfaces/route_scope.py @@ -6,8 +6,9 @@ believing it would hand a read-only credential the ability to ingest. ``POST /graph/neighborhood``, ``POST /graph/path``, ``POST /query/sql`` and ``POST /readiness`` are reads that use POST because their arguments do not fit -in a query string. ``POST /ingest`` and ``POST /connectors`` change the -memory. The method tells you nothing about which is which. +in a query string. ``POST /ingest`` and ``POST /connectors`` change the memory, +but D62 permits a browser to do only the first. The method tells you nothing +about which is which. So the mapping is enumerated by hand, and the default for anything not enumerated is ``WRITE``. @@ -75,6 +76,16 @@ ) +#: The single route a narrow D62 browser ingest credential may reach. +#: +#: Kept separate from reads because an ingest credential cannot read memory, +#: and separate from the WRITE fallback because it must not create standing +#: connectors or perform any other mutation. +_INGEST_ROUTES: tuple[tuple[str, re.Pattern[str]], ...] = ( + ("POST", re.compile(r"^/ingest$")), +) + + #: Routes whose scope cannot be decided from the path, and which enforce it #: themselves. Exactly one today: an assured operation's authority is a #: property of the operation, and operations are registry data. @@ -94,13 +105,16 @@ def required_scope(*, method: str, path: str) -> PerimeterScope | None: the perimeter enforces nothing and the handler must. Unenumerated routes require :attr:`PerimeterScope.WRITE`, so a route added - without a decision here is closed to read-only callers rather than open. + without a decision here is closed to narrow credentials rather than open. """ normalised = path.rstrip("/") or "/" upper = method.upper() for route_method, pattern in _ROUTE_DECIDES: if route_method == upper and pattern.match(normalised): return None + for route_method, pattern in _INGEST_ROUTES: + if route_method == upper and pattern.match(normalised): + return PerimeterScope.INGEST for route_method, pattern in _READ_ROUTES: if route_method == upper and pattern.match(normalised): return PerimeterScope.READ diff --git a/src/tests/adapters/test_signed_token_auth.py b/src/tests/adapters/test_signed_token_auth.py index 563efaf5..a709f3d8 100644 --- a/src/tests/adapters/test_signed_token_auth.py +++ b/src/tests/adapters/test_signed_token_auth.py @@ -13,9 +13,12 @@ from datetime import timedelta from datetime import timezone import json +from typing import Any +from uuid import UUID from uuid import uuid4 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from fastapi.testclient import TestClient import jwt from jwt.algorithms import OKPAlgorithm from pydantic import SecretBytes @@ -24,8 +27,12 @@ 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.model import DocumentUpload +from rememberstack.model import IngestedVersion +from rememberstack.model import IngestPrincipal from rememberstack.model import PerimeterCredential from rememberstack.model.auth import PerimeterScope +from rememberstack.surfaces.http_api import build_api def _keypair(*, kid: str) -> tuple[Ed25519PrivateKey, str]: @@ -72,6 +79,86 @@ def _present(auth: SignedTokenAuth, token: str): # noqa: ANN202 ) +class _OpenBoundary: + """Admission and readiness for the perimeter-only HTTP proof.""" + + def ensure_ready(self, *, deployment_id: UUID) -> tuple[UUID, ...]: + """Admit the configured deployment while the API composes.""" + return () + + def assert_available(self, *, deployment_id: UUID) -> None: + """Leave request admission open.""" + + +class _UnusedEngine: + """A query engine that a refused request must never reach.""" + + def __getattr__(self, name: str) -> Any: + """Turn an accidental handler call into a precise test failure.""" + raise AssertionError(f"ingest-scope request reached query engine method {name}") + + +class _EmptyOperations: + """An operation surface where every unknown operation fails closed.""" + + def __init__(self, *, deployment_id: UUID) -> None: + """Bind the deployment identity checked during composition.""" + self.deployment_id = deployment_id + + def descriptors(self) -> tuple[object, ...]: + """No descriptor means an operation requires WRITE.""" + return () + + def run(self, **_kwargs: object) -> Any: + """The ingest credential must be refused before dispatch.""" + raise AssertionError("ingest-scope request reached an assured operation") + + +class _RecordingIngest: + """A real endpoint result with a counter proving the handler ran.""" + + def __init__(self, *, deployment_id: UUID) -> None: + """Bind the receipt to the served deployment.""" + self.deployment_id = deployment_id + self.calls = 0 + + def ingest( + self, + *, + deployment_id: UUID, + upload: DocumentUpload, + ingested_by: IngestPrincipal | None = None, + ) -> IngestedVersion: + """Record one accepted upload.""" + assert deployment_id == self.deployment_id + assert upload.content == b"memory" + assert ingested_by is None + self.calls += 1 + return IngestedVersion( + deployment_id=deployment_id, + doc_id=uuid4(), + version_id=uuid4(), + content_hash="0" * 64, + created=True, + ) + + def ingest_observed( + self, + *, + deployment_id: UUID, + source_kind: str, + source_ref: str, + upload: DocumentUpload, + versioning_mode: str, + source_modified_at: datetime | None, + source_version_ref: str | None, + sync_cycle_id: UUID | None, + ingested_by: IngestPrincipal | None = None, + ) -> IngestedVersion: + """This proof exercises only the one-shot browser upload path.""" + raise AssertionError("unexpected observed-source 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() @@ -91,6 +178,88 @@ def test_a_valid_credential_names_its_subject_and_scope() -> None: assert context.principal == "signed-bearer" +def test_an_ingest_credential_uses_the_closed_ingest_scope() -> None: + """D62's browser credential is recognised without widening its authority.""" + deployment_id = uuid4() + private, jwks = _keypair(kid="k1") + auth = SignedTokenAuth( + deployment_id=deployment_id, keys=load_verification_keys(jwks=jwks) + ) + + context = _present( + auth, + _token(private=private, kid="k1", audience=str(deployment_id), scope="ingest"), + ) + + assert context.scope is PerimeterScope.INGEST + + +def test_an_ingest_signed_token_reaches_only_the_ingest_route() -> None: + """The signed D62 credential uploads, but cannot read or configure memory.""" + deployment_id = uuid4() + private, jwks = _keypair(kid="k1") + 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) + ingest_token = _token( + private=private, kid="k1", audience=str(deployment_id), scope="ingest" + ) + ingest_headers = {"Authorization": f"Bearer {ingest_token}"} + + accepted = client.post( + "/ingest?filename=memory.md&mime=text/markdown", + content=b"memory", + headers={**ingest_headers, "Content-Type": "application/octet-stream"}, + ) + assert accepted.status_code == 200, accepted.text + assert ingest.calls == 1 + + refused = ( + client.post( + "/connectors", + json={"kind": "watched-directory", "name": "standing pull"}, + headers=ingest_headers, + ), + client.post(f"/connectors/{uuid4()}/pause", headers=ingest_headers), + client.get( + "/search/claims", params={"query": "secret"}, headers=ingest_headers + ), + client.get( + "/search/chunks", params={"query": "secret"}, headers=ingest_headers + ), + client.post("/operations/anything", json={}, headers=ingest_headers), + ) + assert [response.status_code for response in refused] == [403] * len(refused) + 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_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/src/tests/surfaces/test_route_scope.py b/src/tests/surfaces/test_route_scope.py index 6bc03643..e9d1fdef 100644 --- a/src/tests/surfaces/test_route_scope.py +++ b/src/tests/surfaces/test_route_scope.py @@ -1,4 +1,4 @@ -"""Which routes a read-only credential may reach. +"""Which routes each narrow perimeter credential may reach. The table exists because the obvious rule is wrong here: this API uses POST for reads whose arguments do not fit in a query string. These tests pin that, and @@ -38,7 +38,6 @@ def test_reads_are_reachable_by_a_read_credential(method: str, path: str) -> Non @pytest.mark.parametrize( ("method", "path"), [ - ("POST", "/ingest"), ("POST", "/connectors"), ("POST", "/connectors/abc/pause"), ("DELETE", "/search/claims"), @@ -49,6 +48,11 @@ def test_writes_require_write(method: str, path: str) -> None: assert required_scope(method=method, path=path) is PerimeterScope.WRITE +def test_ingest_requires_the_narrow_ingest_scope() -> None: + """D62 separates one upload from every other way to change memory.""" + assert required_scope(method="POST", path="/ingest") is PerimeterScope.INGEST + + def test_an_unknown_route_requires_write() -> None: """The default runs toward refusal. @@ -98,9 +102,47 @@ def test_an_undeclared_operation_requires_write() -> None: assert operation_scope(mutates=False) is PerimeterScope.READ -def test_write_covers_read_but_not_the_reverse() -> None: - """A credential that may change the memory may obviously also read it.""" - assert PerimeterScope.WRITE.covers(required=PerimeterScope.READ) - assert PerimeterScope.WRITE.covers(required=PerimeterScope.WRITE) - assert PerimeterScope.READ.covers(required=PerimeterScope.READ) - assert not PerimeterScope.READ.covers(required=PerimeterScope.WRITE) +@pytest.mark.parametrize( + ("credential", "required", "allowed"), + [ + (PerimeterScope.READ, PerimeterScope.READ, True), + (PerimeterScope.READ, PerimeterScope.INGEST, False), + (PerimeterScope.READ, PerimeterScope.WRITE, False), + (PerimeterScope.INGEST, PerimeterScope.READ, False), + (PerimeterScope.INGEST, PerimeterScope.INGEST, True), + (PerimeterScope.INGEST, PerimeterScope.WRITE, False), + (PerimeterScope.WRITE, PerimeterScope.READ, True), + (PerimeterScope.WRITE, PerimeterScope.INGEST, True), + (PerimeterScope.WRITE, PerimeterScope.WRITE, True), + ], +) +def test_scope_satisfaction_is_closed( + credential: PerimeterScope, required: PerimeterScope, allowed: bool +) -> None: + """Only WRITE crosses scope classes; every narrower pair fails closed.""" + assert credential.covers(required=required) is allowed + + +@pytest.mark.parametrize( + ("method", "path"), + [ + ("POST", "/connectors"), + ("POST", "/connectors/abc/pause"), + ("GET", "/search/claims"), + ("GET", "/search/chunks"), + ("POST", "/some/new/write"), + ], +) +def test_an_ingest_credential_reaches_no_other_route(method: str, path: str) -> None: + """The upload credential cannot configure pull or inspect memory.""" + required = required_scope(method=method, path=path) + assert required is not None + assert not PerimeterScope.INGEST.covers(required=required) + + +@pytest.mark.parametrize("mutates", [None, False, True]) +def test_an_ingest_credential_reaches_no_assured_operation( + mutates: bool | None, +) -> None: + """Dynamic operations are outside D62 whether they read or mutate.""" + assert not PerimeterScope.INGEST.covers(required=operation_scope(mutates=mutates)) diff --git a/website/src/app/docs/configuration/page.mdx b/website/src/app/docs/configuration/page.mdx index 5ee5cc86..ea2803a2 100644 --- a/website/src/app/docs/configuration/page.mdx +++ b/website/src/app/docs/configuration/page.mdx @@ -56,6 +56,21 @@ Authoritative template: repository [`.env.example`](https://github.com/writeitai | `REMEMBERSTACK_COST_EXPORT_BIND` | Optional second listen address for HTTP cost export (`127.0.0.1:8001`, `[::1]:8001`, or `unix:/path`). Unset = no HTTP export | | `REMEMBERSTACK_COST_EXPORT_TOKEN` | Bearer for the export listener. Required and ≥32 bytes when the bind is set | +#### Signed credential scopes + +A credential verified through `REMEMBERSTACK_SELFHOST_API_SIGNING_KEYS` names +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 | +| `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 | + +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. + #### Calling a deployment from a browser A browser refuses a cross-origin request *before it is sent* unless the server