Skip to content

fix(policy): enforce f:policySource and config policy defaults on writes#1419

Open
bplatz wants to merge 2 commits into
feature/shacl-class-value-setfrom
fix/1416-policy-source-write-path
Open

fix(policy): enforce f:policySource and config policy defaults on writes#1419
bplatz wants to merge 2 commits into
feature/shacl-class-value-setfrom
fix/1416-policy-source-write-path

Conversation

@bplatz

@bplatz bplatz commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Fixes #1416.

Stacked on feature/shacl-class-value-set — retarget to main once that branch merges; the two SHACL commits will drop out on rebase, leaving this as a single-commit diff.

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:

  1. A policy relocated into a named graph — or sourced cross-ledger from a model ledger via f:ledger — was enforced on queries but silently not enforced on transactions.
  2. Config-declared policy defaults (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 of wrap_policy:

  • Resolves the ledger config at to_t and merges config policy defaults via merge_policy_opts, so config-declared policy governs writes even when the request carries no policy inputs.
  • Same-ledger f:policySource resolves to graph IDs via resolve_policy_source_g_ids (fail-closed on unknown selectors).
  • Cross-ledger f:policySource resolves 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.
  • Returns 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_policy into a shared helper (resolve_cross_ledger_policy_restrictions), so the identity contract and the f:policyClass filter chain are one implementation on both read and write paths.

Call sites rewired

Path Before After
Consensus transact (LocalCommitter + Raft commit worker) has_any_policy_inputs() gate → same-ledger only new builder via local::build_policy_context (now takes &Fluree)
credential_transact build_policy_context_from_opts(..., &[0]) new builder (verified DID is bind-only under cross-ledger; works when a f:policyClass is configured)
Push replication (build_policy_ctx_for_push) &[0] new builder; stage_commit_flakes takes Option<&PolicyContext>
Commit-detail fetch (graph_commit_builder) &[0] new builder (per-request identity/policy_class opt-in gate preserved)
CLI local insert/upsert/update same-ledger build_policy_context new builder (flags-only gate preserved)
block_fetch &[0] no Fluree handle available: same-ledger config resolution, fails closed on cross-ledger configs

Tests

New it_policy_write_path.rs (registered in grp_policy):

  • config f:policyClass/f:defaultAllow enforced on writes with no request inputs
  • same-ledger named-graph f:policySource modify-deny rejects a violating write (root control passes)
  • cross-ledger: M holds an f:AccessPolicy modify-deny; violating write to D rejected, untargeted write allowed — empty request opts
  • identity + cross-ledger fails closed on the write builder only when no policy class is available anywhere (see second commit)
  • no config + no inputs still runs root

All 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-features and clippy (all features, all targets on touched crates) clean.

Docs

  • docs/security/policy-in-transactions.md — new "Config-driven write enforcement" section
  • docs/security/cross-ledger-policy.md — transactions engage automatically; queries keep the header gate
  • docs/ledger-config/setting-groups.md, docs/security/programmatic-policy.md, docs/design/cross-ledger-model-enforcement.md

Second commit: identity binds ?$identity under 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:

  • Class-filter chain for rule selection: request policy_class → config f:policyClass{f:AccessPolicy} (anonymous only). The config's class is threaded separately because merge_policy_opts returns request opts unchanged when the request carries any policy input and override is permitted.
  • Identity is bind-only: it resolves against D (new resolve_identity_binding_sid, mirroring identity-mode's three-state binding contract) and populates ?$identity for M's f:query rules — it never selects rules. D-local f:policyClass triples on the identity are intentionally not consulted: a cross-ledger f:policySource declares M the policy authority.
  • Fail-closed is retained only for the genuinely ambiguous case: identity present with no policy class anywhere.

Importantly, relaxing the guard alone would have been a silent fail-open: build_policy_context_from_opts_inner gave 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:query rule in M driven by ?$identity on both read and write, and both temp-revert failure modes (blanket guard → fail-closed regression; identity-branch-first → silent bypass).

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 aaj3f left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes sense and good fix

Comment on lines -365 to 399
/// 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)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +405 to +409
if !effective_opts.has_any_policy_inputs() {
return Ok(None);
}

let policy_graphs = policy_builder::resolve_policy_source_g_ids(source, snapshot)?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);
}

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