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.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
|
|
@ -14,7 +16,7 @@ async def require_auth(
|
|||
expected = getattr(request.app.state, "auth_token", None)
|
||||
if expected is None:
|
||||
return
|
||||
if creds is None or creds.credentials != expected:
|
||||
if creds is None or not secrets.compare_digest(creds.credentials, expected):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Unauthorized",
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ async def list_sources(
|
|||
source_id=poller.source_id,
|
||||
type=type(poller.config).__name__,
|
||||
last_polled_at=poller.last_polled_at,
|
||||
circuit_breaker_open=poller._breaker.is_open,
|
||||
circuit_breaker_open=poller.is_circuit_open,
|
||||
)
|
||||
)
|
||||
return summaries
|
||||
|
|
|
|||
|
|
@ -72,6 +72,10 @@ class BasePoller:
|
|||
def last_polled_at(self) -> datetime | None:
|
||||
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
|
||||
raise NotImplementedError
|
||||
|
||||
|
|
|
|||
|
|
@ -35,11 +35,24 @@ def _normalize_metadata(meta: dict) -> tuple[dict, bool]:
|
|||
async def _apply_canonical_metadata_keys(store: Store) -> None:
|
||||
"""Rewrite document.metadata so revision lives under the source-agnostic
|
||||
`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 = (
|
||||
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()
|
||||
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)
|
||||
rewritten = 0
|
||||
skipped = 0
|
||||
|
|
|
|||
|
|
@ -86,6 +86,59 @@ class TestV0_50_0Migration:
|
|||
"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):
|
||||
"""If both legacy and canonical keys are present, the canonical wins
|
||||
and the legacy is dropped — defends against partial-migration states."""
|
||||
|
|
|
|||
Loading…
Reference in a new issue