Policy¶
Per-document entitlement rules. See Restrict what a search can see.
vectrixdb.policy
¶
Entitlement policy: a filter the collection cannot be queried without.
An entitlement filter that the caller builds on every query is a control that
works until somebody forgets. search_with_acl(acl_field="_acl") is opt-in,
so a call site that omits it returns everything, and nothing anywhere says so.
A policy is the same predicate declared once, against the collection, with no
way to run a search that skips it.
Nothing here authenticates anybody. A principal is a plain mapping the host resolved from its own directory, CRM or control room, and this module has no way to check it and does not try. What it does is decide, given that mapping, which documents a query may see, which is the same category of work as the filter engine it compiles down to.
Two properties are load-bearing and both come from the filter engine rather than from anything written here:
- An absent field on a document denies.
FilterCondition.matchesreturns False forMISSINGbefore any operator runs, so a chunk that arrives with no entitlement metadata is invisible rather than universal. There is no option to turn that off. A mixed collection with genuinely public documents should say so in a field and have a rule that permits it, which is a statement a reviewer can read, rather than a global fail-open. - An empty principal value matches nothing, on its own.
$in []matches no document and$nin []excludes none, which is the correct reading of both without a special case. That is only true because membership was fixed to mean overlap earlier in 2.2; before that,$in/$nintested the whole field against the operand and a list-valued field never matched.
What is not free is a principal missing a key the policy names. That is not a denial, it is the host's entitlement resolver failing, and conflating the two hides a broken resolver behind traffic that looks normal. It raises.
Outcome
¶
Bases: str, Enum
How one policied search ended.
Four families, because they belong on different dashboards. A denial is normal traffic and expected in volume. An undecidable or a refusal means something upstream is broken and somebody should be woken up. A single value covering both is how a policy fails closed for a week unnoticed, every record reading "denied" and denials looking like a busy Tuesday.
Predicate
dataclass
¶
One rule, relating a field on the document to a key on the principal.
doc is a dotted path into the document's metadata, the same notation the
filter engine already reads. principal is a key in the principal
mapping. scope marks the rule as defining whether the principal may
know this document exists at all, as against redacting something inside a
scope they already hold; see Decision for why that distinction has to be
declared rather than inferred.
accepts(value)
¶
Whether this rule can reach a verdict on such a document value.
Not whether it passes. A rank of 9 is decidable and denied; a rank of "high" is neither, because it is not greater than any clearance and not less than one either, so the rule is false for every principal and the document is invisible to everybody. That is a pipeline bug worth catching at the write, where the stack trace points at the pipeline.
operand(value)
¶
The principal's value, shaped for the operator.
clause(value)
¶
This rule as one filter clause.
describe()
¶
A short label, used in decision records and test failures.
Overlap
dataclass
¶
Bases: Predicate
The document's field shares at least one value with the principal's.
The workhorse: roles against allowed roles, a care team against the teams someone is on, a client id against a book of business. Either side may be a list or a scalar.
AtMost
dataclass
¶
Bases: Predicate
The document's field is no greater than the principal's value.
Clearance. This is why a classification wants a numeric rank beside its label: "cleared to at least this level" is an ordering question, and set membership cannot answer it without enumerating every level below.
A principal value of zero is a real clearance, not an absent one, which is why nothing here tests it for truthiness.
Excludes
dataclass
¶
Bases: Predicate
The document's field is none of the principal's values.
Walls and conflicts: the list names what this principal may not see. An
empty list excludes nothing, which is the right reading, and needs no
special case because $nin [] already matches every document.
Present
dataclass
¶
Bases: Predicate
The document has the field at all.
Reads nothing from the principal. Use it to require that a field the rest of the policy depends on was actually stamped at ingestion, so a gap in the pipeline reads as a gap rather than as a permissions question.
Decision
dataclass
¶
The outcome of evaluating a policy against one document.
in_scope is the field that keeps an ethical wall standing. A principal
who matched every scope rule and lost a document to a clearance rule can
safely be told something was withheld: they already know the client, the
matter, the patient exists. A principal who failed a scope rule cannot be
told anything at all, because "one result withheld" confirms the thing is
there, and that is exactly what a wall is built to prevent.
Which rules are scope cannot be worked out from the rules themselves, so
Predicate.scope declares it.
disclosable
property
¶
Whether the caller may be told this document was withheld.
Policy
¶
An entitlement rule set, evaluated as one conjunction.
Every rule must pass. There is no or and no nesting, deliberately: the
value of a small vocabulary is that somebody reviewing a policy can read
it once and be certain what it does. A case study that genuinely needs a
seventh predicate should get the predicate rather than an expression
language nobody can audit.
>>> policy = Policy([
... Overlap("entitlements.allowed_roles", "roles"),
... AtMost("entitlements.classification_rank", "clearance_rank"),
... Overlap("entitlements.client_id", "client_coverage", scope=True),
... Excludes("entitlements.client_id", "wall_restrictions", scope=True),
... ])
>>> policy.required_principal_keys == frozenset(
... {"roles", "clearance_rank", "client_coverage", "wall_restrictions"}
... )
True
fingerprint
property
¶
A stable hash of the rule set.
Carried on every decision record. A version number can be forgotten when somebody edits a rule; a fingerprint cannot, which is the whole point. Without it, a past decision that no longer reproduces gives no way to tell a policy change from a data change.
required_principal_keys
property
¶
Every key a principal must carry for this policy to be evaluable.
required_document_fields
property
¶
Every document path the rules read.
A document missing any of them is invisible to everybody, because an absent field denies. That is what makes them a contract a write has to meet, and the reason it is worth meeting at write time.
__init__(rules, version=None, require_pushdown=False, db_role_key=None, on_incomplete_document='reject')
¶
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
rules
|
Sequence[Predicate]
|
the predicates, all of which must pass. |
required |
version
|
Optional[str]
|
an optional human label carried on every decision record beside the fingerprint. The fingerprint is what identifies the rule set; this is what a person reads. |
None
|
db_role_key
|
Optional[str]
|
the principal key holding the PostgreSQL role this read should run as, so row level security applies underneath the filter. The host maps its own principals to roles; this only says which key to look in, the same way the rules say which keys to read. |
None
|
on_incomplete_document
|
str
|
what to do when a document arrives without a field the rules name. Reject refuses the write, warn writes it and says so, allow is silent. It refuses by default, because the alternative is a chunk nobody can ever see failing in a way that reads as a permissions problem. Warn is for backfilling an existing collection. |
'reject'
|
require_pushdown
|
bool
|
refuse to open a collection whose backend filters after fetching rather than in the query. Nothing does the latter yet, so this refuses everywhere today, which is the honest answer and the reason it exists: a deployment that needs engine-side enforcement should be told it does not have it rather than assume it does. |
False
|
to_dict()
¶
The canonical form, for persisting beside the collection.
on_incomplete_document is written only when it is not the default,
reject. Until 2.2 it was never written at all, so a policy read back
from a collection silently became one that rejects; written always, it
would change the fingerprint of every policy already stored and every
policied collection would refuse to open. Written when it differs, a
policy stored before keeps its fingerprint exactly, and one that warns
or allows still says so after the round trip.
from_dict(data)
classmethod
¶
Rebuild a policy from to_dict, what to do with an incomplete document included.
missing_fields(metadata)
¶
Which required paths this document does not have.
undecidable_fields(metadata)
¶
Fields this document stamps that no rule can reach a verdict on.
The companion to missing_fields, and the same failure: a
classification rank stamped as "high" is not greater than any
clearance and not less than one either, so the rule is false for
every principal and the document is invisible to everybody. Absent
and undecidable are one bug with two spellings.
Returns (path, value, what the rule needs) for each.
db_role(principal)
¶
The database role this principal reads as, if the policy names one.
Missing is the resolver failing, the same as any other key the policy reads: running as the connection's own role instead would mean the read happens with whatever privileges the application holds, which is the opposite of the point.
compile(principal, *, scope_only=False)
¶
This policy as a filter for search(filter=...).
With scope_only, just the scope rules: the part a store can be
handed so a document outside the scope never leaves it, while the
redaction rules are still decided here where they can be counted.
Returns None when the policy cannot match anything, so the caller can skip the search entirely rather than run one guaranteed to come back empty. That shortcut leaks nothing about the documents: whether a principal holds any groups is a fact about the principal, which they already know.
The $and form is used rather than a field-keyed dict so that two
rules over the same field, which is exactly how a wall sits beside a
coverage list, cannot collide.
decide(principal, metadata)
¶
Evaluate the policy against one document's metadata.
Used for the withheld counts rather than for filtering, which the engine does. Scope rules are evaluated separately from the rest so that a document lost to a clearance rule can be reported and one lost to a wall cannot.
classify(principal, items, metadata_of=None)
¶
Split anything carrying metadata into allowed and the two withhelds.
Keeping the two counts apart is the only thing standing between an
audit record and a broken ethical wall, so it is implemented once,
here, and both callers go through it. metadata_of pulls the
metadata off whatever the caller is holding; the default treats each
item as the metadata itself.
Returns the allowed items, the disclosable count, the undisclosable count, and every rule that rejected at least one candidate.
partition(principal, candidates)
¶
The metadata-only form of :meth:classify, without the rules.
A caller handed a single "suppressed" number will render it, and the first time somebody outside a scope sees "7 results withheld" the wall is gone. That is why this returns two.
outcome(returned, disclosable, undisclosable)
¶
The outcome value for a finished search.