constant-time auth, migration short-circuit
This commit is contained in:
parent
da3cfe1a58
commit
c2f681dd78
5 changed files with 76 additions and 4 deletions
|
|
@ -1,3 +1,5 @@
|
||||||
|
import secrets
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, Request, status
|
from fastapi import Depends, HTTPException, Request, status
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
|
||||||
|
|
@ -14,7 +16,7 @@ async def require_auth(
|
||||||
expected = getattr(request.app.state, "auth_token", None)
|
expected = getattr(request.app.state, "auth_token", None)
|
||||||
if expected is None:
|
if expected is None:
|
||||||
return
|
return
|
||||||
if creds is None or creds.credentials != expected:
|
if creds is None or not secrets.compare_digest(creds.credentials, expected):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Unauthorized",
|
detail="Unauthorized",
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ async def list_sources(
|
||||||
source_id=poller.source_id,
|
source_id=poller.source_id,
|
||||||
type=type(poller.config).__name__,
|
type=type(poller.config).__name__,
|
||||||
last_polled_at=poller.last_polled_at,
|
last_polled_at=poller.last_polled_at,
|
||||||
circuit_breaker_open=poller._breaker.is_open,
|
circuit_breaker_open=poller.is_circuit_open,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return summaries
|
return summaries
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,10 @@ class BasePoller:
|
||||||
def last_polled_at(self) -> datetime | None:
|
def last_polled_at(self) -> datetime | None:
|
||||||
return self._last_polled_at
|
return self._last_polled_at
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_circuit_open(self) -> bool:
|
||||||
|
return self._breaker.is_open
|
||||||
|
|
||||||
async def run(self) -> None: # pragma: no cover - subclasses override
|
async def run(self) -> None: # pragma: no cover - subclasses override
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,11 +35,24 @@ def _normalize_metadata(meta: dict) -> tuple[dict, bool]:
|
||||||
async def _apply_canonical_metadata_keys(store: Store) -> None:
|
async def _apply_canonical_metadata_keys(store: Store) -> None:
|
||||||
"""Rewrite document.metadata so revision lives under the source-agnostic
|
"""Rewrite document.metadata so revision lives under the source-agnostic
|
||||||
`source_revision` key and `contentType` becomes `content_type`. Documents
|
`source_revision` key and `contentType` becomes `content_type`. Documents
|
||||||
whose metadata already uses the canonical keys are untouched."""
|
whose metadata already uses the canonical keys are untouched.
|
||||||
|
|
||||||
|
Scoped via LIKE on the JSON-encoded metadata string so already-migrated
|
||||||
|
DBs return an empty set and skip the materialisation pass on subsequent
|
||||||
|
boots. `metadata` is stored as JSON text, so the quoted key form
|
||||||
|
(`"etag"`, `"contentType"`) is unambiguous against substrings inside
|
||||||
|
values.
|
||||||
|
"""
|
||||||
rows = (
|
rows = (
|
||||||
await store.documents_table.query().select(["id", "metadata"]).to_arrow()
|
await store.documents_table.query()
|
||||||
|
.where("metadata LIKE '%\"etag\"%' OR metadata LIKE '%\"contentType\"%'")
|
||||||
|
.select(["id", "metadata"])
|
||||||
|
.to_arrow()
|
||||||
).to_pylist()
|
).to_pylist()
|
||||||
total = len(rows)
|
total = len(rows)
|
||||||
|
if total == 0:
|
||||||
|
logger.info("No legacy metadata keys found; nothing to migrate")
|
||||||
|
return
|
||||||
logger.info("Normalising document metadata keys across %d documents", total)
|
logger.info("Normalising document metadata keys across %d documents", total)
|
||||||
rewritten = 0
|
rewritten = 0
|
||||||
skipped = 0
|
skipped = 0
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,59 @@ class TestV0_50_0Migration:
|
||||||
"md5": "m",
|
"md5": "m",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async def test_like_filter_ignores_non_key_occurrences(self, temp_db_path):
|
||||||
|
"""The WHERE short-circuit on `metadata LIKE '%"etag"%'` looks for the
|
||||||
|
quoted-key form. Values that happen to contain the substring `etag` and
|
||||||
|
composite key names like `my_etag_key` must not get rewritten."""
|
||||||
|
async with Store(temp_db_path, create=True, skip_migration_check=True) as store:
|
||||||
|
await store.set_haiku_version("0.48.1")
|
||||||
|
await store.documents_table.add(
|
||||||
|
[
|
||||||
|
# `etag` only appears as a VALUE — no `etag` key. Either
|
||||||
|
# the LIKE excludes it (no work), or it pulls it in and
|
||||||
|
# _normalize_metadata leaves it alone. Either way the row
|
||||||
|
# must end up unchanged.
|
||||||
|
DocumentRecord(
|
||||||
|
id="value-only",
|
||||||
|
content="x",
|
||||||
|
uri="u1",
|
||||||
|
metadata=json.dumps(
|
||||||
|
{
|
||||||
|
"description": "the etag of the file",
|
||||||
|
"source_revision": "v1",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# A composite key containing `etag` but not equal to it.
|
||||||
|
# Must not be rewritten.
|
||||||
|
DocumentRecord(
|
||||||
|
id="composite-key",
|
||||||
|
content="x",
|
||||||
|
uri="u2",
|
||||||
|
metadata=json.dumps(
|
||||||
|
{
|
||||||
|
"my_etag_key": "v",
|
||||||
|
"source_revision": "v2",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
async with Store(temp_db_path, skip_migration_check=True) as store:
|
||||||
|
await store.migrate()
|
||||||
|
rows = await store.documents_table.query().to_list()
|
||||||
|
by_id = {r["id"]: json.loads(r["metadata"]) for r in rows}
|
||||||
|
|
||||||
|
assert by_id["value-only"] == {
|
||||||
|
"description": "the etag of the file",
|
||||||
|
"source_revision": "v1",
|
||||||
|
}
|
||||||
|
assert by_id["composite-key"] == {
|
||||||
|
"my_etag_key": "v",
|
||||||
|
"source_revision": "v2",
|
||||||
|
}
|
||||||
|
|
||||||
async def test_preserves_existing_canonical_keys_on_collision(self, temp_db_path):
|
async def test_preserves_existing_canonical_keys_on_collision(self, temp_db_path):
|
||||||
"""If both legacy and canonical keys are present, the canonical wins
|
"""If both legacy and canonical keys are present, the canonical wins
|
||||||
and the legacy is dropped — defends against partial-migration states."""
|
and the legacy is dropped — defends against partial-migration states."""
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue