Skip to content

feat(authz): shared filter derivation, snapshot decision boundary, and adapter conformance suite - #1483

Draft
tlyyxjz wants to merge 3 commits into
oceanbase:masterfrom
tlyyxjz:authz-snapshot-boundary
Draft

feat(authz): shared filter derivation, snapshot decision boundary, and adapter conformance suite#1483
tlyyxjz wants to merge 3 commits into
oceanbase:masterfrom
tlyyxjz:authz-snapshot-boundary

Conversation

@tlyyxjz

@tlyyxjz tlyyxjz commented Sep 7, 2026

Copy link
Copy Markdown

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_filter was duplicated between BuiltinAuthorizationProvider (service.py L395-433) and CasbinAuthorizationProvider (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_filter in service.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 from policy_revision(), active_bindings() and ownership reads running in separate transactions. Both providers now read decision inputs through one boundary (read_decision_state in service.py):

  • Primary: 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.
  • Fallback: repositories without the capability get bounded revision-check-and-retry (revision → bindings → re-read revision; retry while unstable, max 3), and fail closed (AccessUnavailableError("policy-snapshot-unstable")) when no stable read is obtained.
  • AccessRepository protocol is unchanged — decision_snapshot is 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 per check/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 produce allowed=true under 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:

assert counting.transactions == 1, "check must read from one snapshot transaction"
E  AssertionError: assert 3 == 1                     <- pre-fix: 3 separate transactions

assert not (decision.allowed and decision.policy_revision == pre_mutation_revision)
E  AssertionError: assert not (True and '3' == '3')  <- pre-fix: the exact stale-label shape from the review

with pytest.raises(AccessUnavailableError): ...
E  Failed: DID NOT RAISE AccessUnavailableError      <- pre-fix: unstable reads proceeded silently

After the fix (this PR), full authz suite:

tests/test_access_snapshot_boundary.py tests/test_access_control.py tests/test_access_adapters.py
25 passed in 2.49s

ruff check and ruff format clean on all touched files.

What this draft does not claim

  • OceanBase concurrency run — the interleaving regression runs on SQLite here. It is written against the DatabaseConfig abstraction 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.
  • load_filtered_policy — untouched; it remains an optional optimization and is not load-bearing for decision correctness or pagination safety.
  • Persistent Casbin policy table — intentionally not added (see §3).
  • Evaluation-time materialization failure — still fails closed per the corrected consistency framing; pre-commit validation semantics are unchanged.

- 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)
@CLAassistant

CLAassistant commented Sep 7, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

…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.
@tlyyxjz

tlyyxjz commented Sep 8, 2026

Copy link
Copy Markdown
Author

Note on the CI result: the tests (3.11) failure is a sqlite concurrency flake — database is locked in test_concurrent_default_bootstrap_returns_one_scope (tests/builtin/), which is unrelated to the authz changes in this PR. The same commit passes the full suite on 3.12/3.13/3.14. Happy to rerun if needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants