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