Debugging policies¶
Purview provides two inspection tools that do not query the database: explain()
shows the compiled predicates, and audit() classifies model visibility.
Explain a permission¶
Using the model and policy from the quickstart:
ctx = Context(user_id=42, tenant_id=1, roles={"author"})
explanation = pv.explain(ctx, READ, Post)
print(explanation)
The output includes the effective roles, governing action, tenant SQL, row SQL, combined predicate, and each registered rule's contribution. You can also pass a bound session:
explanation = pv.explain(session, "update", Post)
print(explanation.governing_action) # 'read' if no update rule exists
print(explanation.combined_sql)
Explanation compiles expressions for inspection. It does not prove that a row exists or evaluate an authorization result. SQL literal rendering is best effort. Because the output can contain user and tenant identifiers, keep it in appropriate development or diagnostic logs.
Audit model visibility¶
| Classification | Meaning |
|---|---|
global |
Exempt from automatic tenant and row filtering |
ruled |
Has registered read rules |
tenant-wide |
No read rule, and strict mode is off |
default-deny |
No read rule, and strict mode is on |
An audit reports policy configuration, not whether a particular actor is permitted.
A ruled model can still have a very permissive rule.
To surface tenant-wide models during startup:
pv = install(Base, policy, audit="warn")
# Or fail startup if any scoped model is tenant-wide:
pv = install(Base, policy, audit="raise")
Choose one installation configuration for your app. audit="raise" raises
PolicyAuditError before attaching event listeners. With strict=True, models
without read rules are denied, so they are not flagged as tenant-wide.
Warn about unfiltered operations¶
Warnings can help find recognized unbound reads and raw/non-ORM statements on bound sessions. They are advisory. They do not turn unsupported operations into protected ones, and their absence does not establish enforcement. Review the security boundary when choosing query patterns.
Common surprises¶
| Symptom | Check |
|---|---|
| A query returns no rows | Confirm tenant, roles, registered read rules, and strict mode with explain() |
| Everyone in a tenant can read a model | Add a read rule or enable strict mode, then inspect audit() |
| An update is permitted despite no update rule | Actions without rules fall back to read |
| A new object passes despite no create role | Register and explicitly call a create rule through validate_create() |
An async relationship raises MissingGreenlet |
Use selectinload() or awaitable_attrs |
TenantMismatch appears |
Create a new session for the new tenant |
UnscopedModel appears at startup |
Add/configure the tenant field, or intentionally mark the model global |
| A loaded object remains accessible after a role change | Use a new session and context for the changed actor or permissions |
Bypass for trusted maintenance¶
from purview.sqlalchemy import bypass
with bypass(reason="nightly billing rollup"):
# Use a dedicated maintenance session here.
...
The reason must be non-empty and is logged at warning level. Automatic guards are suspended within the context. Do not reuse objects or sessions loaded under bypass for ordinary requests. Async child tasks inherit context variables, so do not spawn request work inside a bypass block.