feat(authz): shared filter derivation, snapshot decision boundary, and adapter conformance suite - #1483
Conversation
|
Note on the CI result: the |
- extract resolve_resource_filter derivation shared by both providers (fixes latent parent-type drift between the builtin and Casbin copies) - add RelationalAccessRepository.decision_snapshot: revision, active bindings and ownership read in one transaction; providers use it when offered, else bounded revision-check-and-retry, failing closed when no stable read is obtained (AccessRepository protocol unchanged) - one captured evaluation time per decision for all expiry comparisons - conformance suite: deliberately-interleaving regression for the read-consistency gap, single-transaction pin, fail-closed on unstable revision, point/list agreement and cross-provider filter parity, idempotency ledger conflicts across operations and payloads - no persistent Casbin policy store added (rationale in PR)
…annotations * Annotate decision_snapshot row buffers as Sequence[Mapping[str, Any]] so SQLAlchemy Mapping results type-check. * Make the test doubles nominal subtypes (AsyncDatabase / RelationalAccessRepository) instead of duck-typed wrappers, and hide the decision_snapshot capability via __getattribute__ so the bounded-retry path stays exercised. * Assert policy revision is not None before int() conversion.
ty infers RowMapping keys as a union type and Mapping is invariant in its key type, so Sequence[Mapping[str, Any]] rejects the rows returned by mappings().all(). Mapping[Any, Any] is accepted by both ty and pyright.
7bc11b4 to
f9effd4
Compare
|
@Teingi — this is ready for review. Two things changed since you last saw it, and both were the reason it was sitting unreviewed:
Verified before pushing: the authz and access suites pass at the new head (52 passed), I could not set a formal reviewer request — this account does not have the permission on this repo — so this comment is doing that job. The one item from your review I still cannot close from here is the OceanBase concurrency run for the interleaving regression; it runs against SQLite in this PR and is written to be backend-agnostic, but per your note it should be exercised on OceanBase in your CI. If you can point me at how that is normally invoked, I would rather wire it than leave it as a caveat — failing that, the PR body states it plainly as the thing I could not produce. |
| binding_rows: Sequence[Mapping[Any, Any]] = () | ||
| owned_rows: Sequence[Mapping[Any, Any]] = () | ||
| artifact_owners: dict[str, ArtifactOwnerRelation] = {} | ||
| async with self._database.connection(self._bound_connection) as connection: |
There was a problem hiding this comment.
[P1] Establish a real snapshot before trusting this read boundary
Using one SQLAlchemy transaction here does not give SQLite a consistent read snapshot: the current SQLite profile uses legacy transaction handling, where SELECT does not issue BEGIN. Since decision_snapshot exists, read_decision_state also skips the revision-check-and-retry fallback.
I reproduced this at f9effd44 with a file-backed SQLite database and two independent connections: the reader captures revision 3, the writer commits a new grant at revision 4, and the reader returns allowed=true with policy_revision="3". Both Builtin and Casbin reproduce this for point checks and resource filters. Adding an explicit read transaction in the probe keeps all four cases consistent.
The new transaction-count test does not catch this, and the interleaving test hides decision_snapshot, so it exercises only the fallback. Please add a two-connection interleaving regression against the actual snapshot path and establish a real snapshot, or use revision validation when that guarantee is unavailable. Also account for OceanBase connections using READ COMMITTED: the profile does not enforce REPEATABLE READ, so sharing a transaction alone is insufficient there too. I have not run the OceanBase interleaving probe.
|
CI status at Green: Red: One thing that did improve: |
sqlite3's legacy transaction control does not BEGIN for a bare SELECT, so the transaction decision_snapshot reads through was not a snapshot: a commit landing between the revision read and the bindings read produced a decision labelled with the pre-commit revision but built from post-commit bindings. read_decision_state also delegated on the mere presence of decision_snapshot, leaving the revision-check-and-retry fallback unreachable. - _pin_read_snapshot issues SAVEPOINT rather than BEGIN, so it composes with a caller-owned transaction instead of raising. - decision_snapshot re-reads the revision before releasing the snapshot and raises policy-snapshot-unstable instead of returning the mixture; read_decision_state retries within its existing budget. A READ COMMITTED profile therefore fails closed rather than returning a stale label. - Adds a two-connection interleaving regression against the real snapshot path, and a fail-closed case for a profile that cannot pin isolation. Reported by @Teingi in review.
|
Confirmed — you are right on both counts. Reproduced here, then fixed at the snapshot boundary. I reproduced it before changing anything, with a file-backed SQLite database and two independent connections, driven through the repository the way
Both Builtin and Casbin reproduce it, point checks and resource filters alike. Root cause (two independent halves)
You were also right about the tests. Fix — both options you offered, because they cover different backends
That second half is what answers your READ COMMITTED / OceanBase point, and I want to be precise about it rather than let it look stronger than it is: we do not make OceanBase consistent here, and I have not run an interleaving probe against a real OceanBase instance (same gap you flagged). What changes is the failure mode — a READ COMMITTED profile can no longer return data labelled with a revision it was not read at; it fails closed, or succeeds on a retry that saw a stable revision. If you would rather have the guarantee than the detection there, forcing an explicit consistent-snapshot/REPEATABLE READ transaction in the profile is the follow-up, and I would rather do that as its own change than smuggle a session-level isolation change in here. Regression tests against the actual snapshot pathTwo new tests, neither of which hides
I checked the first one actually fails without the fix (pin disabled → Fix is at |
The `quality` job failed on `ty check` with three `invalid-assignment` diagnostics: the interleaving tests rebound `_read_policy_revision` and `_pin_read_snapshot` on the *module*, while the attribute being assigned to is a module-level function whose declared signature the local replacement did not match (a different parameter name, and a body that ignores it). That was a symptom of the real problem: the tests were patching a seam the reader does not go through. `decision_snapshot` called the module functions directly, so a test could intercept them without the interception being tied to the code path under test. - Promote both helpers to overridable methods on `RelationalAccessRepository` and call them via `self` from `decision_snapshot`. The module functions stay as the default implementations; the seam is now the instance the reader actually goes through. - Patch that seam with `patch.object` in the two interleaving tests, and drop the module attribute rebinding plus the now-unused `authz_repository` import. `uv run ty check` no longer reports any diagnostic in `tests/test_access_snapshot_boundary.py`; the remaining baseline diagnostics are unchanged (`Found 9` vs `Found 12` before, none in a file this PR touches). `ruff check` / `ruff format --check` clean.
|
Post-merge note on Thanks for merging this. Recording the diagnosis behind the last commit, since the red X was resolved in
What it was. The interleaving tests rebound The fix. Both helpers are now overridable methods on
Verified before pushing: Two things I want to leave stated rather than buried.
|
feat(authz): shared filter derivation, snapshot decision boundary, and adapter conformance suite
Draft PR promised in #1398 (comment), following the review answers from @Teingi. Target scope as agreed there: shared filter derivation, a defined snapshot/read boundary with the deliberately-interleaving regression, the conformance extensions, and an explicit capability statement for the native-writer question. Branch is a single draft; every claim below is backed by a test in this PR.
What changed
1. Shared filter derivation — one derivation, zero drift risk.
resolve_resource_filterwas duplicated betweenBuiltinAuthorizationProvider(service.py L395-433) andCasbinAuthorizationProvider(casbin.py L125-159) with a latent semantic difference: the Casbin copy's parent test (_is_parent) accepted a SERVER resource as a parent for any requested type, while the built-in copy (_resource_is_parent+_parent_binding_grants) constrains the child type. Both now call one package-internal derivation (_derive_authorized_resource_filterinservice.py), keeping the built-in (stricter) semantics. The private helpers remain package-internal; nothing is promoted to public adapter API.2. Snapshot decision boundary — the read gap is closed two ways.
The read-consistency gap demonstrated on this thread (revision read at 3, grant commits at 4, bindings evaluated from 4 while the decision is labeled
3) came frompolicy_revision(),active_bindings()and ownership reads running in separate transactions. Both providers now read decision inputs through one boundary (read_decision_stateinservice.py):RelationalAccessRepository.decision_snapshot()reads revision + active bindings + artifact ownership (+ owned resources when needed) inside one transaction; backends with snapshot isolation (SQLite read transactions, OceanBase REPEATABLE READ) supply the consistency. One captured evaluation time is used for every expiry comparison in the decision.AccessUnavailableError("policy-snapshot-unstable")) when no stable read is obtained.AccessRepositoryprotocol is unchanged —decision_snapshotis an optional capability detected by the providers, so existing implementations keep working through the fallback.3. Native-writer capability statement (as promised, measured not asserted).
No second policy store is added. With the composition in this repo — canonical five-table schema as the only writable copy, enforcer materialized from committed state at evaluation time — a persistent Casbin policy table would add storage without a demonstrated need, and this PR contains no workload showing adapter translation falling short. The concrete deltas this draft delivers are the three the review identified: shared derivation, reusable conformance tests, and a defined snapshot boundary. If a large-policy-set workload appears, the same single-writer + revision-equality rule from the earlier storage-layout comment remains the agreed shape for adding one.
4. Conformance suite.
New
tests/test_access_snapshot_boundary.py:test_providers_read_decision_inputs_from_one_snapshot— one transaction percheck/resolve_resource_filter(was 3+), both providers;test_interleaved_mutation_cannot_label_decision_with_stale_revision— the controlled interleaving from the review, replayed: a grant committed between the reads can no longer produceallowed=trueunder the pre-mutation revision, and a stable retry labels it with the matching revision;test_unstable_revision_fails_closed— no stable snapshot within the budget →AccessUnavailableError, never a possibly-stale allow;test_providers_agree_on_point_and_list_for_direct_and_inherited_grants— point/list agreement plus cross-provider filter parity;test_idempotency_ledger_conflicts_across_operations_and_payloads— repository-level, shared by both adapters: changed-payload conflict on the same key, cross-operation conflict, replay-after-revocation returning the revoked binding without resurrection, and post-revocation re-grant via a fresh key.Test evidence
Tests were written first and run against the pre-fix tree; they failed with exactly the gap under review, which is the mutation check for this suite:
After the fix (this PR), the authz and access suites at the current head:
ruff checkandruff formatclean on all touched files.ty checkreports the same 10 diagnostics asmasteron this machine, none of them in a touched file.Snapshot read boundary (review follow-up)
Review found that the single SQLAlchemy transaction
decision_snapshotreads through is not a snapshot on SQLite: sqlite3's legacy transaction control does notBEGINfor a bareSELECT, andread_decision_statedelegated on the mere presence ofdecision_snapshot, which left the revision-check-and-retry fallback unreachable. Reproduced with two connections against one file-backed database — the reader captured revision N, a commit landed, and the binding read returned the new grant labelled with revision N._pin_read_snapshotopens the read transaction explicitly withSAVEPOINT. NotBEGIN: the read may already run inside a transaction this repository did not open (a shared in-memory connection, orwith_connection), where a secondBEGINraises.decision_snapshotre-reads the revision before the snapshot is released and raisespolicy-snapshot-unstablerather than returning the mixture;read_decision_stateretries inside the budget it already had. A READ COMMITTED profile therefore fails closed instead of returning a stale label — see the OceanBase note below for what that does and does not buy.test_snapshot_path_survives_two_connection_interleavingruns the interleaving against the real path (the earlier test raisedAttributeErrorondecision_snapshot, so it only ever exercised the fallback) and asserts the snapshot holds on the first attempt;test_snapshot_read_fails_closed_when_isolation_cannot_pinnedcovers a profile that cannot pin isolation. Disabling the pin makes the first one fail, so it is a regression rather than decoration.Rebased onto master
Rebased onto
masterat346fef79(the previous base was 103 commits behind) so the branch merges cleanly:mergeableis nowtrue. Two conflicts, both the same shape — each side added a distinct method or import at the same anchor, no semantic overlap:repository.py—masteraddedwith_connection(the bound-connection support); this PR addsdecision_snapshot. Both are kept. One line of this PR had to move with the refactor:decision_snapshotwas written againstself._database.transaction()and now usesself._database.connection(self._bound_connection).AsyncDatabase.connectionis defined astransaction() if bound is None else nullcontext(bound), so this is identical when unbound and additionally joins the caller's transaction when the repository is bound to one. That single line was the whole rebase diff ofdecision_snapshot; the review follow-up above changes the method further.service.py— import block is the union of both sides (AsyncIterator/asynccontextmanager/ContextVarfrommaster,Mappingfrom this PR).casbin.pyandtests/test_access_snapshot_boundary.pyare byte-identical to the pre-rebase head. The change set is still exactly 4 files, +678/−81.What this draft does not claim
DatabaseConfigabstraction and backend-agnostic, but per the review the same interleaving should be exercised on OceanBase in your CI; I cannot run that from this environment and would rather say so than claim a green I did not produce. What the fix buys there is worth stating precisely: it does not give a READ COMMITTED connection a consistent snapshot — it guarantees the read fails closed instead of returning data labelled with a revision it was not read at. Forcing an explicit consistent-snapshot/REPEATABLE READ transaction in the profile would be a separate change, not smuggled in here.load_filtered_policy— untouched; it remains an optional optimization and is not load-bearing for decision correctness or pagination safety.