fix(policy): enforce f:policySource and config policy defaults on writes#1419
fix(policy): enforce f:policySource and config policy defaults on writes#1419bplatz wants to merge 2 commits into
Conversation
f:policySource was honored on the read/query path but ignored on every write/modify path, which hardcoded the default graph (g_id = 0): a policy relocated into a named graph — or sourced cross-ledger from a model ledger — was enforced on queries but silently not enforced on transactions. Config-declared policy defaults (f:policyClass, f:defaultAllow) also never merged into write-time governance, so requests without policy inputs ran as root even on configured ledgers. Fixes #1416. Changes: - New `build_transact_policy_context` (policy_view.rs): the write-side counterpart of `wrap_policy`. Resolves the ledger config at to_t, merges config policy defaults via merge_policy_opts, and dispatches f:policySource — same-ledger selectors through resolve_policy_source_g_ids (fail-closed on unknown graphs), cross-ledger f:ledger references through the ArtifactKind::PolicyRules resolver with restrictions interned into the data ledger's term space. Returns None (root) only when neither the request nor the config supplies any policy input; a cross-ledger source always builds a context, mirroring the read path. - Extracted `resolve_cross_ledger_policy_restrictions` from wrap_policy's inline cross-ledger block and shared it between the read and write paths, so the Phase 1a identity-mode rejection and the f:policyClass intersection filter (default {f:AccessPolicy}) cannot drift between them. - Rewired the write-path call sites onto the new builder: - consensus transact (LocalCommitter + Raft commit worker) via crate::local::build_policy_context, which now takes &Fluree and no longer short-circuits on empty request governance - credential_transact (verified-identity transactions) - push replication (build_policy_ctx_for_push; stage_commit_flakes now takes Option<&PolicyContext>) - commit-detail fetch (graph_commit_builder, keeping its per-request identity/policy_class opt-in gate) - CLI local insert/upsert/update (flags-only gate preserved) - block_fetch has no Fluree handle, so it resolves same-ledger f:policySource via resolve_policy_graphs_from_config and fails closed on cross-ledger configs instead of silently reading the default graph - Tests (it_policy_write_path.rs): config defaults enforced on writes with no request inputs; same-ledger named-graph f:policySource modify-deny; cross-ledger modify-deny resolved live against the model ledger (violating write rejected, untargeted write allowed); identity + cross-ledger fails closed; no-config/no-inputs still runs root. All verified to fail against the previous hardcoded-[0] behavior. - Docs: policy-in-transactions.md (new "Config-driven write enforcement" section), cross-ledger-policy.md (transactions engage automatically; queries keep the header gate), setting-groups.md, programmatic-policy.md (build_transact_policy_context as the recommended transaction entry point), and the cross-ledger design doc's scope section.
…rce instead of failing closed
The cross-ledger guard rejected any request carrying an identity, even
when a f:policyClass was available (from D's config or the request) to
drive rule loading — the exact case the design intends: rules load by
policy class, the identity binds ?$identity contextually. Since
authenticated deployments attach an identity to virtually every request,
cross-ledger policy governance was unusable outside anonymous /
policy-class-only requests, and an identity-carrying write to a
cross-ledger-governed ledger returned a hard config error.
The naive fix (relax the guard alone) would have been worse than the
bug: build_policy_context_from_opts_inner gave the identity branch
unconditional priority, and that branch ignores cross_ledger_restrictions
entirely — a relaxed guard would have routed identity-carrying requests
into same-ledger identity-mode, silently dropping M's rules (fail-open).
Changes:
- resolve_cross_ledger_policy_restrictions: the class filter is now an
explicit chain — request policy_class → config f:policyClass →
{f:AccessPolicy} (anonymous requests only). The config's class is
passed separately because merge_policy_opts returns request opts
unchanged when the request carries any policy input and override is
permitted, so an identity-only request never sees the config's class
through the merge. An identity-carrying request with no class anywhere
still fails closed: the identity is bind-only and can't select rules,
so the operator must name what governs.
- build_policy_context_from_opts_inner: the cross-ledger branch now
takes priority over identity-mode. Under cross-ledger, the identity is
bind-only — new resolve_identity_binding_sid resolves it against D
(strict encode + subject-existence check, mirroring identity-mode's
three-state binding contract) and populates ?$identity for f:query
rules, without consulting the identity's D-local f:policyClass triples
(a cross-ledger f:policySource declares M the policy authority).
- Both read (wrap_policy) and write (build_transact_policy_context)
builders pass the config policy class through the shared helper, so
the contract cannot drift between paths. Credentialed transactions
(inherently identity-carrying) now work against cross-ledger-governed
ledgers whenever D's config declares a f:policyClass.
Tests:
- cross_ledger_identity_with_config_policy_class_enforced_on_writes —
the reported scenario: identity + config policyClass builds a context
(no error), M's modify-deny rejects the violating write, untargeted
writes pass.
- cross_ledger_identity_binding_drives_fquery_modify_rule — an
owner-only f:query rule in M allows the identity to write its own
user's email and rejects writes to another user's, proving ?$identity
binds live.
- identity_with_policy_class_engages_cross_ledger_rules (read) — the
same owner-only rule filters another user's email from query results
while the identity's own stays visible.
- Existing identity-without-policy-class fail-closed tests unchanged.
- All three new tests verified to fail under BOTH temp-reverts: the old
blanket guard (fail-closed regression) and an identity-branch-first
stub (the silent fail-open bypass).
Docs: cross-ledger-policy.md gains an "Identity binding under
cross-ledger policy" section (bind-only contract, class-filter chain,
the defaultAllow/override-control merge subtlety); limitations table,
policy-in-transactions.md, and the design doc scope updated to match.
aaj3f
left a comment
There was a problem hiding this comment.
This makes sense and good fix
| /// Build a [`PolicyContext`] from the request's policy inputs. | ||
| /// Build a [`PolicyContext`] from the request's policy inputs merged with | ||
| /// the ledger's `#config` policy defaults. | ||
| /// | ||
| /// Returns `Ok(None)` when there are no policy inputs — the transaction | ||
| /// runs under root. The context is built from a snapshot of the ledger | ||
| /// this node is about to stage against, so policy enforcement reflects | ||
| /// the same state the transaction commits onto. Building it here, rather | ||
| /// than having the caller pre-build and pass a context, keeps the policy | ||
| /// context bound to the executing node's state — the shape a replicated | ||
| /// implementation needs. | ||
| /// Returns `Ok(None)` when neither the request nor the ledger config | ||
| /// supplies any policy input — the transaction runs under root. The | ||
| /// context is built from a snapshot of the ledger this node is about to | ||
| /// stage against, so policy enforcement reflects the same state the | ||
| /// transaction commits onto. Building it here, rather than having the | ||
| /// caller pre-build and pass a context, keeps the policy context bound to | ||
| /// the executing node's state — the shape a replicated implementation | ||
| /// needs. | ||
| /// | ||
| /// Delegates to `fluree_db_api::build_transact_policy_context`, which | ||
| /// resolves `f:policySource` (same-ledger named graphs AND cross-ledger | ||
| /// model references) and applies config `f:policyClass` / `f:defaultAllow` | ||
| /// defaults — so writes are governed by the same config the read path | ||
| /// enforces via `wrap_policy`. | ||
| pub(crate) async fn build_policy_context( | ||
| fluree: &Fluree, | ||
| ledger_handle: &LedgerHandle, | ||
| governance: &GovernanceOptions, | ||
| ) -> Result<Option<PolicyContext>, SubmissionError> { | ||
| if !governance.has_any_policy_inputs() { | ||
| return Ok(None); | ||
| } | ||
|
|
||
| let snap = ledger_handle.snapshot().await; | ||
| fluree_db_api::build_policy_context( | ||
| fluree_db_api::build_transact_policy_context( | ||
| fluree, | ||
| &snap.snapshot, | ||
| snap.novelty.as_ref(), | ||
| Some(snap.novelty.as_ref()), | ||
| snap.t, | ||
| governance, | ||
| ) | ||
| .await | ||
| .map(Some) | ||
| .map_err(execution_failure) | ||
| } |
There was a problem hiding this comment.
build_policy_context dropped its if !governance.has_any_policy_inputs() { return Ok(None); } fast-path and now unconditionally calls build_transact_policy_context, which resolves the ledger #config (resolve_ledger_config) on every consensus commit, inside the for attempt in 1..=MAX_TXN_RETRIES retry loop in LocalCommitter::commit (line 116), and again per Raft commit worker. For unconfigured ledgers this is cheap — resolve_ledger_config has an O(1) guard (novelty segment_count + per-graph stats) that returns Ok(None) before any scan. But for configured ledgers it now adds one rdf:type f:LedgerConfig instance scan plus ~7 setting-group range reads to every write, even writes that carry no policy inputs and where config declares no policy — work the write path previously never did. This is the write hot path and it is feature-necessary (you must read config to learn f:policySource), so it is not a scaling regression (cost is bounded by the tiny single-subject config graph, indexed by SID, not O(novelty)) — but please confirm the config graph reads are genuinely constant and consider memoizing the resolved config per (ledger_id, to_t) so the retry loop and back-to-back writes don't re-resolve identical config. At minimum, worth a benchmark on a configured high-write-throughput ledger before merge.
// Consider: cache resolve_ledger_config by (ledger_id, to_t) — the config graph
// is immutable at a given t, and both wrap_policy (per query) and now
// build_transact_policy_context (per write + per retry) re-resolve it.| if !effective_opts.has_any_policy_inputs() { | ||
| return Ok(None); | ||
| } | ||
|
|
||
| let policy_graphs = policy_builder::resolve_policy_source_g_ids(source, snapshot)?; |
There was a problem hiding this comment.
The same-ledger branch early-returns Ok(None) on !has_any_policy_inputs() before calling resolve_policy_source_g_ids (line 409), whereas the read path (fluree_ext.rs:744) resolves the source graphs unconditionally and only then builds. Net effect: a config that declares an invalid same-ledger f:policySource (unknown graph selector) is a fail-closed error on reads but silently runs as root on writes when the request carries no policy inputs and config sets no f:defaultAllow/f:policyClass. The window is narrow (a meaningless config), but it is a read/write fail-closed divergence — the thing this PR exists to eliminate. Suggest validating the source before the gate:
// resolve (and validate) the same-ledger selector first, matching the read path's
// fail-closed-on-unknown-selector contract, then apply the no-inputs shortcut:
let policy_graphs = policy_builder::resolve_policy_source_g_ids(source, snapshot)?;
if !effective_opts.has_any_policy_inputs() {
return Ok(None);
}
Fixes #1416.
Problem
f:policySource(the config-graph setting that points policy rules at a specific graph) was honored on the read/query path but ignored on the write/modify paths, which hardcoded the default graph (g_id = 0). Two consequences:f:ledger— was enforced on queries but silently not enforced on transactions.f:policyClass,f:defaultAllow) never merged into write-time governance at all: a request with no policy inputs ran as root even on a fully configured ledger, while the same request's reads were policy-constrained.Fix
New single choke point:
build_transact_policy_context(policy_view.rs) — the write-side counterpart ofwrap_policy:to_tand merges config policy defaults viamerge_policy_opts, so config-declared policy governs writes even when the request carries no policy inputs.f:policySourceresolves to graph IDs viaresolve_policy_source_g_ids(fail-closed on unknown selectors).f:policySourceresolves live against the model ledger (ArtifactKind::PolicyRules, latest committed M) and interns the restrictions into the data ledger's term space. A cross-ledger source always builds a context, mirroring the read path.None(root) only when neither the request nor the config supplies any policy input — unchanged behavior for unconfigured ledgers.The cross-ledger block was extracted out of
wrap_policyinto a shared helper (resolve_cross_ledger_policy_restrictions), so the identity contract and thef:policyClassfilter chain are one implementation on both read and write paths.Call sites rewired
has_any_policy_inputs()gate → same-ledger onlylocal::build_policy_context(now takes&Fluree)credential_transactbuild_policy_context_from_opts(..., &[0])f:policyClassis configured)build_policy_ctx_for_push)&[0]stage_commit_flakestakesOption<&PolicyContext>graph_commit_builder)&[0]build_policy_contextblock_fetch&[0]Flureehandle available: same-ledger config resolution, fails closed on cross-ledger configsTests
New
it_policy_write_path.rs(registered ingrp_policy):f:policyClass/f:defaultAllowenforced on writes with no request inputsf:policySourcemodify-deny rejects a violating write (root control passes)f:AccessPolicymodify-deny; violating write to D rejected, untargeted write allowed — empty request optsAll four enforcement tests were verified to fail against a temp-revert to the previous hardcoded-
[0]behavior.Suites: grp_policy 53/53, grp_graphsource 101/101, grp_transact 159/159, consensus 48/48, server policy_integration 25/25, credential 2/2.
cargo check --workspace --all-featuresand clippy (all features, all targets on touched crates) clean.Docs
docs/security/policy-in-transactions.md— new "Config-driven write enforcement" sectiondocs/security/cross-ledger-policy.md— transactions engage automatically; queries keep the header gatedocs/ledger-config/setting-groups.md,docs/security/programmatic-policy.md,docs/design/cross-ledger-model-enforcement.mdSecond commit: identity binds
?$identityunder cross-ledger (bug fix on the first commit)The initial Phase 1a guard rejected any identity-carrying request against a cross-ledger
f:policySource, which made cross-ledger governance unusable for authenticated deployments (and made governed ledgers un-writable for identity-carrying writes). The follow-up commit replaces that with the intended contract:policy_class→ configf:policyClass→{f:AccessPolicy}(anonymous only). The config's class is threaded separately becausemerge_policy_optsreturns request opts unchanged when the request carries any policy input and override is permitted.resolve_identity_binding_sid, mirroring identity-mode's three-state binding contract) and populates?$identityfor M'sf:queryrules — it never selects rules. D-localf:policyClasstriples on the identity are intentionally not consulted: a cross-ledgerf:policySourcedeclares M the policy authority.Importantly, relaxing the guard alone would have been a silent fail-open:
build_policy_context_from_opts_innergave the identity branch unconditional priority and that branch ignored the cross-ledger restrictions. The inner builder now gives the cross-ledger branch priority, with the identity handled as a binding.New tests cover: the reported scenario end-to-end on writes (deny enforced, untargeted writes pass), an owner-only
f:queryrule in M driven by?$identityon both read and write, and both temp-revert failure modes (blanket guard → fail-closed regression; identity-branch-first → silent bypass).