Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 13 additions & 8 deletions src/rememberstack/model/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
20 changes: 17 additions & 3 deletions src/rememberstack/surfaces/route_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``.
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down
169 changes: 169 additions & 0 deletions src/tests/adapters/test_signed_token_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]:
Expand Down Expand Up @@ -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()
Expand All @@ -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")
Expand Down
58 changes: 50 additions & 8 deletions src/tests/surfaces/test_route_scope.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"),
Expand All @@ -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.

Expand Down Expand Up @@ -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))
15 changes: 15 additions & 0 deletions website/src/app/docs/configuration/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading